diff --git a/src/frontend/components/_atoms/tab/index.tsx b/src/frontend/components/_atoms/tab/index.tsx index 063f7840e..d40d6473b 100644 --- a/src/frontend/components/_atoms/tab/index.tsx +++ b/src/frontend/components/_atoms/tab/index.tsx @@ -1,3 +1,4 @@ +import { GitCompare } from 'lucide-react' import type React from 'react' import { ComponentPropsWithoutRef, useCallback } from 'react' @@ -55,6 +56,7 @@ const TabIcons: Record = { 'ethercat-device': , 'library-manager': , 'library-manifest': , + 'diff-viewer': , } const Tab = (props: ITabProps) => { @@ -84,7 +86,8 @@ const Tab = (props: ITabProps) => { | 'package-manager' | 'ethercat-device' | 'library-manager' - | 'library-manifest' = 'il' + | 'library-manifest' + | 'diff-viewer' = 'il' if (fileDerivation?.type === 'data-type' || fileDerivation?.type === 'device') { languageOrDerivation = fileDerivation?.derivation @@ -120,6 +123,9 @@ const Tab = (props: ITabProps) => { if (fileDerivation?.type === 'library-manifest') { languageOrDerivation = 'library-manifest' } + if (fileDerivation?.type === 'diff-viewer') { + languageOrDerivation = 'diff-viewer' + } const { file: associatedFile } = getFile({ name: fileName || '' }) const handleFileName = useCallback( diff --git a/src/frontend/components/_features/[workspace]/editor/diff-viewer/file-diff-view.tsx b/src/frontend/components/_features/[workspace]/editor/diff-viewer/file-diff-view.tsx new file mode 100644 index 000000000..134256630 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/diff-viewer/file-diff-view.tsx @@ -0,0 +1,92 @@ +/** + * FileDiffView — single entry point for rendering a before/after diff of one + * project file. Routes graphical POUs (.ld/.fbd) to the GraphicalDiffViewer + * and everything else to Monaco's DiffEditor. Shared by the history page and + * the source-control diff tab so both surfaces render diffs identically. + */ + +import { DiffEditor } from '@monaco-editor/react' + +import { GraphicalDiffViewer, isGraphicalFile } from './graphical-diff-viewer' + +export { isGraphicalFile } + +/** Map a file path to the Monaco language id used for syntax highlighting. */ +export function getLanguageFromPath(path: string): string { + const ext = path.split('.').pop()?.toLowerCase() + switch (ext) { + case 'json': + return 'json' + case 'st': + case 'il': + case 'sfc': + return 'st' + case 'py': + return 'python' + case 'c': + return 'c' + case 'cpp': + return 'cpp' + default: + return 'plaintext' + } +} + +/** + * For graphical files (.ld/.fbd) shown in the textual diff, the embedded JSON + * flow blob is noise — collapse it to a placeholder so the textual diff stays + * focused on the variable declarations. Non-graphical files pass through + * untouched. (Graphical files normally route to GraphicalDiffViewer, but this + * keeps the helper safe if it is ever used on the textual side.) + */ +export function formatContentForDisplay(path: string, content: string): string { + const ext = path.split('.').pop()?.toLowerCase() + if (ext !== 'ld' && ext !== 'fbd') return content + + const endMatch = content.match(/\b(END_PROGRAM|END_FUNCTION_BLOCK|END_FUNCTION)\b/i) + if (!endMatch || endMatch.index === undefined) return content + + const endKeyword = endMatch[0] + const beforeEnd = content.slice(0, endMatch.index) + + const endVarIdx = beforeEnd.lastIndexOf('END_VAR') + if (endVarIdx === -1) return content + + const declaration = beforeEnd.slice(0, endVarIdx + 'END_VAR'.length) + return `${declaration}\n\n(* ${ext.toUpperCase()} graphical data omitted *)\n\n${endKeyword}` +} + +type FileDiffViewProps = { + filePath: string + /** Original (e.g. HEAD / previous-commit) content. Empty string for added files. */ + original: string + /** Current (e.g. working-tree / commit) content. Empty string for deleted files. */ + current: string + isDark: boolean +} + +export function FileDiffView({ filePath, original, current, isDark }: FileDiffViewProps) { + if (isGraphicalFile(filePath)) { + return ( + + ) + } + + return ( + + ) +} diff --git a/src/frontend/components/_features/[workspace]/editor/diff-viewer/graphical-diff-viewer.tsx b/src/frontend/components/_features/[workspace]/editor/diff-viewer/graphical-diff-viewer.tsx new file mode 100644 index 000000000..918f2848e --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/diff-viewer/graphical-diff-viewer.tsx @@ -0,0 +1,371 @@ +/** + * GraphicalDiffViewer — Side-by-side LD/FBD flow comparison between two versions. + * + * Computes diffs via VersionControlPort.computeGraphicalDiff() (backend utility), + * then renders read-only ReactFlow instances with diff-colored nodes and edges. + * + * Shared by the history page, the merge page, and the source-control diff tab. + */ + +import type { Edge, Node, NodeProps } from '@xyflow/react' +import { Background, ReactFlow, ReactFlowProvider } from '@xyflow/react' +import { ChevronDown, ChevronRight } from 'lucide-react' +import { useMemo, useState } from 'react' + +import type { + DiffStatus, + FlowData, + GraphicalDiffResult, + VarDiffEntry, +} from '../../../../../../middleware/shared/ports/version-control-port' +import { useVersionControl } from '../../../../../../middleware/shared/providers' +import { cn } from '../../../../../utils/cn' +import { + EDGE_DIFF_STROKE, + fbdDiffNodeTypes, + ladderDiffNodeTypes, + VAR_DIFF_COLORS, +} from '../../../../_atoms/graphical-editor/diff' + +// --------------------------------------------------------------------------- +// Hidden edge node types (edges to/from these are filtered out) +// --------------------------------------------------------------------------- + +const HIDDEN_EDGE_NODE_TYPES = new Set(['placeholder', 'parallelPlaceholder', 'mockNode']) + +// --------------------------------------------------------------------------- +// Prepare flow data for ReactFlow rendering +// --------------------------------------------------------------------------- + +function prepareFlowForRender( + flow: FlowData, + diffMap: Map, + edgeDiffMap?: Map, + isLadder?: boolean, +) { + const nodes: Node[] = (flow.nodes as Node[]).map((node) => ({ + ...node, + data: { ...node.data, diffStatus: diffMap.get(node.id) ?? 'unchanged', nodeType: node.type }, + draggable: false, + selectable: false, + connectable: false, + })) + + const flowNodes = flow.nodes as Node[] + const edges: Edge[] = (flow.edges as Edge[]) + .filter((edge) => { + const src = flowNodes.find((n) => n.id === edge.source) + const tgt = flowNodes.find((n) => n.id === edge.target) + return src && tgt && !HIDDEN_EDGE_NODE_TYPES.has(src.type ?? '') && !HIDDEN_EDGE_NODE_TYPES.has(tgt.type ?? '') + }) + .map((edge) => { + const edgeStatus = edgeDiffMap?.get(edge.id) ?? 'unchanged' + const strokeColor = EDGE_DIFF_STROKE[edgeStatus] + return { + ...edge, + type: 'smoothstep', + selectable: false, + focusable: false, + style: strokeColor ? { stroke: strokeColor, strokeWidth: 2.5 } : isLadder ? { stroke: '#50545f' } : {}, + } + }) + + return { nodes, edges } +} + +// --------------------------------------------------------------------------- +// Variable diff section +// --------------------------------------------------------------------------- + +function VariableDiffSection({ entries, collapsible = false }: { entries: VarDiffEntry[]; collapsible?: boolean }) { + const [open, setOpen] = useState(!collapsible) + if (entries.length === 0) return null + + return ( +
+ {collapsible ? ( + + ) : ( +
+ Variables +
+ )} + {open && ( +
+ {entries.map((entry) => { + const v = entry.current ?? entry.original! + const prefix = entry.status === 'added' ? '+ ' : entry.status === 'removed' ? '- ' : '~ ' + const detail = `${v.name} : ${v.type}${v.initialValue ? ` := ${v.initialValue}` : ''}${v.location ? ` AT ${v.location}` : ''}` + return ( +
+ {prefix} + {detail} + {entry.status === 'modified' && entry.original && ( + + (was: {entry.original.type} + {entry.original.initialValue ? ` := ${entry.original.initialValue}` : ''}) + + )} +
+ ) + })} +
+ )} +
+ ) +} + +// --------------------------------------------------------------------------- +// Single rung cell (one ReactFlow instance) +// --------------------------------------------------------------------------- + +function RungCell({ + flow, + diffMap, + edgeDiffMap, + nodeTypes, + isDark, + height, + minWidth, + className, + isLadder, +}: { + flow: FlowData | null + diffMap: Map + edgeDiffMap?: Map + nodeTypes: Record> + isDark: boolean + height: number + minWidth?: number + className?: string + isLadder?: boolean +}) { + const { nodes, edges } = useMemo( + () => (flow ? prepareFlowForRender(flow, diffMap, edgeDiffMap, isLadder) : { nodes: [], edges: [] }), + [flow, diffMap, edgeDiffMap, isLadder], + ) + + // Block labels are rendered with `-top-[16px]` absolute positioning, so we + // need extra top padding in the ladder cell to keep them visible. + const ladderTopPadding = isLadder ? 20 : 0 + return ( +
+
+ + + {!isLadder && } + + +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Main exported component +// --------------------------------------------------------------------------- + +export function GraphicalDiffViewer({ + originalContent, + currentContent, + filePath, + isDark, + originalLabel = 'Previous', + currentLabel = 'Current', + showOriginalSide = true, +}: { + originalContent: string + currentContent: string + filePath: string + isDark: boolean + originalLabel?: string + currentLabel?: string + /** When false, render only the "current" side with diff coloring (no before/after). */ + showOriginalSide?: boolean +}) { + const versionControl = useVersionControl() + + const diffResult: GraphicalDiffResult | null = useMemo(() => { + if (!versionControl) return null + return versionControl.computeGraphicalDiff(originalContent, currentContent, filePath) + }, [originalContent, currentContent, filePath, versionControl]) + + const isLadder = diffResult?.isLadder ?? filePath.endsWith('.ld') + const nodeTypes = useMemo(() => (isLadder ? ladderDiffNodeTypes : fbdDiffNodeTypes), [isLadder]) + + if (!diffResult) { + return ( +
+

Could not parse graphical data for diff view

+
+ ) + } + + const { flows, changedIndexes, variableDiff, nodeDiffMaps, edgeDiffMaps } = diffResult + + return ( +
+ {/* FBD: side-by-side header */} + {!isLadder && showOriginalSide && ( +
+
+ {originalLabel} +
+
+
+ {currentLabel} +
+
+ )} + + {/* Scrollable content (only when we own the viewport — otherwise the parent scrolls) */} +
+ {/* Variables render BEFORE rungs in history mode, AFTER in merge mode + (focus on rungs when resolving conflicts). */} + {showOriginalSide && variableDiff.length > 0 && } + + {changedIndexes.length === 0 && variableDiff.length === 0 && ( +
+

No graphical changes detected

+
+ )} + + {changedIndexes.map((i) => { + const { original, current, originalHeight, currentHeight, originalWidth, currentWidth } = flows[i] + const rungEdgeDiff = edgeDiffMaps[i] + + return ( +
+
+ {isLadder ? `Rung ${i + 1}` : 'Diagram'} +
+ + {isLadder ? ( + <> + {showOriginalSide && + (original ? ( + <> +
+ {originalLabel} +
+ >} + isDark={isDark} + height={originalHeight} + minWidth={originalWidth} + isLadder + /> + + ) : ( +
+

New rung

+
+ ))} + {current ? ( + <> + {showOriginalSide && ( +
+ {currentLabel} +
+ )} + >} + isDark={isDark} + height={currentHeight} + minWidth={currentWidth} + isLadder + /> + + ) : ( +
+

Rung removed

+
+ )} + + ) : ( +
+ {showOriginalSide && + (original ? ( + >} + isDark={isDark} + height={originalHeight} + className='min-w-0 flex-1' + /> + ) : ( +
+

New diagram

+
+ ))} + {showOriginalSide &&
} + {current ? ( + >} + isDark={isDark} + height={currentHeight} + className='min-w-0 flex-1' + /> + ) : ( +
+

Diagram removed

+
+ )} +
+ )} +
+ ) + })} + + {/* In merge mode, variables show collapsed AFTER the rungs so the user's focus stays on the diagrams. */} + {!showOriginalSide && variableDiff.length > 0 && } +
+
+ ) +} + +export function isGraphicalFile(path: string): boolean { + const ext = path.split('.').pop()?.toLowerCase() + return ext === 'ld' || ext === 'fbd' +} diff --git a/src/frontend/components/_features/[workspace]/editor/diff-viewer/index.tsx b/src/frontend/components/_features/[workspace]/editor/diff-viewer/index.tsx new file mode 100644 index 000000000..ed998f0b5 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/diff-viewer/index.tsx @@ -0,0 +1,140 @@ +/** + * DiffViewerEditor — the read-only "source control diff" editor tab. + * + * Opened from the Source Control panel when the user clicks a changed file. + * Renders the same Working Tree ↔ HEAD comparison as the commit-details page: + * - original (HEAD): the committed content of the file, taken straight from + * the backend's `/changes?includeContent=true` response (the `before` + * field). The backend computes this against the actually checked-out HEAD, + * so there's no client-side guessing about commit ordering or branch. + * - current (working tree): `buildAllProjectFileContents()`, which echoes the + * raw loaded bytes for files untouched this session (so a pre-existing + * pending change diffs raw-vs-raw, no serialization noise) and the freshly + * serialized form for files edited this session — keeping the diff live. + * + * The HEAD `before` map is cached in the version-control slice (`headContent`) + * and invalidated on project load / commit / in-place reload. + */ + +import { useEffect, useMemo } from 'react' + +import { useVersionControl } from '../../../../../../middleware/shared/providers' +import { buildAllProjectFileContents } from '../../../../../services/save-actions' +import { useOpenPLCStore } from '../../../../../store' +import { cn } from '../../../../../utils/cn' +import { FileDiffView } from './file-diff-view' + +export { FileDiffView, getLanguageFromPath, isGraphicalFile } from './file-diff-view' +export { GraphicalDiffViewer } from './graphical-diff-viewer' + +// Mirrors the status badge on the commit-details (history) page so the +// source-control diff tab reads identically. +type FileStatus = 'A' | 'M' | 'D' | 'U' + +const FILE_STATUS_CONFIG: Record = { + A: { label: 'Added', badge: 'bg-green-500/10 text-green-500' }, + M: { label: 'Modified', badge: 'bg-yellow-500/10 text-yellow-500' }, + D: { label: 'Deleted', badge: 'bg-red-500/10 text-red-500' }, + U: { label: 'Unchanged', badge: 'bg-neutral-500/10 text-neutral-400' }, +} + +function deriveStatus(original: string, current: string): FileStatus { + if (original === current) return 'U' + if (!original) return 'A' + if (!current) return 'D' + return 'M' +} + +export function DiffViewerEditor() { + const editor = useOpenPLCStore((s) => s.editor) + const projectId = useOpenPLCStore((s) => s.project.meta.path) + const versionControl = useVersionControl() + + // Re-render (and recompute `current` below) whenever project state changes + // so the diff stays live as the user edits. + const project = useOpenPLCStore((s) => s.project) + + // Per-path HEAD (committed) content of the changed files — the `before` side + // of each diff. `null` = not yet loaded → fetched lazily below. + const headContent = useOpenPLCStore((s) => s.versionControl.headContent) + const setHeadContent = useOpenPLCStore((s) => s.versionControlActions.setHeadContent) + + const filePath = editor.type === 'diff-viewer' ? editor.meta.filePath : '' + + // Lazily fetch the HEAD content of all pending files via the backend's + // content-bearing /changes call (authoritative against the real HEAD), and + // cache the `before` map. Invalidated on load / commit / reload. + useEffect(() => { + if (headContent !== null || !projectId || !versionControl) return + let cancelled = false + void (async () => { + try { + const { changes } = await versionControl.getChanges(projectId, undefined, true) + const map: Record = {} + for (const c of changes) map[c.path] = c.before ?? '' + if (!cancelled) setHeadContent(map) + } catch { + if (!cancelled) setHeadContent({}) + } + })() + return () => { + cancelled = true + } + }, [headContent, projectId, versionControl, setHeadContent]) + + const original = headContent && filePath ? (headContent[filePath] ?? '') : '' + + // The working-tree side: raw loaded bytes for files untouched this session, + // freshly serialized for edited ones. Recomputed when `project` changes. + const current = useMemo( + () => { + if (!filePath) return '' + try { + return buildAllProjectFileContents()[filePath] ?? '' + } catch { + return '' + } + }, + // `project` drives recomputation; `buildAllProjectFileContents` reads the + // live store internally. + // eslint-disable-next-line react-hooks/exhaustive-deps + [filePath, project], + ) + + if (editor.type !== 'diff-viewer') return null + + const isDark = document.documentElement.classList.contains('dark') + + // Card chrome + header identical to the commit-details (history) page panel. + return ( +
+
+
+

{filePath}

+ {headContent !== null && ( + + {FILE_STATUS_CONFIG[deriveStatus(original, current)].label} + + )} +
+
+ {headContent === null ? ( +
+
+
+

Loading diff…

+
+
+ ) : ( + + )} +
+
+
+ ) +} diff --git a/src/frontend/components/_features/[workspace]/source-control/changes-section.tsx b/src/frontend/components/_features/[workspace]/source-control/changes-section.tsx index 78b908ebd..a7f7992a4 100644 --- a/src/frontend/components/_features/[workspace]/source-control/changes-section.tsx +++ b/src/frontend/components/_features/[workspace]/source-control/changes-section.tsx @@ -1,5 +1,4 @@ -import Editor from '@monaco-editor/react' -import { File, Folder, FolderOpen, X } from 'lucide-react' +import { File, Folder, FolderOpen } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { PendingChange } from '../../../../../middleware/shared/ports/version-control-port' @@ -38,68 +37,6 @@ const STATUS_TOOLTIP: Record = { deleted: 'Deleted -- File has been removed', } -// --------------------------------------------------------------------------- -// File content resolution & preview -// --------------------------------------------------------------------------- - -function getLanguageFromPath(path: string): string { - const ext = path.split('.').pop()?.toLowerCase() - switch (ext) { - case 'json': - return 'json' - case 'st': - case 'il': - case 'ld': - case 'fbd': - return 'st' - case 'py': - return 'python' - case 'cpp': - return 'cpp' - default: - return 'plaintext' - } -} - -function FilePreviewModal({ filePath, content, onClose }: { filePath: string; content: string; onClose: () => void }) { - const isDark = document.documentElement.classList.contains('dark') - - return ( -
-
e.stopPropagation()} - > -
- {filePath} - -
-
- -
-
-
- ) -} - // --------------------------------------------------------------------------- // Tree types & helpers // --------------------------------------------------------------------------- @@ -280,14 +217,11 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { const { versionControlActions, sharedWorkspaceActions, - project, tabsActions: { updateTabs }, editorActions: { setEditor, addModel, getEditorFromEditors }, } = useOpenPLCStore() const canEdit = useOpenPLCStore((s) => s.workspace.canEdit) - const pous = project.data.pous - // System files (e.g. legacy `git-data.tar.gz` from migration) ride along on // commits silently — they're never shown, never selectable, never discardable. // We keep them in a separate bucket so the UI never has to filter them out @@ -308,7 +242,6 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { const [showStashModal, setShowStashModal] = useState(false) const [isStashing, setIsStashing] = useState(false) const [expandedFolders, setExpandedFolders] = useState>(new Set()) - const [previewFile, setPreviewFile] = useState<{ path: string; content: string } | null>(null) const tree = useMemo(() => buildChangesTree(visibleFiles), [visibleFiles]) @@ -420,51 +353,28 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { }) } + // Clicking any changed file opens a read-only diff tab in the editor area + // (Working Tree ↔ HEAD), reusing the same diff view as the history page. + // The tab name is namespaced with `Diff: ` so it never collides with the + // editable POU tab of the same POU. const handleFileClick = useCallback( (filePath: string) => { - // POU files open in the editor - if (filePath.startsWith('pous/')) { - const filename = filePath.split('/').pop() ?? '' - const dotIndex = filename.lastIndexOf('.') - if (dotIndex === -1) return - const pouName = filename.substring(0, dotIndex) - - const pou = pous.find((p) => p.name === pouName) - if (!pou) return - - const tabToBeCreated = { - name: pou.name, - path: `/pous/${pou.pouType}s/${pou.name}`, - elementType: { type: pou.pouType, language: pou.body.language }, - } as TabsProps - - updateTabs(tabToBeCreated) - const editorObj = getEditorFromEditors(pouName) - if (!editorObj) { - const model = CreateEditorObjectFromTab(tabToBeCreated) - addModel(model) - setEditor(model) - return - } - addModel(editorObj) - setEditor(editorObj) - return + const tab: TabsProps = { + name: `Diff: ${filePath}`, + elementType: { type: 'diff-viewer', filePath }, } - // Non-POU files: resolve content via the same canonical serializer - // the save flow uses. Building ad-hoc shapes here previously made the - // preview diverge from what got committed (e.g. `project.json` showed - // {name,type,path} while save wrote {meta,data,...}). - try { - const content = buildAllProjectFileContentsPure()[filePath] - if (content !== undefined) { - setPreviewFile({ path: filePath, content }) - } - } catch { - // Serialization failed — ignore + updateTabs(tab) + const existing = getEditorFromEditors(tab.name) + if (existing) { + setEditor(existing) + return } + const model = CreateEditorObjectFromTab(tab) + addModel(model) + setEditor(model) }, - [pous, updateTabs, getEditorFromEditors, addModel, setEditor], + [updateTabs, getEditorFromEditors, addModel, setEditor], ) const hasChanges = visibleFiles.length > 0 @@ -773,14 +683,6 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { onConfirm={(stashMessage) => void handleStash(stashMessage)} onCancel={() => setShowStashModal(false)} /> - - {previewFile && ( - setPreviewFile(null)} - /> - )}
) } diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index 3190839ee..852aaf90d 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -19,6 +19,7 @@ import { DataTypeEditor } from '../components/_features/[workspace]/data-type' import { DeviceEditor } from '../components/_features/[workspace]/editor/device' import { EtherCATDeviceEditor, EtherCATEditor } from '../components/_features/[workspace]/editor/device/ethercat' import { RemoteDeviceEditor } from '../components/_features/[workspace]/editor/device/remote-device' +import { DiffViewerEditor } from '../components/_features/[workspace]/editor/diff-viewer' import { GraphicalEditor } from '../components/_features/[workspace]/editor/graphical' import { LibraryManagerEditor } from '../components/_features/[workspace]/editor/library-manager' import { LibraryManifestEditor } from '../components/_features/[workspace]/editor/library-manifest' @@ -530,6 +531,7 @@ const WorkspaceScreen = () => { {editor['type'] === 'plc-package-manager' && } {editor['type'] === 'plc-library-manager' && } {editor['type'] === 'plc-library-manifest' && } + {editor['type'] === 'diff-viewer' && } {/* EtherCAT device editors — multi-instance (one tab per `deviceId`). Kept mounted across tab switches diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 7facad445..b8d013888 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -1457,6 +1457,21 @@ describe('createSharedSlice', () => { expect(store.getState().editor.type).toBe('available') }) + it('selects a diff-viewer next tab with a null project-tree leaf', () => { + // A diff-viewer tab has no project-tree leaf to highlight, so when it + // becomes the active tab after a close its leaf type must be null. + store.getState().tabsActions.updateTabs({ + name: 'Diff: pous/programs/Main.st', + elementType: { type: 'diff-viewer', filePath: 'pous/programs/Main.st' }, + }) + store.getState().pouActions.create({ type: 'program', name: 'PouA', language: 'st' }) + + store.getState().sharedWorkspaceActions.forceCloseFile('PouA') + + expect(store.getState().editor.type).toBe('diff-viewer') + expect(store.getState().workspace.selectedProjectTreeLeaf.type).toBeNull() + }) + it('does not resurrect the closed model in editors[]', () => { // Multi-mount keeps every open POU's editor model in `editors[]`. // `forceCloseFile` removes the active model from `editors[]` diff --git a/src/frontend/store/__tests__/tabs-utils.test.ts b/src/frontend/store/__tests__/tabs-utils.test.ts index ab967e423..96806562e 100644 --- a/src/frontend/store/__tests__/tabs-utils.test.ts +++ b/src/frontend/store/__tests__/tabs-utils.test.ts @@ -1,6 +1,7 @@ import type { TabsProps } from '../slices/tabs/types' import { CreateDeviceEditor, + CreateDiffViewerEditor, CreateEditorModelObject, CreateEditorObjectFromTab, CreatePLCGraphicalObject, @@ -224,6 +225,19 @@ describe('tabs/utils', () => { }) }) + // ------------------------------------------------------------------------- + // CreateDiffViewerEditor + // ------------------------------------------------------------------------- + describe('CreateDiffViewerEditor', () => { + it('creates a diff-viewer editor carrying name + filePath', () => { + const result = CreateDiffViewerEditor('Diff: pous/programs/Main.st', 'pous/programs/Main.st') + expect(result).toEqual({ + type: 'diff-viewer', + meta: { name: 'Diff: pous/programs/Main.st', filePath: 'pous/programs/Main.st' }, + }) + }) + }) + // ------------------------------------------------------------------------- // CreateEditorObjectFromTab // ------------------------------------------------------------------------- @@ -276,5 +290,18 @@ describe('tabs/utils', () => { const result = CreateEditorObjectFromTab(tab) expect(result.type).toBe('plc-server') }) + + it('creates editor from diff-viewer tab', () => { + const tab: TabsProps = { + name: 'Diff: devices/configuration.json', + elementType: { type: 'diff-viewer', filePath: 'devices/configuration.json' }, + } + const result = CreateEditorObjectFromTab(tab) + expect(result.type).toBe('diff-viewer') + if (result.type === 'diff-viewer') { + expect(result.meta.filePath).toBe('devices/configuration.json') + expect(result.meta.name).toBe('Diff: devices/configuration.json') + } + }) }) }) diff --git a/src/frontend/store/__tests__/version-control-slice.test.ts b/src/frontend/store/__tests__/version-control-slice.test.ts new file mode 100644 index 000000000..876f79111 --- /dev/null +++ b/src/frontend/store/__tests__/version-control-slice.test.ts @@ -0,0 +1,172 @@ +import { createStore } from 'zustand/vanilla' + +import { createVersionControlSlice } from '../slices/version-control/slice' +import type { VersionControlSlice } from '../slices/version-control/types' + +function makeStore() { + return createStore()(createVersionControlSlice) +} + +describe('createVersionControlSlice', () => { + let store: ReturnType + + beforeEach(() => { + store = makeStore() + }) + + const vc = () => store.getState().versionControl + const actions = () => store.getState().versionControlActions + + it('starts with sane defaults', () => { + expect(vc().activePanel).toBe('explorer') + expect(vc().selectedCommitHash).toBeNull() + expect(vc().headContent).toBeNull() + expect(vc().pendingChangesCount).toBe(0) + }) + + it('setActivePanel switches the panel', () => { + actions().setActivePanel('source-control') + expect(vc().activePanel).toBe('source-control') + }) + + it('setSelectedCommitHash sets and clears the hash', () => { + actions().setSelectedCommitHash('abc123') + expect(vc().selectedCommitHash).toBe('abc123') + actions().setSelectedCommitHash(null) + expect(vc().selectedCommitHash).toBeNull() + }) + + // --------------------------------------------------------------------------- + // headContent (source-control diff HEAD snapshot) + // --------------------------------------------------------------------------- + describe('setHeadContent', () => { + it('stores a copy of the provided snapshot', () => { + const snapshot = { 'pous/programs/Main.st': 'PROGRAM Main\nEND_PROGRAM' } + actions().setHeadContent(snapshot) + expect(vc().headContent).toEqual(snapshot) + // Stored value is a copy, not the same reference. + expect(vc().headContent).not.toBe(snapshot) + }) + + it('clears the snapshot when passed null', () => { + actions().setHeadContent({ 'a.st': 'x' }) + actions().setHeadContent(null) + expect(vc().headContent).toBeNull() + }) + }) + + it('initBaseline resets the cached HEAD snapshot to null', () => { + actions().setHeadContent({ 'a.st': 'x' }) + actions().initBaseline({ + initialPending: [{ path: 'a.st', status: 'modified' }], + baselineContent: { 'a.st': 'serialized' }, + rawLoadedContent: { 'a.st': 'raw' }, + loadedSerialized: { 'a.st': 'serialized' }, + }) + expect(vc().headContent).toBeNull() + // Raw text is preferred over serialized in the baseline. + expect(vc().baselineContent['a.st']).toBe('raw') + expect(vc().loadedSerialized['a.st']).toBe('serialized') + expect(vc().pendingChangesCount).toBe(1) + }) + + it('initBaseline falls back to baselineContent when raw/serialized omitted', () => { + actions().initBaseline({ + initialPending: [], + baselineContent: { 'a.st': 'base' }, + }) + expect(vc().baselineContent['a.st']).toBe('base') + expect(vc().loadedSerialized['a.st']).toBe('base') + expect(vc().rawLoadedContent).toEqual({}) + }) + + it('syncFromChanges replaces the pending set and clears changedPaths', () => { + actions().initBaseline({ initialPending: [], baselineContent: {} }) + actions().syncFromChanges([ + { path: 'a.st', status: 'modified' }, + { path: 'a.st', status: 'modified' }, // duplicate is deduped + { path: 'b.st', status: 'added' }, + ]) + expect(vc().pendingChangesCount).toBe(2) + }) + + describe('recordSavedFiles', () => { + it('adds, clears, and skips paths per baseline / initialPending', () => { + actions().initBaseline({ + initialPending: [{ path: 'pending.st', status: 'modified' }], + baselineContent: { 'clean.st': 'same', 'pending.st': 'p' }, + rawLoadedContent: { 'clean.st': 'same', 'pending.st': 'p' }, + loadedSerialized: { 'clean.st': 'same', 'pending.st': 'p' }, + }) + + actions().recordSavedFiles({ + saved: [ + { path: 'clean.st', content: 'same' }, // matches baseline → not changed + { path: 'edited.st', content: 'new' }, // differs → changed + { path: 'pending.st', content: 'p2' }, // in initialPending → skipped + ], + deleted: [], + }) + + expect(vc().changedPaths).toContain('edited.st') + expect(vc().changedPaths).not.toContain('clean.st') + // rawLoadedContent mirrors the just-saved content. + expect(vc().rawLoadedContent['edited.st']).toBe('new') + }) + + it('handles deletions across initialPending and baseline cases', () => { + actions().initBaseline({ + initialPending: [ + { path: 'added.st', status: 'added' }, + { path: 'mod.st', status: 'modified' }, + ], + baselineContent: { 'mod.st': 'm', 'tracked.st': 't' }, + }) + + actions().recordSavedFiles({ + saved: [], + deleted: [ + 'added.st', // initialPending 'added' → removed from pending + 'mod.st', // initialPending 'modified' → stays pending + 'tracked.st', // not pending, in baseline → becomes pending + 'session.st', // not pending, not in baseline → cancels out + ], + }) + + const pendingPaths = vc().initialPending.map((e) => e.path) + expect(pendingPaths).toContain('mod.st') + expect(pendingPaths).not.toContain('added.st') + expect(vc().changedPaths).toContain('tracked.st') + expect(vc().changedPaths).not.toContain('session.st') + }) + }) + + it('commitBaseline refreshes baseline, clears pending, and invalidates HEAD', () => { + actions().initBaseline({ + initialPending: [{ path: 'a.st', status: 'modified' }], + baselineContent: { 'a.st': 'old' }, + }) + actions().setHeadContent({ 'a.st': 'old' }) + + actions().commitBaseline({ + newBaseline: { 'a.st': 'new' }, + loadedSerialized: { 'a.st': 'new' }, + }) + + expect(vc().baselineContent['a.st']).toBe('new') + expect(vc().rawLoadedContent['a.st']).toBe('new') + expect(vc().initialPending).toEqual([]) + expect(vc().changedPaths).toEqual([]) + expect(vc().pendingChangesCount).toBe(0) + expect(vc().headContent).toBeNull() + }) + + it('clearVersionControlState resets everything', () => { + actions().setActivePanel('source-control') + actions().setHeadContent({ 'a.st': 'x' }) + actions().clearVersionControlState() + expect(vc().activePanel).toBe('explorer') + expect(vc().headContent).toBeNull() + expect(vc().pendingChangesCount).toBe(0) + }) +}) diff --git a/src/frontend/store/slices/editor/types.ts b/src/frontend/store/slices/editor/types.ts index b0c52c69b..dc146aba0 100644 --- a/src/frontend/store/slices/editor/types.ts +++ b/src/frontend/store/slices/editor/types.ts @@ -199,6 +199,18 @@ export type EditorModel = EditorModelBase & deviceId: string } } + | { + /** Read-only source-control diff tab. Carries only the project- + * relative `filePath`; the original (HEAD) and current (working- + * tree) contents are derived live from the store at render time so + * the diff stays fresh as the user edits. `name` is the unique tab + * key (e.g. `Diff: pous/programs/Main.st`). */ + type: 'diff-viewer' + meta: { + name: string + filePath: string + } + } ) // --------------------------------------------------------------------------- diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 5ec48a957..60dcc419a 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -478,7 +478,9 @@ const createSharedSlice: StateCreator = (s getState().tabsActions.setSelectedTab(nextTab.name) getState().workspaceActions.setSelectedProjectTreeLeaf({ label: nextTab.name, - type: nextTab.elementType.type, + // A diff-viewer tab has no corresponding project-tree leaf to + // highlight, so it maps to `null` rather than a tree leaf type. + type: nextTab.elementType.type === 'diff-viewer' ? null : nextTab.elementType.type, }) return { success: true } @@ -523,6 +525,10 @@ const createSharedSlice: StateCreator = (s handleOpenProjectResponse: (data) => { getState().sharedWorkspaceActions.clearStatesOnCloseProject() getState().workspaceActions.setEditingState('saved') + // Any in-place reload (branch switch, restore, discard, stash) can move + // HEAD, so drop the cached HEAD snapshot used by source-control diffs; + // it refetches lazily on the next diff open. + getState().versionControlActions.setHeadContent(null) // Apply the persist-permission flag from the backend. `canEdit === // false` ⇒ the viewer can't push changes back (e.g. a public project // they don't own), so backend writes (save/commit/branch) are gated; diff --git a/src/frontend/store/slices/tabs/types.ts b/src/frontend/store/slices/tabs/types.ts index 6e0ff3016..53608ad8c 100644 --- a/src/frontend/store/slices/tabs/types.ts +++ b/src/frontend/store/slices/tabs/types.ts @@ -19,6 +19,7 @@ export type TabsProps = { | { type: 'library-manager' } | { type: 'library-manifest' } | { type: 'ethercat-device'; busName: string; deviceId: string } + | { type: 'diff-viewer'; filePath: string } configuration?: Record } diff --git a/src/frontend/store/slices/tabs/utils.ts b/src/frontend/store/slices/tabs/utils.ts index 8f4dba72a..5e60d201d 100644 --- a/src/frontend/store/slices/tabs/utils.ts +++ b/src/frontend/store/slices/tabs/utils.ts @@ -139,6 +139,15 @@ const CreateLibraryManifestEditor = (name = LIBRARY_MANIFEST_TAB_NAME): EditorMo meta: { name }, }) +/** Read-only source-control diff tab. The tab `name` doubles as the unique + * editor key, so it must not collide with the editable POU tab of the same + * POU — callers pass a `Diff: ` style name. `filePath` is the + * project-relative path the diff view resolves its before/after content from. */ +const CreateDiffViewerEditor = (name: string, filePath: string): EditorModel => ({ + type: 'diff-viewer', + meta: { name, filePath }, +}) + const CreateEditorObjectFromTab = (tab: TabsProps): EditorModel => { const { elementType, name } = tab switch (elementType.type) { @@ -168,11 +177,14 @@ const CreateEditorObjectFromTab = (tab: TabsProps): EditorModel => { return CreateLibraryManagerEditor(name) case 'library-manifest': return CreateLibraryManifestEditor(name) + case 'diff-viewer': + return CreateDiffViewerEditor(name, elementType.filePath) } } export { CreateDeviceEditor, + CreateDiffViewerEditor, CreateEditorModelObject, CreateEditorObjectFromTab, CreateEtherCATDeviceEditor, diff --git a/src/frontend/store/slices/version-control/slice.ts b/src/frontend/store/slices/version-control/slice.ts index a4d32b819..e4cc235e7 100644 --- a/src/frontend/store/slices/version-control/slice.ts +++ b/src/frontend/store/slices/version-control/slice.ts @@ -12,6 +12,7 @@ const initialState: VersionControlSlice['versionControl'] = { loadedSerialized: {}, changedPaths: [], pendingChangesCount: 0, + headContent: null, } function dedupeByPath(entries: InitialPendingEntry[]): InitialPendingEntry[] { @@ -50,6 +51,13 @@ const createVersionControlSlice: StateCreator | null) => + setState( + produce((draft) => { + draft.versionControl.headContent = content ? { ...content } : null + }), + ), + initBaseline: ({ initialPending, baselineContent, rawLoadedContent, loadedSerialized }) => setState( produce((draft) => { @@ -74,6 +82,9 @@ const createVersionControlSlice: StateCreator | null } } @@ -60,6 +72,9 @@ export type SavedFileRecord = { path: string; content: string } export type VersionControlActions = { setActivePanel: (panel: SidePanel) => void setSelectedCommitHash: (hash: string | null) => void + /** Set (or clear, with `null`) the lazily-fetched HEAD snapshot used as the + * "original" side of source-control diffs. */ + setHeadContent: (content: Record | null) => void /** * Snapshot baseline + initial pending at the last "in-sync" point * (project load, after restore, after discard). diff --git a/src/middleware/shared/ports/version-control-port.ts b/src/middleware/shared/ports/version-control-port.ts index 440b2dd59..3b77eb8b4 100644 --- a/src/middleware/shared/ports/version-control-port.ts +++ b/src/middleware/shared/ports/version-control-port.ts @@ -64,6 +64,12 @@ export interface CommitInfo { export interface PendingChange { path: string status: 'added' | 'modified' | 'deleted' + /** HEAD (committed) content. Present only when getChanges is called with + * `includeContent`. Empty string for added files. */ + before?: string + /** Working-tree content. Present only when getChanges is called with + * `includeContent`. Empty string for deleted files. */ + after?: string } export interface Stash { @@ -168,8 +174,16 @@ export interface VersionControlPort { /** Restore the project to a previous commit state. */ restoreCommit(projectId: string, hash: string, branch?: string): Promise<{ message: string; restoredCommit: Commit }> - /** Get pending (uncommitted) changes. */ - getChanges(projectId: string, branch?: string): Promise<{ changes: PendingChange[]; hasChanges: boolean }> + /** + * Get pending (uncommitted) changes. When `includeContent` is true, each + * change also carries `before` (HEAD) and `after` (working-tree) content so + * the caller can render a diff without further requests. + */ + getChanges( + projectId: string, + branch?: string, + includeContent?: boolean, + ): Promise<{ changes: PendingChange[]; hasChanges: boolean }> /** Discard pending changes. Optionally specify which files to discard. */ discardChanges(projectId: string, files?: string[], branch?: string): Promise