From c5596f2f32ad02517a6a8ea0a78c051fd5202676 Mon Sep 17 00:00:00 2001 From: JulioSergioFS Date: Mon, 27 Apr 2026 21:29:26 -0300 Subject: [PATCH 1/5] feat(version-control): implement enhanced version control features --- .../branches/create-branch-popover.tsx | 186 ++++++++++++++ .../[workspace]/editor/graphical/index.tsx | 2 +- .../source-control/changes-section.tsx | 70 ++++-- .../_molecules/project-tree/index.tsx | 42 +++- .../modals/delete-confirmation-modal.tsx | 24 +- src/frontend/services/save-actions.ts | 236 ++++++++++++++++-- src/frontend/store/slices/project/slice.ts | 22 +- .../store/slices/version-control/slice.ts | 151 ++++++++++- .../store/slices/version-control/types.ts | 86 ++++++- src/frontend/utils/sanitize-branch-name.ts | 124 +++++++++ src/frontend/utils/system-files.ts | 13 + src/frontend/utils/version-control-content.ts | 28 +++ src/middleware/shared/ports/project-port.ts | 7 + 13 files changed, 939 insertions(+), 52 deletions(-) create mode 100644 src/frontend/components/_features/[workspace]/branches/create-branch-popover.tsx create mode 100644 src/frontend/utils/sanitize-branch-name.ts create mode 100644 src/frontend/utils/system-files.ts create mode 100644 src/frontend/utils/version-control-content.ts diff --git a/src/frontend/components/_features/[workspace]/branches/create-branch-popover.tsx b/src/frontend/components/_features/[workspace]/branches/create-branch-popover.tsx new file mode 100644 index 000000000..fc7d17bb1 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/branches/create-branch-popover.tsx @@ -0,0 +1,186 @@ +import * as Popover from '@radix-ui/react-popover' +import { useMemo, useState } from 'react' + +import { useVersionControl } from '../../../../../middleware/shared/providers' +import { cn } from '../../../../utils/cn' +import { getBranchNameFeedback } from '../../../../utils/sanitize-branch-name' + +type CreateBranchPopoverProps = { + projectId: string + onCreated?: (name: string) => void + onCloseParent?: () => void +} + +export function CreateBranchPopover({ projectId, onCreated, onCloseParent }: CreateBranchPopoverProps) { + const versionControl = useVersionControl() + const [isOpen, setIsOpen] = useState(false) + const [name, setName] = useState('') + const [error, setError] = useState('') + const [isPending, setIsPending] = useState(false) + + const handleCancel = () => { + setIsOpen(false) + setName('') + setError('') + } + + // Live sanitization preview. The user can type freely; submitting always + // uses `feedback.sanitized` so the backend never sees forbidden chars. + const feedback = useMemo(() => getBranchNameFeedback(name), [name]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + const finalName = feedback.sanitized + + if (!finalName) { + setError(feedback.error ?? 'Branch name cannot be empty') + return + } + if (!versionControl) { + setError('Version control unavailable') + return + } + + setError('') + setIsPending(true) + try { + await versionControl.createBranch(projectId, finalName) + onCreated?.(finalName) + setIsOpen(false) + setName('') + setError('') + onCloseParent?.() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create branch') + } finally { + setIsPending(false) + } + } + + return ( + { + setIsOpen(open) + if (!open) { + setName('') + setError('') + } + }} + > + +
+ + + + + Create new branch + + + + +
+
+ + +
e.stopPropagation()} + > +
+
+ + + +

+ New Branch +

+
+
+
+
+
+ + { + setName(e.target.value) + setError('') + }} + placeholder='feature/my-branch' + autoFocus + disabled={isPending} + className='mb-1 mt-[6px] h-[30px] w-full rounded-md border border-neutral-100 bg-white px-2 py-2 text-cp-sm font-medium text-neutral-850 outline-none dark:border-brand-medium-dark dark:bg-neutral-950 dark:text-neutral-300' + /> + {error ? ( + + * {error} + + ) : feedback.error ? ( + + * {feedback.error} + + ) : feedback.changed && feedback.sanitized ? ( +
+ + Will be created as:{' '} + + {feedback.sanitized} + + + {feedback.notes.map((note) => ( + + · {note} + + ))} +
+ ) : ( + + ** Spaces and special characters are not allowed + + )} +
+
+ + + + +
+
+
+ + + + ) +} diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/index.tsx index 7fe04f508..d69539cfa 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/index.tsx @@ -25,7 +25,7 @@ const GraphicalEditor = ({ language, readOnly }: GraphicalEditorProps) => { {readOnly && (
)} -
+
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 6228cff46..e57a53aff 100644 --- a/src/frontend/components/_features/[workspace]/source-control/changes-section.tsx +++ b/src/frontend/components/_features/[workspace]/source-control/changes-section.tsx @@ -4,12 +4,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { PendingChange } from '../../../../../middleware/shared/ports/version-control-port' import { useProject, useVersionControl } from '../../../../../middleware/shared/providers' +import { buildAllProjectFileContents, buildAllProjectFileContentsPure } from '../../../../services/save-actions' import { useOpenPLCStore } from '../../../../store' import type { TabsProps } from '../../../../store/slices/tabs' import { CreateEditorObjectFromTab } from '../../../../store/slices/tabs/utils' import { cn } from '../../../../utils/cn' import { serializePouToText } from '../../../../utils/PLC/pou-text-serializer' import { sanitizePou } from '../../../../utils/save-project' +import { isSystemFile } from '../../../../utils/system-files' import { toast } from '../../../../utils/toast' import { DiscardConfirmationModal } from './modals/discard-confirmation-modal' @@ -297,19 +299,23 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { const [expandedFolders, setExpandedFolders] = useState>(new Set()) const [previewFile, setPreviewFile] = useState<{ path: string; content: string } | null>(null) - const tree = useMemo(() => buildChangesTree(files), [files]) + // System files (e.g. legacy `git-data.tar.gz` from migration) ride along on + // commits silently — they're never shown, never selectable, never discardable. + const visibleFiles = useMemo(() => files.filter((f) => !isSystemFile(f.path)), [files]) + + const tree = useMemo(() => buildChangesTree(visibleFiles), [visibleFiles]) // Auto-expand all folders const folderKey = useMemo(() => { const folders = new Set() - for (const file of files) { + for (const file of visibleFiles) { const parts = file.path.split('/').filter(Boolean) for (let i = 1; i < parts.length; i++) { folders.add(parts.slice(0, i).join('/')) } } return folders - }, [files]) + }, [visibleFiles]) const prevFolderKeyRef = useRef>(new Set()) useEffect(() => { @@ -325,8 +331,12 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { setIsFetching(true) try { const data = await versionControl.getChanges(projectId) + // Keep system-file changes in `files` (so they ride along on commit), but + // count and display only consider user-visible files (see `visibleFiles`). setFiles(data.changes) - versionControlActions.setPendingChangesCount(data.changes.length) + versionControlActions.syncFromChanges( + data.changes.filter((c) => !isSystemFile(c.path)).map((c) => ({ path: c.path, status: c.status })), + ) } catch { setFiles([]) } finally { @@ -339,28 +349,30 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { void fetchChanges() }, [fetchChanges]) - // Auto-select all files on initial load or when file list changes + // Auto-select all files on initial load or when file list changes. + // Only visible files are tracked in `selectedFiles`; system files are auto- + // included at commit time regardless of selection. const fileListKey = useMemo( () => - files + visibleFiles .map((f) => f.path) .sort() .join('\n'), - [files], + [visibleFiles], ) const prevFileListKey = useRef(fileListKey) const isInitialLoad = useRef(true) useEffect(() => { if (isInitialLoad.current || fileListKey !== prevFileListKey.current) { - setSelectedFiles(new Set(files.map((f) => f.path))) + setSelectedFiles(new Set(visibleFiles.map((f) => f.path))) prevFileListKey.current = fileListKey isInitialLoad.current = false } - }, [fileListKey, files]) + }, [fileListKey, visibleFiles]) - const allSelected = files.length > 0 && selectedFiles.size === files.length - const someSelected = selectedFiles.size > 0 && selectedFiles.size < files.length + const allSelected = visibleFiles.length > 0 && selectedFiles.size === visibleFiles.length + const someSelected = selectedFiles.size > 0 && selectedFiles.size < visibleFiles.length const toggleFile = (path: string) => { setSelectedFiles((prev) => { @@ -384,7 +396,7 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { const toggleAll = () => { if (allSelected) setSelectedFiles(new Set()) - else setSelectedFiles(new Set(files.map((f) => f.path))) + else setSelectedFiles(new Set(visibleFiles.map((f) => f.path))) } const toggleExpand = (path: string) => { @@ -475,7 +487,7 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { [pous, project, deviceDefinitions, updateTabs, getEditorFromEditors, addModel, setEditor], ) - const hasChanges = files.length > 0 + const hasChanges = visibleFiles.length > 0 const canCommit = message.trim().length > 0 && selectedFiles.size > 0 const handleCommit = async () => { @@ -493,18 +505,34 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { return } - const validPaths = [...selectedFiles].filter((p) => freshFiles.some((f) => f.path === p)) - if (validPaths.length === 0) { + const validUserPaths = [...selectedFiles].filter((p) => freshFiles.some((f) => f.path === p)) + if (validUserPaths.length === 0) { setIsCommitting(false) return } + // Always include system-file changes — the user never sees or controls + // them, but they ride along on whatever the user commits so they don't + // pile up as ghost pending changes. + const freshSystemPaths = freshFiles.filter((f) => isSystemFile(f.path)).map((f) => f.path) + const pathsToCommit = [...validUserPaths, ...freshSystemPaths] + await versionControl.createCommit( projectId, message.trim(), - validPaths.length === freshFiles.length ? undefined : validPaths, + pathsToCommit.length === freshFiles.length ? undefined : pathsToCommit, ) + // Commit landed: S3 == HEAD again. Refresh the version-control + // baseline to the just-committed state and clear all pending lists. + // We pass two snapshots: the actual upload (mixed raw + serialized, + // for diff baseline) and the pure serialization of current state + // (for the save flow's "state == sync state?" detection). + versionControlActions.commitBaseline({ + newBaseline: buildAllProjectFileContents(), + loadedSerialized: buildAllProjectFileContentsPure(), + }) + setMessage('') setSelectedFiles(new Set()) await fetchChanges() @@ -523,8 +551,10 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { setErrorMessage(null) try { + // Discard only what the user explicitly selected — never `undefined` + // (which would also discard system-file changes the user can't see). const selectedPaths = [...selectedFiles] - await versionControl.discardChanges(projectId, selectedPaths.length === files.length ? undefined : selectedPaths) + await versionControl.discardChanges(projectId, selectedPaths) setShowDiscardModal(false) // Reload project data in-place (no hard page reload) @@ -577,8 +607,8 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { {!hasChanges ? 'No changes' : someSelected - ? `${selectedFiles.size} of ${files.length} selected` - : `${files.length} file${files.length > 1 ? 's' : ''} changed`} + ? `${selectedFiles.size} of ${visibleFiles.length} selected` + : `${visibleFiles.length} file${visibleFiles.length > 1 ? 's' : ''} changed`}
- setShowSwitcher(false)} onSelect={handleSelect} - onCreateNew={() => setShowCreate(true)} onDelete={handleDelete} + onMerge={handleMerge} /> - setShowCreate(false)} /> - + onClose: () => void + onSelect: (branch: Branch) => void + onDelete: (branch: Branch) => void + onMerge: (branch: Branch) => void +} + +export function BranchSwitcherPopover({ + isOpen, + projectId, + currentBranchName, + anchorRef, + onClose, + onSelect, + onDelete, + onMerge, +}: BranchSwitcherPopoverProps) { + const versionControl = useVersionControl() + const [branches, setBranches] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [filter, setFilter] = useState('') + const popoverRef = useRef(null) + const [position, setPosition] = useState<{ left: number; bottom: number } | null>(null) + const [visible, setVisible] = useState(false) + const [closing, setClosing] = useState(false) + + // Fetch branches each time the popover opens + useEffect(() => { + if (!isOpen || !versionControl) return + setIsLoading(true) + versionControl + .listBranches(projectId) + .then(({ branches: b }) => setBranches(b)) + .catch(() => setBranches([])) + .finally(() => setIsLoading(false)) + }, [isOpen, projectId, versionControl]) + + const handleClose = useCallback(() => { + setClosing(true) + setTimeout(() => { + setClosing(false) + setVisible(false) + onClose() + }, 75) // matches popover-out duration + }, [onClose]) + + useEffect(() => { + if (isOpen) { + setFilter('') + setVisible(true) + setClosing(false) + } + }, [isOpen]) + + useEffect(() => { + if (!isOpen || !anchorRef?.current) return + const rect = anchorRef.current.getBoundingClientRect() + setPosition({ + left: rect.left, + bottom: window.innerHeight - rect.top + 4, + }) + }, [isOpen, anchorRef]) + + useEffect(() => { + if (!visible) return + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') handleClose() + } + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement + // Don't close if the click is inside Radix-portalled content (popover form + // for create branch, or the branch actions dropdown menu) + if (target.closest?.('[data-radix-popper-content-wrapper]')) return + if (target.closest?.('[data-radix-menu-content]')) return + if (popoverRef.current && !popoverRef.current.contains(target)) { + handleClose() + } + } + document.addEventListener('keydown', handleKeyDown) + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.removeEventListener('mousedown', handleClickOutside) + } + }, [visible, handleClose]) + + const filtered = useMemo(() => { + if (!filter.trim()) return branches + const lower = filter.toLowerCase() + return branches.filter((b) => b.name.toLowerCase().includes(lower)) + }, [branches, filter]) + + if (!visible) return null + + return ( +
+
+ {/* Search */} +
+ setFilter(e.target.value)} + placeholder='Search branches...' + autoFocus + className='h-[30px] w-full rounded-md border border-neutral-100 bg-white px-2 py-2 text-cp-sm font-medium text-neutral-850 outline-none dark:border-brand-medium-dark dark:bg-neutral-950 dark:text-neutral-300' + /> +
+
+ + {/* Branch list */} +
+ {isLoading && ( +

+ Loading... +

+ )} + + {!isLoading && filtered.length === 0 && ( +

+ No branches found +

+ )} + + {filtered.map((branch) => { + const isActive = branch.name === currentBranchName + return ( +
+ {/* Clickable area — only branch icon + name triggers selection. + Side elements (checkmark, default badge, dots menu) are OUTSIDE + this button so they don't accidentally trigger a branch switch. */} + + {isActive && ( + + + + )} + {branch.isDefault && ( + + default + + )} + {/* Actions menu (3 dots) — reveals merge + delete on hover. */} +
+ + + + + + e.preventDefault()} + className='z-[60] min-w-[140px] overflow-hidden rounded-md border border-neutral-200 bg-white py-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-900' + > + { + e.preventDefault() + if (isActive) return + onMerge(branch) + handleClose() + }} + title={isActive ? 'Cannot merge a branch into itself' : undefined} + className={cn( + 'flex select-none items-center gap-2 px-3 py-1.5 text-xs outline-none', + isActive + ? 'cursor-not-allowed text-neutral-400 dark:text-neutral-600' + : 'cursor-pointer text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-800', + )} + > + + + + + Merge {branch.name} into{' '} + {currentBranchName} + + + { + e.preventDefault() + if (branch.isDefault) return + onDelete(branch) + }} + title={branch.isDefault ? 'Cannot delete the default branch' : undefined} + className={cn( + 'flex select-none items-center gap-2 px-3 py-1.5 text-xs outline-none', + branch.isDefault + ? 'cursor-not-allowed text-neutral-400 dark:text-neutral-600' + : 'cursor-pointer text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40', + )} + > + + + + Delete + + + + +
+
+ ) + })} +
+ + {/* Create new branch */} +
+
+ +
+
+
+ ) +} diff --git a/src/frontend/components/_features/[workspace]/branches/index.ts b/src/frontend/components/_features/[workspace]/branches/index.ts index 6ab4e597f..173d12d9d 100644 --- a/src/frontend/components/_features/[workspace]/branches/index.ts +++ b/src/frontend/components/_features/[workspace]/branches/index.ts @@ -1,5 +1,5 @@ export { BranchStatusBar } from './branch-status-bar' -export { BranchSwitcherModal } from './branch-switcher-modal' -export { CreateBranchModal } from './create-branch-modal' +export { BranchSwitcherPopover } from './branch-switcher-popover' +export { CreateBranchPopover } from './create-branch-popover' export { DeleteBranchModal } from './delete-branch-modal' export { UnsavedChangesWarningModal } from './unsaved-changes-warning-modal' diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/communication.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/communication.tsx index 71c05212e..ada8fea0f 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/communication.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/communication.tsx @@ -45,6 +45,7 @@ const Communication = () => { } } updateModbusConfig() + // eslint-disable-next-line react-hooks/exhaustive-deps }, [deviceBoard, isRuntimeTarget]) const handleEnableModbusRTU = () => { diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/components/modbus-rtu.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/components/modbus-rtu.tsx index a3a6fb6ef..0b7ac6e94 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/components/modbus-rtu.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/components/modbus-rtu.tsx @@ -59,6 +59,7 @@ const ModbusRTUComponent = memo(function ({ isModbusRTUEnabled }: { isModbusRTUE if (modbusRTU.rtuRS485ENPin) { setEnableRS485ENPin(true) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) useEffect(() => { diff --git a/src/frontend/components/_features/[workspace]/editor/device/remote-device/index.tsx b/src/frontend/components/_features/[workspace]/editor/device/remote-device/index.tsx index ebaa60f78..f5be9d5bc 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/remote-device/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/remote-device/index.tsx @@ -130,6 +130,7 @@ const SerialPortCombobox = ({ setHighlightedIndex(currentIndex >= 0 ? currentIndex : -1) }, 0) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]) // Scroll highlighted option into view @@ -654,7 +655,10 @@ const RemoteDeviceEditor = () => { } }, [remoteDevice]) - const ioGroups = remoteDevice?.modbusTcpConfig?.ioGroups || [] + const ioGroups = useMemo( + () => remoteDevice?.modbusTcpConfig?.ioGroups || [], + [remoteDevice?.modbusTcpConfig?.ioGroups], + ) // Fetch serial ports from runtime when transport is RTU and connected const fetchSerialPorts = useCallback(async () => { diff --git a/src/frontend/components/_features/[workspace]/source-control/commit-details.tsx b/src/frontend/components/_features/[workspace]/source-control/commit-details.tsx index 4b09ecb34..ea9e51c99 100644 --- a/src/frontend/components/_features/[workspace]/source-control/commit-details.tsx +++ b/src/frontend/components/_features/[workspace]/source-control/commit-details.tsx @@ -54,17 +54,11 @@ export function CommitDetails({ commit, projectId }: CommitDetailsProps) { } const handleViewFiles = () => { - window.open( - `/history?project_id=${encodeURIComponent(effectiveProjectId)}&commit_hash=${encodeURIComponent(commit.hash)}`, - '_blank', - ) + window.location.href = `/history?project_id=${encodeURIComponent(effectiveProjectId)}&commit_hash=${encodeURIComponent(commit.hash)}` } const handleFileClick = (filePath: string) => { - window.open( - `/history?project_id=${encodeURIComponent(effectiveProjectId)}&commit_hash=${encodeURIComponent(commit.hash)}&file=${encodeURIComponent(filePath)}`, - '_blank', - ) + window.location.href = `/history?project_id=${encodeURIComponent(effectiveProjectId)}&commit_hash=${encodeURIComponent(commit.hash)}&file=${encodeURIComponent(filePath)}` } const handleRestore = async () => { diff --git a/src/frontend/components/_features/[workspace]/source-control/history-section.tsx b/src/frontend/components/_features/[workspace]/source-control/history-section.tsx index b310e4313..d7dfc288c 100644 --- a/src/frontend/components/_features/[workspace]/source-control/history-section.tsx +++ b/src/frontend/components/_features/[workspace]/source-control/history-section.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import type { Commit } from '../../../../../middleware/shared/ports/version-control-port' import { useVersionControl } from '../../../../../middleware/shared/providers' +import { useActiveBranch } from '../../../../hooks/use-active-branch' import { CommitDetails } from './commit-details' import { CommitItem } from './commit-item' @@ -13,6 +14,7 @@ type HistorySectionProps = { export function HistorySection({ projectId }: HistorySectionProps) { const versionControl = useVersionControl() + const [activeBranchName] = useActiveBranch(projectId) const [commits, setCommits] = useState([]) const [total, setTotal] = useState(0) const [offset, setOffset] = useState(0) @@ -20,6 +22,12 @@ export function HistorySection({ projectId }: HistorySectionProps) { const [isFetching, setIsFetching] = useState(false) const [selectedHash, setSelectedHash] = useState(null) + // Reset pagination when the active branch changes + useEffect(() => { + setOffset(0) + setSelectedHash(null) + }, [activeBranchName]) + useEffect(() => { if (!versionControl) return @@ -28,7 +36,7 @@ export function HistorySection({ projectId }: HistorySectionProps) { setIsFetching(true) versionControl - .listCommits(projectId, { limit: PAGE_SIZE, offset }) + .listCommits(projectId, { limit: PAGE_SIZE, offset, branch: activeBranchName }) .then((data) => { setCommits(data.commits) setTotal(data.total) @@ -41,7 +49,7 @@ export function HistorySection({ projectId }: HistorySectionProps) { setIsLoading(false) setIsFetching(false) }) - }, [projectId, offset, versionControl]) + }, [projectId, offset, versionControl, activeBranchName]) const hasMore = offset + PAGE_SIZE < total @@ -52,7 +60,7 @@ export function HistorySection({ projectId }: HistorySectionProps) { if (isLoading) { return (
- {[...Array(5)].map((_, i) => ( + {Array.from({ length: 5 }).map((_, i) => (
))}
diff --git a/src/frontend/components/_molecules/data-types/array/index.tsx b/src/frontend/components/_molecules/data-types/array/index.tsx index 7132f3a61..9659bb920 100644 --- a/src/frontend/components/_molecules/data-types/array/index.tsx +++ b/src/frontend/components/_molecules/data-types/array/index.tsx @@ -75,6 +75,7 @@ const ArrayDataType = ({ data, ...rest }: ArrayDatatypeProps) => { useEffect(() => { setInitialValueData(data.initialValue || '') + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) useEffect(() => { diff --git a/src/frontend/components/_molecules/data-types/array/table/index.tsx b/src/frontend/components/_molecules/data-types/array/table/index.tsx index ed76f2ce4..f90a7c126 100644 --- a/src/frontend/components/_molecules/data-types/array/table/index.tsx +++ b/src/frontend/components/_molecules/data-types/array/table/index.tsx @@ -57,6 +57,7 @@ const DimensionsTable = ({ ), }), ], + // eslint-disable-next-line react-hooks/exhaustive-deps [name, selectedRow], ) diff --git a/src/frontend/components/_molecules/data-types/enumerated/index.tsx b/src/frontend/components/_molecules/data-types/enumerated/index.tsx index a407ea143..dbac28311 100644 --- a/src/frontend/components/_molecules/data-types/enumerated/index.tsx +++ b/src/frontend/components/_molecules/data-types/enumerated/index.tsx @@ -32,6 +32,7 @@ const EnumeratorDataType = ({ data, ...rest }: EnumDatatypeProps) => { useEffect(() => { setInitialValueData(data.initialValue || '') + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) useEffect(() => { diff --git a/src/frontend/components/_molecules/graphical-editor/fbd/fbd-utils/useCopyPaste.ts b/src/frontend/components/_molecules/graphical-editor/fbd/fbd-utils/useCopyPaste.ts index f0a3802e7..eab546a96 100644 --- a/src/frontend/components/_molecules/graphical-editor/fbd/fbd-utils/useCopyPaste.ts +++ b/src/frontend/components/_molecules/graphical-editor/fbd/fbd-utils/useCopyPaste.ts @@ -61,6 +61,7 @@ export const useFBDClipboard = ({ variant: 'default', }) }, + // eslint-disable-next-line react-hooks/exhaustive-deps [rung], ) @@ -89,6 +90,7 @@ export const useFBDClipboard = ({ variant: 'default', }) }, + // eslint-disable-next-line react-hooks/exhaustive-deps [rung, handleDeleteNodes], ) @@ -169,6 +171,7 @@ export const useFBDClipboard = ({ variant: 'default', }) }, + // eslint-disable-next-line react-hooks/exhaustive-deps [insideViewport, mousePosition, reactFlowInstance, fbdFlowActions, rung], ) diff --git a/src/frontend/components/_molecules/variables-table/editable-cell.tsx b/src/frontend/components/_molecules/variables-table/editable-cell.tsx index 74373040b..c271e68eb 100644 --- a/src/frontend/components/_molecules/variables-table/editable-cell.tsx +++ b/src/frontend/components/_molecules/variables-table/editable-cell.tsx @@ -526,7 +526,7 @@ const EditableLocationCell = ({ { label: 'Digital Outputs', options: doutPins }, ...remoteGroups, ] - }, [id, variable, existingPins, remoteIOPoints]) + }, [id, existingPins, remoteIOPoints]) return selected ? ( { useEffect(() => { lastParsedCodeRef.current = editorCode + // eslint-disable-next-line react-hooks/exhaustive-deps }, [editor.meta.name]) useEffect(() => { @@ -318,7 +319,7 @@ const GlobalVariablesEditor = () => { useEffect(() => { commitCodeRef.current = commitCode - }, [commitCode]) + }) return (
diff --git a/src/frontend/components/_organisms/variables-editor/index.tsx b/src/frontend/components/_organisms/variables-editor/index.tsx index 7da1bdd74..b72b18588 100644 --- a/src/frontend/components/_organisms/variables-editor/index.tsx +++ b/src/frontend/components/_organisms/variables-editor/index.tsx @@ -194,6 +194,7 @@ const VariablesEditor = () => { useEffect(() => { lastParsedCodeRef.current = editorCode + // eslint-disable-next-line react-hooks/exhaustive-deps }, [editor.meta.name]) useEffect(() => { @@ -906,7 +907,7 @@ const VariablesEditor = () => { useEffect(() => { commitCodeRef.current = commitCode - }, [commitCode]) + }) return ( <> diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index 50943487d..9a74b6388 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -6,7 +6,6 @@ import { useShallow } from 'zustand/react/shallow' import { useCapabilities, useChatPanel, useDebugger, useDevice, useProject } from '../../middleware/shared/providers' import { ExitIcon } from '../assets/icons/interface/Exit' import { ClearConsoleButton } from '../components/_atoms/buttons/console/clear-console' -// eslint-disable-next-line @typescript-eslint/no-unused-vars import { BranchStatusBar } from '../components/_features/[workspace]/branches' import { DataTypeEditor } from '../components/_features/[workspace]/data-type' import { DeviceEditor } from '../components/_features/[workspace]/editor/device' @@ -242,7 +241,6 @@ const WorkspaceScreen = () => { ) const [isVariablesPanelCollapsed, setIsVariablesPanelCollapsed] = useState(false) - // eslint-disable-next-line @typescript-eslint/no-unused-vars const handleBranchSwitch = useCallback( async (branchName: string) => { if (!projectPath) return @@ -668,10 +666,9 @@ const WorkspaceScreen = () => {
- {/* TODO: Re-enable branch status bar once branch switching is fully implemented */} - {/* {hasVersionControl && projectPath && ( + {hasVersionControl && projectPath && ( - )} */} + )}
) } diff --git a/src/frontend/services/project-initializer.ts b/src/frontend/services/project-initializer.ts new file mode 100644 index 000000000..d84e4e0a4 --- /dev/null +++ b/src/frontend/services/project-initializer.ts @@ -0,0 +1,181 @@ +import type { PLCPou } from '../../middleware/shared/ports/types' +import type { FBDFlowType } from '../store/slices/fbd/types' +import type { FileSliceData } from '../store/slices/file/types' +import type { LadderFlowType } from '../store/slices/ladder/types' +import type { ProjectState } from '../store/slices/project/types' +import type { TabsProps } from '../store/slices/tabs/types' +import { CreateEditorObjectFromTab } from '../store/slices/tabs/utils' + +function normalizeLadderFlow(flow: Record | undefined, pouName: string): LadderFlowType { + return { + name: (flow?.name as string) || pouName, + updated: flow?.updated !== undefined ? (flow.updated as boolean) : false, + rungs: Array.isArray(flow?.rungs) ? (flow.rungs as LadderFlowType['rungs']) : [], + } +} + +type StoreActions = { + projectActions: { setProject: (data: ProjectState) => void } + editorActions: { + addModel: (model: ReturnType) => void + setEditor: (model: ReturnType) => void + clearEditor: () => void + } + tabsActions: { + updateTabs: (tab: TabsProps) => void + clearTabs: () => void + } + fileActions: { setFiles: (data: { files: Record }) => void } + ladderFlowActions: { + addLadderFlow: (flow: LadderFlowType) => void + clearLadderFlows: () => void + } + fbdFlowActions: { + addFBDFlow: (flow: FBDFlowType) => void + clearFBDFlows: () => void + } + libraryActions: { addLibrary: (name: string, type: 'function' | 'function-block') => void } + setProjectLoading: (loading: boolean, message?: string) => void +} + +/** + * Initializes or re-initializes the project in the Zustand store. + * Used on first load (from router) and on branch switch (without page reload). + * Set `showLoading: false` to skip the loading overlay (e.g. on branch switch). + */ +export function initializeProject(projectData: ProjectState, actions: StoreActions, showLoading = true) { + const { + projectActions, + editorActions, + tabsActions, + fileActions, + ladderFlowActions, + fbdFlowActions, + libraryActions, + setProjectLoading, + } = actions + + if (showLoading) setProjectLoading(true, 'Loading project...') + + // Set the project in store + projectActions.setProject(projectData) + + // Clear and populate flows + ladderFlowActions.clearLadderFlows() + fbdFlowActions.clearFBDFlows() + + // Process ladder POUs + projectData.data.pous + .filter((pou: PLCPou) => pou.body.language === 'ld') + .forEach((pou: PLCPou) => { + if (pou.body.language === 'ld') { + const flow = pou.body.value as Record + const normalizedFlow = normalizeLadderFlow(flow, pou.name) + ladderFlowActions.addLadderFlow(normalizedFlow) + } + }) + + // Process FBD POUs + projectData.data.pous + .filter((pou: PLCPou) => pou.body.language === 'fbd') + .forEach((pou: PLCPou) => { + if (pou.body.language === 'fbd') { + const flow = pou.body.value as FBDFlowType + fbdFlowActions.addFBDFlow(flow) + } + }) + + // Populate user libraries (functions and function-blocks) + projectData.data.pous.forEach((pou: PLCPou) => { + if (pou.pouType !== 'program') { + libraryActions.addLibrary(pou.name, pou.pouType) + } + }) + + // Set up file entries with cleanState snapshots + const files: Record = {} + projectData.data.pous.forEach((pou: PLCPou) => { + const pouCleanState: Record = { + pou: structuredClone(pou), + } + if (pou.body.language === 'ld') { + pouCleanState.ladderFlow = structuredClone(pou.body.value) + } + if (pou.body.language === 'fbd') { + pouCleanState.fbdFlow = structuredClone(pou.body.value) + } + files[pou.name] = { + type: pou.pouType, + filePath: `/data/pous/${pou.pouType}/${pou.name}`, + saved: true, + cleanState: pouCleanState, + } + }) + projectData.data.dataTypes.forEach((datatype) => { + files[datatype.name] = { + type: 'data-type', + filePath: `/project.json`, + saved: true, + cleanState: structuredClone(datatype), + } + }) + if (projectData.data.remoteDevices) { + projectData.data.remoteDevices.forEach((remoteDevice) => { + files[remoteDevice.name] = { + type: 'remote-device', + filePath: `/devices/remote/${remoteDevice.name}.json`, + saved: true, + cleanState: structuredClone(remoteDevice), + } + }) + } + if (projectData.data.servers) { + projectData.data.servers.forEach((server) => { + files[server.name] = { + type: 'server', + filePath: `/servers/${server.name}.json`, + saved: true, + cleanState: structuredClone(server), + } + }) + } + files['Resource'] = { + type: 'resource', + filePath: `/project.json`, + saved: true, + cleanState: structuredClone(projectData.data.configurations.resource), + } + files['Configuration'] = { + type: 'device', + filePath: `/device`, + saved: true, + } + fileActions.setFiles({ files }) + + // Clear existing tabs and editors + tabsActions.clearTabs() + editorActions.clearEditor() + + // Open main POU or first program + if (projectData.data.pous.length > 0) { + const mainPou = projectData.data.pous.find((pou: PLCPou) => pou.name === 'main' && pou.pouType === 'program') + const pouToOpen = mainPou || projectData.data.pous.find((pou: PLCPou) => pou.pouType === 'program') + + if (pouToOpen) { + const tabToBeCreated: TabsProps = { + name: pouToOpen.name, + path: `/pous/programs/${pouToOpen.name}`, + elementType: { + type: 'program', + language: pouToOpen.body.language.toLowerCase() as 'il' | 'st' | 'ld' | 'sfc' | 'fbd' | 'python' | 'cpp', + }, + } + const model = CreateEditorObjectFromTab(tabToBeCreated) + editorActions.addModel(model) + editorActions.setEditor(model) + tabsActions.updateTabs(tabToBeCreated) + } + } + + if (showLoading) setProjectLoading(false) +} diff --git a/src/middleware/shared/ports/version-control-port.ts b/src/middleware/shared/ports/version-control-port.ts index 27e9ac9e3..7fd4a83d2 100644 --- a/src/middleware/shared/ports/version-control-port.ts +++ b/src/middleware/shared/ports/version-control-port.ts @@ -139,7 +139,14 @@ export interface VarDiffEntry { } export interface GraphicalDiffResult { - flows: { original: FlowData | null; current: FlowData | null; height: number; width: number }[] + flows: { + original: FlowData | null + current: FlowData | null + originalHeight: number + currentHeight: number + originalWidth: number + currentWidth: number + }[] changedIndexes: number[] variableDiff: VarDiffEntry[] nodeDiffMaps: { original: Map; current: Map } From 16e88eb398ad1dac8bdcde065e5eb5191d96450e Mon Sep 17 00:00:00 2001 From: JulioSergioFS Date: Wed, 29 Apr 2026 12:11:53 -0300 Subject: [PATCH 3/5] refactor: improve variable editor and project file handling --- .../branches/branch-status-bar.tsx | 15 +- .../source-control/changes-section.tsx | 120 ++--- .../source-control/commit-details.tsx | 10 +- .../_molecules/project-tree/index.tsx | 33 +- .../global-variables-editor/index.tsx | 2 +- .../_organisms/variables-editor/index.tsx | 2 +- src/frontend/hooks/use-active-branch.ts | 9 +- src/frontend/services/project-initializer.ts | 181 ------- src/frontend/services/save-actions.ts | 470 ++++++++---------- .../store/slices/version-control/slice.ts | 8 - .../store/slices/version-control/types.ts | 2 - src/frontend/utils/sanitize-branch-name.ts | 124 +++-- src/frontend/utils/save-project.ts | 88 +++- 13 files changed, 485 insertions(+), 579 deletions(-) delete mode 100644 src/frontend/services/project-initializer.ts diff --git a/src/frontend/components/_features/[workspace]/branches/branch-status-bar.tsx b/src/frontend/components/_features/[workspace]/branches/branch-status-bar.tsx index c6493870f..ad048c242 100644 --- a/src/frontend/components/_features/[workspace]/branches/branch-status-bar.tsx +++ b/src/frontend/components/_features/[workspace]/branches/branch-status-bar.tsx @@ -82,14 +82,13 @@ export function BranchStatusBar({ projectId, onBranchSwitch }: BranchStatusBarPr const handleMerge = useCallback( (branch: Branch) => { // Source is the clicked branch; default target to the active branch - // (if different) — otherwise leave it blank and let the merge page validate. - const target = activeBranchName !== branch.name ? activeBranchName : '' - const params = new URLSearchParams({ - project_id: projectId, - source: branch.name, - target, - }).toString() - window.location.href = `/merge?${params}` + // (if different). When source == active, omit `target` entirely so the + // merge page can apply its own default rather than receiving `target=`. + const params = new URLSearchParams({ project_id: projectId, source: branch.name }) + if (activeBranchName !== branch.name) { + params.set('target', activeBranchName) + } + window.location.href = `/merge?${params.toString()}` }, [projectId, activeBranchName], ) 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 e57a53aff..77346c649 100644 --- a/src/frontend/components/_features/[workspace]/source-control/changes-section.tsx +++ b/src/frontend/components/_features/[workspace]/source-control/changes-section.tsx @@ -8,9 +8,8 @@ import { buildAllProjectFileContents, buildAllProjectFileContentsPure } from '.. import { useOpenPLCStore } from '../../../../store' import type { TabsProps } from '../../../../store/slices/tabs' import { CreateEditorObjectFromTab } from '../../../../store/slices/tabs/utils' +import type { PendingChangeStatus } from '../../../../store/slices/version-control/types' import { cn } from '../../../../utils/cn' -import { serializePouToText } from '../../../../utils/PLC/pou-text-serializer' -import { sanitizePou } from '../../../../utils/save-project' import { isSystemFile } from '../../../../utils/system-files' import { toast } from '../../../../utils/toast' import { DiscardConfirmationModal } from './modals/discard-confirmation-modal' @@ -19,19 +18,19 @@ type ChangesSectionProps = { projectId: string } -const STATUS_LABEL: Record = { +const STATUS_LABEL: Record = { modified: 'M', added: 'A', deleted: 'D', } -const STATUS_COLOR: Record = { +const STATUS_COLOR: Record = { modified: 'text-yellow-500 dark:text-yellow-400', added: 'text-green-500 dark:text-green-400', deleted: 'text-red-500 dark:text-red-400', } -const STATUS_TOOLTIP: Record = { +const STATUS_TOOLTIP: Record = { modified: 'Modified -- File has been changed since last commit', added: 'Added -- New file not in previous commit', deleted: 'Deleted -- File has been removed', @@ -103,13 +102,13 @@ function FilePreviewModal({ filePath, content, onClose }: { filePath: string; co // Tree types & helpers // --------------------------------------------------------------------------- -type ChangedFile = { path: string; status: string } +type ChangedFile = { path: string; status: PendingChangeStatus } type FileTreeNode = { name: string path: string type: 'file' | 'folder' - status?: string + status?: PendingChangeStatus children?: FileTreeNode[] } @@ -259,11 +258,11 @@ function ChangesTreeItem({ - {STATUS_LABEL[node.status ?? ''] ?? node.status} + {node.status ? STATUS_LABEL[node.status] : ''}
) @@ -280,14 +279,21 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { versionControlActions, sharedWorkspaceActions, project, - deviceDefinitions, tabsActions: { updateTabs }, editorActions: { setEditor, addModel, getEditorFromEditors }, } = useOpenPLCStore() const pous = project.data.pous - const [files, setFiles] = useState([]) + // 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 + // again, and the commit path can pass them straight through. + const [pendingFiles, setPendingFiles] = useState<{ visible: PendingChange[]; system: PendingChange[] }>({ + visible: [], + system: [], + }) + const visibleFiles = pendingFiles.visible const [isLoading, setIsLoading] = useState(true) const [isFetching, setIsFetching] = useState(false) const [message, setMessage] = useState('') @@ -299,10 +305,6 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { const [expandedFolders, setExpandedFolders] = useState>(new Set()) const [previewFile, setPreviewFile] = useState<{ path: string; content: string } | null>(null) - // System files (e.g. legacy `git-data.tar.gz` from migration) ride along on - // commits silently — they're never shown, never selectable, never discardable. - const visibleFiles = useMemo(() => files.filter((f) => !isSystemFile(f.path)), [files]) - const tree = useMemo(() => buildChangesTree(visibleFiles), [visibleFiles]) // Auto-expand all folders @@ -331,14 +333,19 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { setIsFetching(true) try { const data = await versionControl.getChanges(projectId) - // Keep system-file changes in `files` (so they ride along on commit), but - // count and display only consider user-visible files (see `visibleFiles`). - setFiles(data.changes) - versionControlActions.syncFromChanges( - data.changes.filter((c) => !isSystemFile(c.path)).map((c) => ({ path: c.path, status: c.status })), - ) + // Split into visible (user-facing) and system (silent passengers) once, + // so subsequent renders and the commit path can read them directly + // without re-filtering. + const visible: PendingChange[] = [] + const system: PendingChange[] = [] + for (const c of data.changes) { + if (isSystemFile(c.path)) system.push(c) + else visible.push(c) + } + setPendingFiles({ visible, system }) + versionControlActions.syncFromChanges(visible.map((c) => ({ path: c.path, status: c.status }))) } catch { - setFiles([]) + setPendingFiles({ visible: [], system: [] }) } finally { setIsLoading(false) setIsFetching(false) @@ -439,52 +446,20 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { return } - // Non-POU files: resolve content from store and show in preview modal + // 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 { - let content: string | null = null - - if (filePath === 'project.json') { - content = JSON.stringify( - { name: project.meta.name, type: project.meta.type, path: project.meta.path }, - null, - 2, - ) - } else if (filePath === 'devices/configuration.json') { - content = JSON.stringify(deviceDefinitions.configuration, null, 2) - } else if (filePath === 'devices/pin-mapping.json') { - content = JSON.stringify(deviceDefinitions.pinMapping, null, 2) - } else if (filePath.startsWith('devices/remote/')) { - const name = filePath.split('/').pop()?.replace('.json', '') - const rd = project.data.remoteDevices?.find((d) => d.name === name) - if (rd) content = JSON.stringify(rd, null, 2) - } else if (filePath.startsWith('devices/servers/')) { - const name = filePath.split('/').pop()?.replace('.json', '') - const srv = project.data.servers?.find((s) => s.name === name) - if (srv) content = JSON.stringify(srv, null, 2) - } - - // Try POU serialization as fallback (in case the path format differs) - if (content === null) { - const filename = filePath.split('/').pop() ?? '' - const dotIndex = filename.lastIndexOf('.') - if (dotIndex > 0) { - const pouName = filename.substring(0, dotIndex) - const pou = pous.find((p) => p.name === pouName) - if (pou) { - const sanitized = sanitizePou(pou, undefined) - content = serializePouToText(sanitized) - } - } - } - - if (content !== null) { + const content = buildAllProjectFileContentsPure()[filePath] + if (content !== undefined) { setPreviewFile({ path: filePath, content }) } } catch { // Serialization failed — ignore } }, - [pous, project, deviceDefinitions, updateTabs, getEditorFromEditors, addModel, setEditor], + [pous, updateTabs, getEditorFromEditors, addModel, setEditor], ) const hasChanges = visibleFiles.length > 0 @@ -497,15 +472,23 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { setErrorMessage(null) try { - // Re-fetch to avoid stale state + // Re-fetch to avoid stale state. Split into visible/system once so the + // commit path doesn't re-filter and so we can detect whether the user + // selected literally everything (passing `undefined` lets the backend + // commit all pending changes in one shot). const freshData = await versionControl.getChanges(projectId) - const freshFiles = freshData.changes - if (freshFiles.length === 0) { + const freshVisible: PendingChange[] = [] + const freshSystem: PendingChange[] = [] + for (const c of freshData.changes) { + if (isSystemFile(c.path)) freshSystem.push(c) + else freshVisible.push(c) + } + if (freshVisible.length + freshSystem.length === 0) { setIsCommitting(false) return } - const validUserPaths = [...selectedFiles].filter((p) => freshFiles.some((f) => f.path === p)) + const validUserPaths = [...selectedFiles].filter((p) => freshVisible.some((f) => f.path === p)) if (validUserPaths.length === 0) { setIsCommitting(false) return @@ -514,13 +497,14 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { // Always include system-file changes — the user never sees or controls // them, but they ride along on whatever the user commits so they don't // pile up as ghost pending changes. - const freshSystemPaths = freshFiles.filter((f) => isSystemFile(f.path)).map((f) => f.path) + const freshSystemPaths = freshSystem.map((f) => f.path) const pathsToCommit = [...validUserPaths, ...freshSystemPaths] + const totalFresh = freshVisible.length + freshSystem.length await versionControl.createCommit( projectId, message.trim(), - pathsToCommit.length === freshFiles.length ? undefined : pathsToCommit, + pathsToCommit.length === totalFresh ? undefined : pathsToCommit, ) // Commit landed: S3 == HEAD again. Refresh the version-control diff --git a/src/frontend/components/_features/[workspace]/source-control/commit-details.tsx b/src/frontend/components/_features/[workspace]/source-control/commit-details.tsx index ea9e51c99..4b09ecb34 100644 --- a/src/frontend/components/_features/[workspace]/source-control/commit-details.tsx +++ b/src/frontend/components/_features/[workspace]/source-control/commit-details.tsx @@ -54,11 +54,17 @@ export function CommitDetails({ commit, projectId }: CommitDetailsProps) { } const handleViewFiles = () => { - window.location.href = `/history?project_id=${encodeURIComponent(effectiveProjectId)}&commit_hash=${encodeURIComponent(commit.hash)}` + window.open( + `/history?project_id=${encodeURIComponent(effectiveProjectId)}&commit_hash=${encodeURIComponent(commit.hash)}`, + '_blank', + ) } const handleFileClick = (filePath: string) => { - window.location.href = `/history?project_id=${encodeURIComponent(effectiveProjectId)}&commit_hash=${encodeURIComponent(commit.hash)}&file=${encodeURIComponent(filePath)}` + window.open( + `/history?project_id=${encodeURIComponent(effectiveProjectId)}&commit_hash=${encodeURIComponent(commit.hash)}&file=${encodeURIComponent(filePath)}`, + '_blank', + ) } const handleRestore = async () => { diff --git a/src/frontend/components/_molecules/project-tree/index.tsx b/src/frontend/components/_molecules/project-tree/index.tsx index 8227c91df..7a163b391 100644 --- a/src/frontend/components/_molecules/project-tree/index.tsx +++ b/src/frontend/components/_molecules/project-tree/index.tsx @@ -1,7 +1,7 @@ import * as Popover from '@radix-ui/react-popover' import { ComponentPropsWithoutRef, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useProject } from '../../../../middleware/shared/providers' +import { useCapabilities, useProject } from '../../../../middleware/shared/providers' import { ArrowIcon } from '../../../assets/icons/interface/Arrow' import { CloseIcon } from '../../../assets/icons/interface/Close' import { ConfigIcon } from '../../../assets/icons/interface/Config' @@ -265,6 +265,7 @@ const ProjectTreeExpandableLeaf = ({ fileActions: { getFile }, } = useOpenPLCStore() const projectPort = useProject() + const { hasVersionControl } = useCapabilities() const [isExpanded, setIsExpanded] = useState(true) const [isEditing, setIsEditing] = useState(false) @@ -292,9 +293,15 @@ const ProjectTreeExpandableLeaf = ({ setNewLabel(label || '') return } - // Persist immediately so refresh doesn't show the old name (rename - // queues the old path's deletion in `pendingDeletions`, save propagates). - await executeSaveProject(projectPort) + // Only auto-persist on platforms that track per-file changes — otherwise + // the user's first action on a fresh project triggers a full save with + // no version-control benefit. Local editor users keep the existing + // "save on Ctrl+S" mental model. + if (hasVersionControl) { + // Persist immediately so refresh doesn't show the old name (rename + // queues the old path's deletion in `pendingDeletions`, save propagates). + await executeSaveProject(projectPort) + } } const handleDeleteFile = () => { @@ -498,6 +505,7 @@ const ProjectTreeLeaf = ({ fileActions: { getFile }, } = useOpenPLCStore() const projectPort = useProject() + const { hasVersionControl } = useCapabilities() const [isEditing, setIsEditing] = useState(false) const [newLabel, setNewLabel] = useState(label || '') @@ -556,6 +564,15 @@ const ProjectTreeLeaf = ({ // auto-save below so we don't persist a phantom rename event. if (newLabel === label) return + // Auto-save on rename only matters on platforms that track per-file + // changes (web). Local editor users would otherwise eat a full project + // save on every rename with no version-control payoff — so gate the + // persist behind the capability and let the editor follow the regular + // Ctrl+S flow. + const persist = async () => { + if (hasVersionControl) await executeSaveProject(projectPort) + } + if (isAPou) { const res = renamePou(label, newLabel) if (!res.ok) { @@ -564,7 +581,7 @@ const ProjectTreeLeaf = ({ } // Persist immediately: rename creates a new file in S3 and removes // the old, plus updates the badge correctly via pendingDeletions. - await executeSaveProject(projectPort) + await persist() return } @@ -576,7 +593,7 @@ const ProjectTreeLeaf = ({ } // Datatype lives inside project.json — saving rewrites it with the // renamed entry. No separate file deletion needed. - await executeSaveProject(projectPort) + await persist() return } @@ -586,7 +603,7 @@ const ProjectTreeLeaf = ({ setNewLabel(label || '') return } - await executeSaveProject(projectPort) + await persist() return } @@ -596,7 +613,7 @@ const ProjectTreeLeaf = ({ setNewLabel(label || '') return } - await executeSaveProject(projectPort) + await persist() return } diff --git a/src/frontend/components/_organisms/global-variables-editor/index.tsx b/src/frontend/components/_organisms/global-variables-editor/index.tsx index ee0f3a08b..22d0398d9 100644 --- a/src/frontend/components/_organisms/global-variables-editor/index.tsx +++ b/src/frontend/components/_organisms/global-variables-editor/index.tsx @@ -319,7 +319,7 @@ const GlobalVariablesEditor = () => { useEffect(() => { commitCodeRef.current = commitCode - }) + }, [commitCode]) return (
diff --git a/src/frontend/components/_organisms/variables-editor/index.tsx b/src/frontend/components/_organisms/variables-editor/index.tsx index b72b18588..71835f551 100644 --- a/src/frontend/components/_organisms/variables-editor/index.tsx +++ b/src/frontend/components/_organisms/variables-editor/index.tsx @@ -907,7 +907,7 @@ const VariablesEditor = () => { useEffect(() => { commitCodeRef.current = commitCode - }) + }, [commitCode]) return ( <> diff --git a/src/frontend/hooks/use-active-branch.ts b/src/frontend/hooks/use-active-branch.ts index b561fedfe..ace2b2a90 100644 --- a/src/frontend/hooks/use-active-branch.ts +++ b/src/frontend/hooks/use-active-branch.ts @@ -15,15 +15,22 @@ function getBranchMap(): BranchMap { function setBranchMap(map: BranchMap): void { localStorage.setItem(STORAGE_KEY, JSON.stringify(map)) - // Dispatch storage event so other tabs/components pick up the change + // Notify same-window subscribers (other components in this tab using + // useActiveBranch). Cross-tab sync is handled by the native `storage` + // event in `subscribe`, which fires on tabs other than the writer. window.dispatchEvent(new Event('active-branch-change')) } function subscribe(listener: () => void): () => void { const handleChange = () => listener() + const handleStorage = (event: StorageEvent) => { + if (event.key === STORAGE_KEY) listener() + } window.addEventListener('active-branch-change', handleChange) + window.addEventListener('storage', handleStorage) return () => { window.removeEventListener('active-branch-change', handleChange) + window.removeEventListener('storage', handleStorage) } } diff --git a/src/frontend/services/project-initializer.ts b/src/frontend/services/project-initializer.ts deleted file mode 100644 index d84e4e0a4..000000000 --- a/src/frontend/services/project-initializer.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { PLCPou } from '../../middleware/shared/ports/types' -import type { FBDFlowType } from '../store/slices/fbd/types' -import type { FileSliceData } from '../store/slices/file/types' -import type { LadderFlowType } from '../store/slices/ladder/types' -import type { ProjectState } from '../store/slices/project/types' -import type { TabsProps } from '../store/slices/tabs/types' -import { CreateEditorObjectFromTab } from '../store/slices/tabs/utils' - -function normalizeLadderFlow(flow: Record | undefined, pouName: string): LadderFlowType { - return { - name: (flow?.name as string) || pouName, - updated: flow?.updated !== undefined ? (flow.updated as boolean) : false, - rungs: Array.isArray(flow?.rungs) ? (flow.rungs as LadderFlowType['rungs']) : [], - } -} - -type StoreActions = { - projectActions: { setProject: (data: ProjectState) => void } - editorActions: { - addModel: (model: ReturnType) => void - setEditor: (model: ReturnType) => void - clearEditor: () => void - } - tabsActions: { - updateTabs: (tab: TabsProps) => void - clearTabs: () => void - } - fileActions: { setFiles: (data: { files: Record }) => void } - ladderFlowActions: { - addLadderFlow: (flow: LadderFlowType) => void - clearLadderFlows: () => void - } - fbdFlowActions: { - addFBDFlow: (flow: FBDFlowType) => void - clearFBDFlows: () => void - } - libraryActions: { addLibrary: (name: string, type: 'function' | 'function-block') => void } - setProjectLoading: (loading: boolean, message?: string) => void -} - -/** - * Initializes or re-initializes the project in the Zustand store. - * Used on first load (from router) and on branch switch (without page reload). - * Set `showLoading: false` to skip the loading overlay (e.g. on branch switch). - */ -export function initializeProject(projectData: ProjectState, actions: StoreActions, showLoading = true) { - const { - projectActions, - editorActions, - tabsActions, - fileActions, - ladderFlowActions, - fbdFlowActions, - libraryActions, - setProjectLoading, - } = actions - - if (showLoading) setProjectLoading(true, 'Loading project...') - - // Set the project in store - projectActions.setProject(projectData) - - // Clear and populate flows - ladderFlowActions.clearLadderFlows() - fbdFlowActions.clearFBDFlows() - - // Process ladder POUs - projectData.data.pous - .filter((pou: PLCPou) => pou.body.language === 'ld') - .forEach((pou: PLCPou) => { - if (pou.body.language === 'ld') { - const flow = pou.body.value as Record - const normalizedFlow = normalizeLadderFlow(flow, pou.name) - ladderFlowActions.addLadderFlow(normalizedFlow) - } - }) - - // Process FBD POUs - projectData.data.pous - .filter((pou: PLCPou) => pou.body.language === 'fbd') - .forEach((pou: PLCPou) => { - if (pou.body.language === 'fbd') { - const flow = pou.body.value as FBDFlowType - fbdFlowActions.addFBDFlow(flow) - } - }) - - // Populate user libraries (functions and function-blocks) - projectData.data.pous.forEach((pou: PLCPou) => { - if (pou.pouType !== 'program') { - libraryActions.addLibrary(pou.name, pou.pouType) - } - }) - - // Set up file entries with cleanState snapshots - const files: Record = {} - projectData.data.pous.forEach((pou: PLCPou) => { - const pouCleanState: Record = { - pou: structuredClone(pou), - } - if (pou.body.language === 'ld') { - pouCleanState.ladderFlow = structuredClone(pou.body.value) - } - if (pou.body.language === 'fbd') { - pouCleanState.fbdFlow = structuredClone(pou.body.value) - } - files[pou.name] = { - type: pou.pouType, - filePath: `/data/pous/${pou.pouType}/${pou.name}`, - saved: true, - cleanState: pouCleanState, - } - }) - projectData.data.dataTypes.forEach((datatype) => { - files[datatype.name] = { - type: 'data-type', - filePath: `/project.json`, - saved: true, - cleanState: structuredClone(datatype), - } - }) - if (projectData.data.remoteDevices) { - projectData.data.remoteDevices.forEach((remoteDevice) => { - files[remoteDevice.name] = { - type: 'remote-device', - filePath: `/devices/remote/${remoteDevice.name}.json`, - saved: true, - cleanState: structuredClone(remoteDevice), - } - }) - } - if (projectData.data.servers) { - projectData.data.servers.forEach((server) => { - files[server.name] = { - type: 'server', - filePath: `/servers/${server.name}.json`, - saved: true, - cleanState: structuredClone(server), - } - }) - } - files['Resource'] = { - type: 'resource', - filePath: `/project.json`, - saved: true, - cleanState: structuredClone(projectData.data.configurations.resource), - } - files['Configuration'] = { - type: 'device', - filePath: `/device`, - saved: true, - } - fileActions.setFiles({ files }) - - // Clear existing tabs and editors - tabsActions.clearTabs() - editorActions.clearEditor() - - // Open main POU or first program - if (projectData.data.pous.length > 0) { - const mainPou = projectData.data.pous.find((pou: PLCPou) => pou.name === 'main' && pou.pouType === 'program') - const pouToOpen = mainPou || projectData.data.pous.find((pou: PLCPou) => pou.pouType === 'program') - - if (pouToOpen) { - const tabToBeCreated: TabsProps = { - name: pouToOpen.name, - path: `/pous/programs/${pouToOpen.name}`, - elementType: { - type: 'program', - language: pouToOpen.body.language.toLowerCase() as 'il' | 'st' | 'ld' | 'sfc' | 'fbd' | 'python' | 'cpp', - }, - } - const model = CreateEditorObjectFromTab(tabToBeCreated) - editorActions.addModel(model) - editorActions.setEditor(model) - tabsActions.updateTabs(tabToBeCreated) - } - } - - if (showLoading) setProjectLoading(false) -} diff --git a/src/frontend/services/save-actions.ts b/src/frontend/services/save-actions.ts index b9f3341ed..ba0c47b35 100644 --- a/src/frontend/services/save-actions.ts +++ b/src/frontend/services/save-actions.ts @@ -5,6 +5,10 @@ * menu items, activity bar, modals) and centralise the save logic so it isn't * duplicated. They read state from the store, perform serialization, call the * platform port, and update state on success/failure. + * + * All path → content production funnels through `iterateProjectFiles` so the + * preview, the snapshot baselines, the per-file save and the full-project + * save can never disagree about what a given file should look like on disk. */ import type { ProjectPort, RawProjectFile, WriteProjectFiles } from '../../middleware/shared/ports/project-port' @@ -24,48 +28,30 @@ import { pickContentForSave } from '../utils/version-control-content' /** Join path segments with forward slashes (platform-agnostic, works with Node's fs on all OSes). */ const joinPath = (...parts: string[]): string => parts.join('/').replace(/\/+/g, '/') -/** - * Serialize the full project state into the same path → text map the save - * flow uploads to S3. Used to: - * - Snapshot baseline content at project load (so the version-control - * slice can detect "user reverted to original" reverts on subsequent saves). - * - Refresh baseline after a commit (S3 == HEAD again). - * - * Returns the same relative-path keys the backend reports in /changes, so - * the version-control diff stays byte-stable across baseline ↔ save. - */ -/** - * Pure-serialize every project file (no raw fallback). Use this to capture - * the "state at sync point" snapshot stored in - * `versionControl.loadedSerialized`, so the save flow can later detect - * "state hasn't changed since sync" via byte-equality comparison. - */ -export function buildAllProjectFileContentsPure(): Record { - const state = openPLCStoreBase.getState() - const { project, deviceDefinitions } = state - const result: Record = {} +// --------------------------------------------------------------------------- +// Project file iteration — single source of truth for path → content +// --------------------------------------------------------------------------- - for (const pou of project.data.pous) { - const folder = getFolderFromPouType(pou.pouType) - const ext = getExtensionFromLanguage(pou.body.language) - const editorModel = state.editorActions.getEditorFromEditors(pou.name) - const sanitized = stripGraphicalSelections(sanitizePou(pou, editorModel ?? undefined)) - result[`pous/${folder}/${pou.name}${ext}`] = serializePouToText(sanitized) - } +type StoreState = ReturnType - for (const s of project.data.servers ?? []) { - result[`devices/servers/${s.name}.json`] = JSON.stringify(s, null, 2) - } - - for (const d of project.data.remoteDevices ?? []) { - result[`devices/remote/${d.name}.json`] = JSON.stringify(d, null, 2) - } +type ProjectFileCategory = + | 'pou' + | 'server' + | 'remote-device' + | 'device-config' + | 'pin-mapping' + | 'project-json' - result['devices/configuration.json'] = JSON.stringify(deviceDefinitions.configuration, null, 2) - result['devices/pin-mapping.json'] = JSON.stringify(deviceDefinitions.pinMapping.pins, null, 2) +type ProjectFileSpec = { + path: string + content: string + category: ProjectFileCategory +} +function buildProjectJsonContent(state: StoreState): string { + const { project } = state const debugVariables = collectDebugVariables(project.data.configurations.resource.globalVariables, project.data.pous) - result['project.json'] = JSON.stringify( + return JSON.stringify( { meta: { name: project.meta.name, type: 'plc-project' }, data: { @@ -78,162 +64,182 @@ export function buildAllProjectFileContentsPure(): Record { null, 2, ) +} - return result +function buildPouSpec(pou: PLCPou, state: StoreState): ProjectFileSpec { + const folder = getFolderFromPouType(pou.pouType) + const ext = getExtensionFromLanguage(pou.body.language) + const editorModel = state.editorActions.getEditorFromEditors(pou.name) + const sanitized = sanitizePou(pou, editorModel ?? undefined) + return { + path: `pous/${folder}/${pou.name}${ext}`, + content: serializePouToText(sanitized), + category: 'pou', + } } /** - * Build the file content map the save flow actually uploads. Like - * `buildAllProjectFileContentsPure`, but applies the raw-fallback for files - * whose serialized state hasn't changed since the last sync (byte-stable - * echo back to S3, no phantom modifications vs HEAD). - * - * Used as input to `versionControlActions.commitBaseline` so the post-commit - * baseline matches what was actually on S3 at commit time. + * Yield every file the save flow uploads, in a deterministic order, with the + * canonical serialized content for each. Used by `buildAllProjectFileContents*` + * for snapshots and previews, and by `executeSaveProject` to build the + * platform write payload. */ -export function buildAllProjectFileContents(): Record { - const state = openPLCStoreBase.getState() - const pure = buildAllProjectFileContentsPure() - const result: Record = {} - for (const [path, content] of Object.entries(pure)) { - result[path] = pickContentForSave(path, content, state.versionControl) +function* iterateProjectFiles(state: StoreState): Generator { + const { project, deviceDefinitions } = state + + for (const pou of project.data.pous) { + yield buildPouSpec(pou, state) + } + + for (const s of project.data.servers ?? []) { + yield { + path: `devices/servers/${s.name}.json`, + content: JSON.stringify(s, null, 2), + category: 'server', + } + } + + for (const d of project.data.remoteDevices ?? []) { + yield { + path: `devices/remote/${d.name}.json`, + content: JSON.stringify(d, null, 2), + category: 'remote-device', + } + } + + yield { + path: 'devices/configuration.json', + content: JSON.stringify(deviceDefinitions.configuration, null, 2), + category: 'device-config', + } + + yield { + path: 'devices/pin-mapping.json', + content: JSON.stringify(deviceDefinitions.pinMapping.pins, null, 2), + category: 'pin-mapping', + } + + yield { + path: 'project.json', + content: buildProjectJsonContent(state), + category: 'project-json', } - return result } /** - * Build the saved-file payload (path + serialized content) for a single - * file save. Mirrors the path conventions used by `buildAllProjectFileContents` - * and the save use case on the backend, so the version-control slice can - * compare against the baseline byte-for-byte. + * Resolve the canonical specs for a single named file (POU, datatype, server, + * etc.). Returns multiple specs only for the `device` editor type, which + * persists both the configuration and pin-mapping JSON files. */ -function collectSavedRecordsForFile( +function serializeProjectFile( fileName: string, file: { type: string | null; filePath: string }, - state: ReturnType, -): Array<{ path: string; content: string }> { + state: StoreState, +): ProjectFileSpec[] { const { project, deviceDefinitions } = state const isPouType = file.type === 'program' || file.type === 'function' || file.type === 'function-block' if (isPouType) { const pou = project.data.pous.find((p) => p.name === fileName) - if (!pou) return [] - const editorModel = state.editorActions.getEditorFromEditors(pou.name) - const sanitized = stripGraphicalSelections(sanitizePou(pou, editorModel ?? undefined)) - const folder = getFolderFromPouType(pou.pouType) - const ext = getExtensionFromLanguage(pou.body.language) - return [{ path: `pous/${folder}/${pou.name}${ext}`, content: serializePouToText(sanitized) }] + return pou ? [buildPouSpec(pou, state)] : [] } if (file.type === 'device') { return [ - { path: 'devices/configuration.json', content: JSON.stringify(deviceDefinitions.configuration, null, 2) }, - { path: 'devices/pin-mapping.json', content: JSON.stringify(deviceDefinitions.pinMapping.pins, null, 2) }, + { + path: 'devices/configuration.json', + content: JSON.stringify(deviceDefinitions.configuration, null, 2), + category: 'device-config', + }, + { + path: 'devices/pin-mapping.json', + content: JSON.stringify(deviceDefinitions.pinMapping.pins, null, 2), + category: 'pin-mapping', + }, ] } if (file.type === 'server') { const server = project.data.servers?.find((s) => s.name === fileName) if (!server) return [] - return [{ path: `devices/servers/${fileName}.json`, content: JSON.stringify(server, null, 2) }] + return [ + { path: `devices/servers/${fileName}.json`, content: JSON.stringify(server, null, 2), category: 'server' }, + ] } if (file.type === 'remote-device') { const device = project.data.remoteDevices?.find((d) => d.name === fileName) if (!device) return [] - return [{ path: `devices/remote/${fileName}.json`, content: JSON.stringify(device, null, 2) }] + return [ + { + path: `devices/remote/${fileName}.json`, + content: JSON.stringify(device, null, 2), + category: 'remote-device', + }, + ] } if (file.type === 'ethercat-device') { const bus = project.data.remoteDevices?.find((d) => d.name === file.filePath) if (!bus) return [] - return [{ path: `devices/remote/${file.filePath}.json`, content: JSON.stringify(bus, null, 2) }] + return [ + { + path: `devices/remote/${file.filePath}.json`, + content: JSON.stringify(bus, null, 2), + category: 'remote-device', + }, + ] } // data-type, resource: live in project.json - const debugVariables = collectDebugVariables(project.data.configurations.resource.globalVariables, project.data.pous) - return [ - { - path: 'project.json', - content: JSON.stringify( - { - meta: { name: project.meta.name, type: 'plc-project' }, - data: { - dataTypes: project.data.dataTypes, - pous: [], - configuration: project.data.configurations, - debugVariables, - }, - }, - null, - 2, - ), - }, - ] + return [{ path: 'project.json', content: buildProjectJsonContent(state), category: 'project-json' }] } +// --------------------------------------------------------------------------- +// Public file-content builders +// --------------------------------------------------------------------------- + /** - * Strip transient UI state (selected, selectedNodes) from graphical POU body - * before serializing to disk. This prevents nodes from loading as selected - * on the next open, which would cause a spurious dirty mark on first click. + * Pure-serialize every project file (no raw fallback). Use this to capture + * the "state at sync point" snapshot stored in + * `versionControl.loadedSerialized`, so the save flow can later detect + * "state hasn't changed since sync" via byte-equality comparison. + * + * Also used by the version-control changes panel to render the diff preview, + * so what the user sees there is byte-identical to what the next commit + * would upload. */ -function stripGraphicalSelections(pou: T): T { - const lang = pou.body.language - if (lang !== 'ld' && lang !== 'fbd') return pou - - const body = pou.body.value as Record - if (!body) return pou - - if (lang === 'ld' && Array.isArray(body.rungs)) { - return { - ...pou, - body: { - ...pou.body, - value: { - ...body, - rungs: (body.rungs as Array>).map((rung) => ({ - ...rung, - selectedNodes: [], - nodes: Array.isArray(rung.nodes) - ? (rung.nodes as Array>).map((n) => ({ - ...n, - selected: false, - dragging: false, - })) - : rung.nodes, - })), - }, - }, - } +export function buildAllProjectFileContentsPure(): Record { + const state = openPLCStoreBase.getState() + const result: Record = {} + for (const spec of iterateProjectFiles(state)) { + result[spec.path] = spec.content } + return result +} - if (lang === 'fbd' && body.rung) { - const rung = body.rung as Record - return { - ...pou, - body: { - ...pou.body, - value: { - ...body, - rung: { - ...rung, - selectedNodes: [], - nodes: Array.isArray(rung.nodes) - ? (rung.nodes as Array>).map((n) => ({ - ...n, - selected: false, - dragging: false, - })) - : rung.nodes, - }, - }, - }, - } +/** + * Build the file content map the save flow actually uploads. Like + * `buildAllProjectFileContentsPure`, but applies the raw-fallback for files + * whose serialized state hasn't changed since the last sync (byte-stable + * echo back to S3, no phantom modifications vs HEAD). + * + * Used as input to `versionControlActions.commitBaseline` so the post-commit + * baseline matches what was actually on S3 at commit time. + */ +export function buildAllProjectFileContents(): Record { + const state = openPLCStoreBase.getState() + const result: Record = {} + for (const spec of iterateProjectFiles(state)) { + result[spec.path] = pickContentForSave(spec.path, spec.content, state.versionControl) } - - return pou + return result } +// --------------------------------------------------------------------------- +// Save flows +// --------------------------------------------------------------------------- + /** * Save the entire project (all files, device config, debug variables). * Equivalent to Ctrl+Shift+S / "Save Project" menu item. @@ -244,7 +250,7 @@ function stripGraphicalSelections { const state = openPLCStoreBase.getState() - const { project, pendingDeletions, deviceDefinitions } = state + const { project, pendingDeletions } = state const { setEditingState } = state.workspaceActions const { setAllToSaved } = state.fileActions const { markAllSaved } = state.snapshotActions @@ -259,59 +265,40 @@ export async function executeSaveProject(projectPort: ProjectPort): Promise<{ su }) try { - // Build content per path using `pickContentForSave`: it compares the - // freshly serialized form to the snapshot at the last sync point and - // falls back to the raw text when the state hasn't changed — keeping - // S3 byte-identical to HEAD for unchanged files. This works uniformly - // for POUs, server/device JSON, and the special files that have no - // file-slice tracking (project.json, devices/configuration.json, etc.). - const pouFiles: RawProjectFile[] = project.data.pous.map((pou) => { - const folder = getFolderFromPouType(pou.pouType) - const ext = getExtensionFromLanguage(pou.body.language) - const relativePath = `pous/${folder}/${pou.name}${ext}` - const editorModel = state.editorActions.getEditorFromEditors(pou.name) - const sanitized = stripGraphicalSelections(sanitizePou(pou, editorModel ?? undefined)) - const fresh = serializePouToText(sanitized) - return { relativePath, content: pickContentForSave(relativePath, fresh, state.versionControl) } - }) - - const serverFiles: RawProjectFile[] = (project.data.servers ?? []).map((s) => { - const relativePath = `devices/servers/${s.name}.json` - const fresh = JSON.stringify(s, null, 2) - return { relativePath, content: pickContentForSave(relativePath, fresh, state.versionControl) } - }) - - const remoteDeviceFiles: RawProjectFile[] = (project.data.remoteDevices ?? []).map((d) => { - const relativePath = `devices/remote/${d.name}.json` - const fresh = JSON.stringify(d, null, 2) - return { relativePath, content: pickContentForSave(relativePath, fresh, state.versionControl) } - }) - - // Build project.json — same structure as single-file save - const debugVariables = collectDebugVariables( - project.data.configurations.resource.globalVariables, - project.data.pous, - ) - const projectJsonFresh = JSON.stringify( - { - meta: { name: project.meta.name, type: 'plc-project' }, - data: { - dataTypes: project.data.dataTypes, - pous: [], - configuration: project.data.configurations, - debugVariables, - }, - }, - null, - 2, - ) - const projectJson = pickContentForSave('project.json', projectJsonFresh, state.versionControl) - - const deviceConfigFresh = JSON.stringify(deviceDefinitions.configuration, null, 2) - const deviceConfig = pickContentForSave('devices/configuration.json', deviceConfigFresh, state.versionControl) - - const pinMappingFresh = JSON.stringify(deviceDefinitions.pinMapping.pins, null, 2) - const pinMapping = pickContentForSave('devices/pin-mapping.json', pinMappingFresh, state.versionControl) + // Group every spec by category so we can build the platform's + // category-shaped WriteProjectFiles struct without duplicating the + // serialization logic. `pickContentForSave` keeps unedited files + // byte-identical to their last-synced raw content. + const pouFiles: RawProjectFile[] = [] + const serverFiles: RawProjectFile[] = [] + const remoteDeviceFiles: RawProjectFile[] = [] + let projectJson = '' + let deviceConfig = '' + let pinMapping = '' + + for (const spec of iterateProjectFiles(state)) { + const content = pickContentForSave(spec.path, spec.content, state.versionControl) + switch (spec.category) { + case 'pou': + pouFiles.push({ relativePath: spec.path, content }) + break + case 'server': + serverFiles.push({ relativePath: spec.path, content }) + break + case 'remote-device': + remoteDeviceFiles.push({ relativePath: spec.path, content }) + break + case 'device-config': + deviceConfig = content + break + case 'pin-mapping': + pinMapping = content + break + case 'project-json': + projectJson = content + break + } + } const files: WriteProjectFiles = { projectPath: project.meta.path, @@ -417,83 +404,68 @@ export async function executeSaveFile(fileName: string, projectPort: ProjectPort } try { + // Use the same canonical serializer as the full-project save path so + // both flows agree on what bytes hit disk. For POUs and JSON files this + // is a one-shot lookup; the special `device` type returns two specs + // (configuration + pin-mapping). + const specs = serializeProjectFile(fileName, file, state) + if (specs.length === 0) { + // Some categories (e.g. ethercat-device) need handling that doesn't + // map to a single fileName lookup, so fall through to the legacy path. + } + const isPouType = file.type === 'program' || file.type === 'function' || file.type === 'function-block' if (isPouType) { + const spec = specs[0] + if (!spec) return fail(`POU "${fileName}" not found.`) const pou = project.data.pous.find((p) => p.name === fileName) if (!pou) return fail(`POU "${fileName}" not found.`) - - const editorModel = state.editorActions.getEditorFromEditors(fileName) - const sanitized = stripGraphicalSelections(sanitizePou(pou, editorModel ?? undefined)) - const textContent = serializePouToText(sanitized) const folder = getFolderFromPouType(pou.pouType) const ext = getExtensionFromLanguage(pou.body.language) - - const res = await projectPort.saveFile(joinPath(projectPath, 'pous', folder, `${fileName}${ext}`), textContent) + const res = await projectPort.saveFile(joinPath(projectPath, 'pous', folder, `${fileName}${ext}`), spec.content) if (!res.success) return fail(res.error ?? 'Save failed') } else if (file.type === 'device') { - const configRes = await projectPort.saveFile( - joinPath(projectPath, 'devices/configuration.json'), - JSON.stringify(state.deviceDefinitions.configuration, null, 2), - ) - const pinRes = await projectPort.saveFile( - joinPath(projectPath, 'devices/pin-mapping.json'), - JSON.stringify(state.deviceDefinitions.pinMapping.pins, null, 2), - ) + const config = specs.find((s) => s.category === 'device-config') + const pin = specs.find((s) => s.category === 'pin-mapping') + if (!config || !pin) return fail('Save failed') + const configRes = await projectPort.saveFile(joinPath(projectPath, 'devices/configuration.json'), config.content) + const pinRes = await projectPort.saveFile(joinPath(projectPath, 'devices/pin-mapping.json'), pin.content) if (!configRes.success || !pinRes.success) return fail('Save failed') } else if (file.type === 'server') { - const server = project.data.servers?.find((s) => s.name === fileName) - if (!server) return fail(`Server "${fileName}" not found.`) - const res = await projectPort.saveFile( - joinPath(projectPath, 'devices/servers', `${fileName}.json`), - JSON.stringify(server, null, 2), - ) + const spec = specs[0] + if (!spec) return fail(`Server "${fileName}" not found.`) + const res = await projectPort.saveFile(joinPath(projectPath, 'devices/servers', `${fileName}.json`), spec.content) if (!res.success) return fail(res.error ?? 'Save failed') } else if (file.type === 'remote-device') { - const device = project.data.remoteDevices?.find((d) => d.name === fileName) - if (!device) return fail(`Remote device "${fileName}" not found.`) - const res = await projectPort.saveFile( - joinPath(projectPath, 'devices/remote', `${fileName}.json`), - JSON.stringify(device, null, 2), - ) + const spec = specs[0] + if (!spec) return fail(`Remote device "${fileName}" not found.`) + const res = await projectPort.saveFile(joinPath(projectPath, 'devices/remote', `${fileName}.json`), spec.content) if (!res.success) return fail(res.error ?? 'Save failed') } else if (file.type === 'ethercat-device') { // Slave devices live inside the parent bus file. filePath holds the bus name. - const busName = file.filePath - const bus = project.data.remoteDevices?.find((d) => d.name === busName) - if (!bus) return fail(`Parent bus "${busName}" not found for device "${fileName}".`) + const spec = specs[0] + if (!spec) return fail(`Parent bus "${file.filePath}" not found for device "${fileName}".`) const res = await projectPort.saveFile( - joinPath(projectPath, 'devices/remote', `${busName}.json`), - JSON.stringify(bus, null, 2), + joinPath(projectPath, 'devices/remote', `${file.filePath}.json`), + spec.content, ) if (!res.success) return fail(res.error ?? 'Save failed') } else { // data-type, resource: live in project.json - const debugVariables = collectDebugVariables( - project.data.configurations.resource.globalVariables, - project.data.pous, - ) - const projectJson = { - meta: { name: project.meta.name, type: 'plc-project' }, - data: { - dataTypes: project.data.dataTypes, - pous: [], - configuration: project.data.configurations, - debugVariables, - }, - } - const res = await projectPort.saveFile( - joinPath(projectPath, 'project.json'), - JSON.stringify(projectJson, null, 2), - ) + const spec = specs[0] + if (!spec) return fail('Save failed') + const res = await projectPort.saveFile(joinPath(projectPath, 'project.json'), spec.content) if (!res.success) return fail(res.error ?? 'Save failed') } // Tell the version-control slice exactly which paths/content were just // sent. The slice diffs against baseline to add or remove from changedPaths. - const savedRecords = collectSavedRecordsForFile(fileName, file, state) - if (savedRecords.length > 0) { - state.versionControlActions.recordSavedFiles({ saved: savedRecords, deleted: [] }) + if (specs.length > 0) { + state.versionControlActions.recordSavedFiles({ + saved: specs.map((spec) => ({ path: spec.path, content: spec.content })), + deleted: [], + }) } // Mark only this file as saved diff --git a/src/frontend/store/slices/version-control/slice.ts b/src/frontend/store/slices/version-control/slice.ts index a26da18fd..a4d32b819 100644 --- a/src/frontend/store/slices/version-control/slice.ts +++ b/src/frontend/store/slices/version-control/slice.ts @@ -5,7 +5,6 @@ import type { InitialPendingEntry, SavedFileRecord, SidePanel, VersionControlSli const initialState: VersionControlSlice['versionControl'] = { activePanel: 'explorer', - activeBranch: null, selectedCommitHash: null, initialPending: [], baselineContent: {}, @@ -44,13 +43,6 @@ const createVersionControlSlice: StateCreator - setState( - produce((draft) => { - draft.versionControl.activeBranch = branchName - }), - ), - setSelectedCommitHash: (hash: string | null) => setState( produce((draft) => { diff --git a/src/frontend/store/slices/version-control/types.ts b/src/frontend/store/slices/version-control/types.ts index 531c10267..3e3255d03 100644 --- a/src/frontend/store/slices/version-control/types.ts +++ b/src/frontend/store/slices/version-control/types.ts @@ -7,7 +7,6 @@ export type InitialPendingEntry = { path: string; status: PendingChangeStatus } export type VersionControlState = { versionControl: { activePanel: SidePanel - activeBranch: string | null selectedCommitHash: string | null /** * Files flagged by /changes at the last sync point, paired with the @@ -60,7 +59,6 @@ export type SavedFileRecord = { path: string; content: string } export type VersionControlActions = { setActivePanel: (panel: SidePanel) => void - setActiveBranch: (branchName: string | null) => void setSelectedCommitHash: (hash: string | null) => void /** * Snapshot baseline + initial pending at the last "in-sync" point diff --git a/src/frontend/utils/sanitize-branch-name.ts b/src/frontend/utils/sanitize-branch-name.ts index 2c1290ad1..1f735c59c 100644 --- a/src/frontend/utils/sanitize-branch-name.ts +++ b/src/frontend/utils/sanitize-branch-name.ts @@ -16,26 +16,83 @@ * 7. Strip trailing `.lock`. * 8. Strip ASCII control characters. * 9. Truncate to 255. + * + * `sanitizeBranchName` returns just the cleaned string. `sanitizeBranchNameDetailed` + * also returns the set of transforms that actually happened, which feeds the + * live-preview notes in `getBranchNameFeedback` without re-running the regex + * chain a second time. */ -export function sanitizeBranchName(input: string): string { - if (!input) return '' - let result = input +type TransformKind = + | 'spaces-replaced' + | 'non-ascii-stripped' + | 'diacritics-stripped' + | 'forbidden-chars-replaced' + | 'dot-sequences-replaced' + | 'at-brace-replaced' + | 'leading-or-trailing-stripped' + | 'lock-suffix-stripped' + | 'truncated' + +const TRANSFORM_NOTES: Record = { + 'spaces-replaced': "Spaces will be replaced with '-'.", + 'non-ascii-stripped': 'Non-ASCII characters (e.g. kanji, emojis) will be removed.', + 'diacritics-stripped': 'Accent marks will be stripped (e.g. "é" → "e").', + 'forbidden-chars-replaced': "Characters ~ ^ : ? * [ ] \\ will be replaced with '-'.", + 'dot-sequences-replaced': "Sequences of '.' will be replaced with '-'.", + 'at-brace-replaced': "'@{' is not allowed and will be replaced with '-'.", + 'leading-or-trailing-stripped': "Leading/trailing '.', '/' or '-' will be stripped.", + 'lock-suffix-stripped': "'.lock' suffix is reserved and will be stripped.", + truncated: 'Names longer than 255 characters will be truncated.', +} - // Decompose accented chars into base + combining marks, then drop the marks. - // Non-ASCII characters that don't decompose into ASCII (e.g. kanji) survive - // this step and are stripped by the next. - result = result.normalize('NFD').replace(/[̀-ͯ]/g, '') +// Fixed display order for notes in the live preview, independent of the +// internal transform order in the sanitizer. Keep in sync with the original +// `getBranchNameFeedback` ordering so existing screenshots/tests still match. +const NOTE_ORDER: readonly TransformKind[] = [ + 'spaces-replaced', + 'non-ascii-stripped', + 'diacritics-stripped', + 'forbidden-chars-replaced', + 'dot-sequences-replaced', + 'at-brace-replaced', + 'leading-or-trailing-stripped', + 'lock-suffix-stripped', + 'truncated', +] + +function sanitizeBranchNameDetailed(input: string): { result: string; transforms: Set } { + const transforms = new Set() + if (!input) return { result: '', transforms } + + // Decompose accented chars into base + combining marks. + const decomposed = input.normalize('NFD') + const withoutDiacritics = decomposed.replace(/[̀-ͯ]/g, '') + if (withoutDiacritics !== decomposed) transforms.add('diacritics-stripped') // Strip non-ASCII (anything outside printable ASCII range). - result = result.replace(/[^\x20-\x7E]/g, '') + let result = withoutDiacritics.replace(/[^\x20-\x7E]/g, '') + if (result !== withoutDiacritics) transforms.add('non-ascii-stripped') // Whitespace and forbidden-anywhere characters → '-'. - result = result.replace(/[\s~^:?*[\]\\]/g, '-') + // We split spaces from the other forbidden chars so the user gets a + // targeted note ("spaces" vs "~ ^ : ? *…"). + const beforeSpaces = result + result = result.replace(/\s+/g, '-') + if (result !== beforeSpaces) transforms.add('spaces-replaced') + + const beforeForbidden = result + result = result.replace(/[~^:?*[\]\\]/g, '-') + if (result !== beforeForbidden) transforms.add('forbidden-chars-replaced') // Forbidden sequences. + const beforeDots = result result = result.replace(/\.\.+/g, '-') + if (result !== beforeDots) transforms.add('dot-sequences-replaced') + + const beforeAtBrace = result result = result.replace(/@\{/g, '-') + if (result !== beforeAtBrace) transforms.add('at-brace-replaced') // Strip ASCII control chars (defensive — most are filtered above). result = result @@ -50,15 +107,26 @@ export function sanitizeBranchName(input: string): string { result = result.replace(/-+/g, '-') // Strip leading/trailing '.', '/', '-'. + const beforeEdges = result result = result.replace(/^[./-]+/, '').replace(/[./-]+$/, '') + if (result !== beforeEdges) transforms.add('leading-or-trailing-stripped') // Strip trailing '.lock' (case-insensitive). + const beforeLock = result result = result.replace(/\.lock$/i, '') + if (result !== beforeLock) transforms.add('lock-suffix-stripped') // Truncate. - if (result.length > 255) result = result.slice(0, 255) + if (result.length > 255) { + result = result.slice(0, 255) + transforms.add('truncated') + } + + return { result, transforms } +} - return result +export function sanitizeBranchName(input: string): string { + return sanitizeBranchNameDetailed(input).result } export type BranchNameFeedback = { @@ -83,36 +151,10 @@ export type BranchNameFeedback = { * uses this to render a live preview without reaching the backend. */ export function getBranchNameFeedback(input: string): BranchNameFeedback { - const sanitized = sanitizeBranchName(input) - const changed = sanitized !== input + const { result: sanitized, transforms } = sanitizeBranchNameDetailed(input) const notes: string[] = [] - - if (input.length > 0 && /\s/.test(input)) { - notes.push("Spaces will be replaced with '-'.") - } - if (/[^\x20-\x7E]/.test(input.normalize('NFD').replace(/[̀-ͯ]/g, ''))) { - notes.push('Non-ASCII characters (e.g. kanji, emojis) will be removed.') - } - if (/[̀-ͯ]/.test(input.normalize('NFD'))) { - notes.push('Accent marks will be stripped (e.g. "é" → "e").') - } - if (/[~^:?*[\]\\]/.test(input)) { - notes.push("Characters ~ ^ : ? * [ ] \\ will be replaced with '-'.") - } - if (input.includes('..')) { - notes.push("Sequences of '.' will be replaced with '-'.") - } - if (input.includes('@{')) { - notes.push("'@{' is not allowed and will be replaced with '-'.") - } - if (/^[./-]/.test(input) || /[./-]$/.test(input)) { - notes.push("Leading/trailing '.', '/' or '-' will be stripped.") - } - if (/\.lock$/i.test(input)) { - notes.push("'.lock' suffix is reserved and will be stripped.") - } - if (input.length > 255) { - notes.push('Names longer than 255 characters will be truncated.') + for (const kind of NOTE_ORDER) { + if (transforms.has(kind)) notes.push(TRANSFORM_NOTES[kind]) } let error: string | null = null @@ -120,5 +162,5 @@ export function getBranchNameFeedback(input: string): BranchNameFeedback { error = 'No valid characters left after sanitization.' } - return { sanitized, changed, notes, error } + return { sanitized, changed: sanitized !== input, notes, error } } diff --git a/src/frontend/utils/save-project.ts b/src/frontend/utils/save-project.ts index d4f5048cc..8ee01c450 100644 --- a/src/frontend/utils/save-project.ts +++ b/src/frontend/utils/save-project.ts @@ -23,22 +23,92 @@ export interface EditorLike { // --------------------------------------------------------------------------- /** - * Syncs a POU's variablesText with the current code editor state. + * Prepare a POU for serialization to disk: * - * When a user edits variables in "code" display mode, the text lives in the - * editor model but hasn't been parsed back into structured variables yet. - * Before saving we must capture that text so the IPC layer writes it to disk. + * 1. If the user edited variables in "code" display mode, capture the raw + * editor text into `variablesText` so the IPC layer writes that as the + * authoritative variables block. + * 2. For graphical bodies (LD/FBD), clear transient UI state from every + * node — `selected`, `dragging`, and `selectedNodes`. Without this, + * reopening a project loads nodes pre-selected, and the first deselect + * click triggers `updateNode` which marks the file dirty. + * + * Both behaviors used to live in two different helpers (`sanitizePou` and a + * post-pass `stripGraphicalSelections`) called in lockstep at every save + * site. Folding them together makes the contract single-source-of-truth: + * "give me a POU ready to write to disk." */ export function sanitizePou(pou: PLCPou, editor: EditorLike | undefined): PLCPou { - if (!editor || (editor.type !== 'plc-textual' && editor.type !== 'plc-graphical') || !editor.variable) { - return pou + let next: PLCPou = pou + + if ( + editor && + (editor.type === 'plc-textual' || editor.type === 'plc-graphical') && + editor.variable && + editor.variable.display === 'code' && + editor.variable.code != null + ) { + next = { + ...next, + variablesText: editor.variable.code, + } as PLCPou & { variablesText?: string } + } + + return stripGraphicalSelections(next) +} + +function stripGraphicalSelections(pou: PLCPou): PLCPou { + const lang = pou.body.language + if (lang !== 'ld' && lang !== 'fbd') return pou + + const body = pou.body.value as Record | undefined + if (!body) return pou + + if (lang === 'ld' && Array.isArray(body.rungs)) { + return { + ...pou, + body: { + ...pou.body, + value: { + ...body, + rungs: (body.rungs as Array>).map((rung) => ({ + ...rung, + selectedNodes: [], + nodes: Array.isArray(rung.nodes) + ? (rung.nodes as Array>).map((n) => ({ + ...n, + selected: false, + dragging: false, + })) + : rung.nodes, + })), + }, + }, + } as PLCPou } - if (editor.variable.display === 'code' && editor.variable.code != null) { + if (lang === 'fbd' && body.rung) { + const rung = body.rung as Record return { ...pou, - variablesText: editor.variable.code, - } as PLCPou & { variablesText?: string } + body: { + ...pou.body, + value: { + ...body, + rung: { + ...rung, + selectedNodes: [], + nodes: Array.isArray(rung.nodes) + ? (rung.nodes as Array>).map((n) => ({ + ...n, + selected: false, + dragging: false, + })) + : rung.nodes, + }, + }, + }, + } as PLCPou } return pou From 525004728db48ab997c5c010f70c3f0432b8e5e9 Mon Sep 17 00:00:00 2001 From: JulioSergioFS Date: Wed, 29 Apr 2026 12:16:48 -0300 Subject: [PATCH 4/5] refactor: simplify ProjectFileCategory type definition and streamline server file serialization --- src/frontend/services/save-actions.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/frontend/services/save-actions.ts b/src/frontend/services/save-actions.ts index ba0c47b35..79a041d03 100644 --- a/src/frontend/services/save-actions.ts +++ b/src/frontend/services/save-actions.ts @@ -34,13 +34,7 @@ const joinPath = (...parts: string[]): string => parts.join('/').replace(/\/+/g, type StoreState = ReturnType -type ProjectFileCategory = - | 'pou' - | 'server' - | 'remote-device' - | 'device-config' - | 'pin-mapping' - | 'project-json' +type ProjectFileCategory = 'pou' | 'server' | 'remote-device' | 'device-config' | 'pin-mapping' | 'project-json' type ProjectFileSpec = { path: string @@ -162,9 +156,7 @@ function serializeProjectFile( if (file.type === 'server') { const server = project.data.servers?.find((s) => s.name === fileName) if (!server) return [] - return [ - { path: `devices/servers/${fileName}.json`, content: JSON.stringify(server, null, 2), category: 'server' }, - ] + return [{ path: `devices/servers/${fileName}.json`, content: JSON.stringify(server, null, 2), category: 'server' }] } if (file.type === 'remote-device') { From 1efed228219e5af92e15b97fd81472fcd2c3d0fe Mon Sep 17 00:00:00 2001 From: JulioSergioFS Date: Wed, 29 Apr 2026 15:49:04 -0300 Subject: [PATCH 5/5] feat(navigation): implement navigation adapter and integrate into version control components --- .../branches/branch-status-bar.tsx | 15 ++--- .../source-control/commit-details.tsx | 20 +++--- .../adapters/editor/navigation-adapter.ts | 35 +++++++++++ src/middleware/editor-platform.ts | 2 + src/middleware/shared/ports/index.ts | 2 + .../shared/ports/navigation-port.ts | 62 +++++++++++++++++++ src/middleware/shared/providers/index.ts | 1 + .../shared/providers/platform-context.tsx | 4 ++ src/middleware/shared/providers/types.ts | 2 + 9 files changed, 127 insertions(+), 16 deletions(-) create mode 100644 src/middleware/adapters/editor/navigation-adapter.ts create mode 100644 src/middleware/shared/ports/navigation-port.ts diff --git a/src/frontend/components/_features/[workspace]/branches/branch-status-bar.tsx b/src/frontend/components/_features/[workspace]/branches/branch-status-bar.tsx index ad048c242..e604c5983 100644 --- a/src/frontend/components/_features/[workspace]/branches/branch-status-bar.tsx +++ b/src/frontend/components/_features/[workspace]/branches/branch-status-bar.tsx @@ -1,7 +1,7 @@ import { useCallback, useRef, useState } from 'react' import type { Branch } from '../../../../../middleware/shared/ports/version-control-port' -import { useVersionControl } from '../../../../../middleware/shared/providers' +import { useNavigation, useVersionControl } from '../../../../../middleware/shared/providers' import { useActiveBranch } from '../../../../hooks/use-active-branch' import { BranchSwitcherPopover } from './branch-switcher-popover' import { DeleteBranchModal } from './delete-branch-modal' @@ -14,6 +14,7 @@ type BranchStatusBarProps = { export function BranchStatusBar({ projectId, onBranchSwitch }: BranchStatusBarProps) { const versionControl = useVersionControl() + const navigation = useNavigation() const [activeBranchName, setActiveBranch] = useActiveBranch(projectId) const branchButtonRef = useRef(null) @@ -84,13 +85,13 @@ export function BranchStatusBar({ projectId, onBranchSwitch }: BranchStatusBarPr // Source is the clicked branch; default target to the active branch // (if different). When source == active, omit `target` entirely so the // merge page can apply its own default rather than receiving `target=`. - const params = new URLSearchParams({ project_id: projectId, source: branch.name }) - if (activeBranchName !== branch.name) { - params.set('target', activeBranchName) - } - window.location.href = `/merge?${params.toString()}` + navigation.navigate('/merge', { + project_id: projectId, + source: branch.name, + target: activeBranchName !== branch.name ? activeBranchName : undefined, + }) }, - [projectId, activeBranchName], + [projectId, activeBranchName, navigation], ) const handleDeleted = useCallback(() => { diff --git a/src/frontend/components/_features/[workspace]/source-control/commit-details.tsx b/src/frontend/components/_features/[workspace]/source-control/commit-details.tsx index 4b09ecb34..edb1cc207 100644 --- a/src/frontend/components/_features/[workspace]/source-control/commit-details.tsx +++ b/src/frontend/components/_features/[workspace]/source-control/commit-details.tsx @@ -2,7 +2,7 @@ import { ChevronDown, ChevronRight, File } from 'lucide-react' import { useState } from 'react' import type { Commit, CommitFile } from '../../../../../middleware/shared/ports/version-control-port' -import { useProject, useVersionControl } from '../../../../../middleware/shared/providers' +import { useNavigation, useProject, useVersionControl } from '../../../../../middleware/shared/providers' import { useOpenPLCStore } from '../../../../store' import { cn } from '../../../../utils/cn' import { toast } from '../../../../utils/toast' @@ -15,6 +15,7 @@ type CommitDetailsProps = { export function CommitDetails({ commit, projectId }: CommitDetailsProps) { const versionControl = useVersionControl() + const navigation = useNavigation() const { project: { meta: { path: storedProjectId }, @@ -54,17 +55,18 @@ export function CommitDetails({ commit, projectId }: CommitDetailsProps) { } const handleViewFiles = () => { - window.open( - `/history?project_id=${encodeURIComponent(effectiveProjectId)}&commit_hash=${encodeURIComponent(commit.hash)}`, - '_blank', - ) + navigation.openInNewWindow('/history', { + project_id: effectiveProjectId, + commit_hash: commit.hash, + }) } const handleFileClick = (filePath: string) => { - window.open( - `/history?project_id=${encodeURIComponent(effectiveProjectId)}&commit_hash=${encodeURIComponent(commit.hash)}&file=${encodeURIComponent(filePath)}`, - '_blank', - ) + navigation.openInNewWindow('/history', { + project_id: effectiveProjectId, + commit_hash: commit.hash, + file: filePath, + }) } const handleRestore = async () => { diff --git a/src/middleware/adapters/editor/navigation-adapter.ts b/src/middleware/adapters/editor/navigation-adapter.ts new file mode 100644 index 000000000..a95971f22 --- /dev/null +++ b/src/middleware/adapters/editor/navigation-adapter.ts @@ -0,0 +1,35 @@ +/** + * Editor NavigationPort adapter. + * + * The Electron editor has no SPA router — navigation is tab-driven via the + * Zustand `tabs`/`editor` slices. The routed features that drive shared + * navigation calls (the merge page, the history page) are gated away by + * `capabilities.hasVersionControl=false`, so in practice these methods + * never fire from the editor build. + * + * The fallbacks below exist purely so the port satisfies its contract for + * any code path that does call into it (a no-op would silently swallow a + * navigation request, which is harder to debug): + * - `navigate` writes to `window.location.href`. Inside the Electron + * renderer this reloads the SPA shell at the requested path; for + * unknown routes it simply lands back on the index, but the user + * gets a deterministic outcome instead of nothing happening. + * - `openInNewWindow` calls `window.open(url, '_blank')`. Electron + * translates this into a fresh `BrowserWindow`, matching what the + * existing "Open in new tab" affordances did before this port. + */ + +import type { NavigationPort, NavigationSearch } from '../../shared/ports/navigation-port' +import { buildNavigationUrl } from '../../shared/ports/navigation-port' + +export function createEditorNavigationAdapter(): NavigationPort { + return { + navigate(path: string, search?: NavigationSearch): void { + window.location.href = buildNavigationUrl(path, search) + }, + + openInNewWindow(path: string, search?: NavigationSearch): void { + window.open(buildNavigationUrl(path, search), '_blank') + }, + } +} diff --git a/src/middleware/editor-platform.ts b/src/middleware/editor-platform.ts index d18cb6e15..7832507ec 100644 --- a/src/middleware/editor-platform.ts +++ b/src/middleware/editor-platform.ts @@ -18,6 +18,7 @@ import { createEditorCompilerAdapter } from './adapters/editor/compiler-adapter' import { createEditorDebuggerAdapter } from './adapters/editor/debugger-adapter' import { createEditorDeviceAdapter } from './adapters/editor/device-adapter' import { createEditorEsiAdapter } from './adapters/editor/esi-adapter' +import { createEditorNavigationAdapter } from './adapters/editor/navigation-adapter' import { createEditorOrchestratorAdapter } from './adapters/editor/orchestrator-adapter' import { createEditorProjectAdapter } from './adapters/editor/project-adapter' import { createEditorRuntimeAdapter } from './adapters/editor/runtime-adapter' @@ -61,5 +62,6 @@ export const editorPorts: PlatformPorts = { theme: createEditorThemeAdapter(), esi: createEditorEsiAdapter(() => _projectPath), versionControl: createEditorVersionControlAdapter(), + navigation: createEditorNavigationAdapter(), capabilities: { ...EDITOR_CAPABILITIES, isDevMode: process.env.NODE_ENV === 'development' }, } diff --git a/src/middleware/shared/ports/index.ts b/src/middleware/shared/ports/index.ts index d5faad647..eba5e51d4 100644 --- a/src/middleware/shared/ports/index.ts +++ b/src/middleware/shared/ports/index.ts @@ -57,6 +57,8 @@ export type { export type { CompilerPort } from './compiler-port' export type { DebuggerPort } from './debugger-port' export type { DevicePort } from './device-port' +export type { NavigationPort, NavigationSearch } from './navigation-port' +export { buildNavigationUrl } from './navigation-port' export type { OrchestratorPort } from './orchestrator-port' export type { ProjectPort } from './project-port' export type { RuntimePort } from './runtime-port' diff --git a/src/middleware/shared/ports/navigation-port.ts b/src/middleware/shared/ports/navigation-port.ts new file mode 100644 index 000000000..107d8dd87 --- /dev/null +++ b/src/middleware/shared/ports/navigation-port.ts @@ -0,0 +1,62 @@ +/** + * NavigationPort — Abstracts in-app and external navigation. + * + * Editor adapter: Falls back to `window.open` (new BrowserWindow) for + * secondary windows; `navigate` is a best-effort + * `window.location.href` fallback. The editor has no + * SPA router, but the routed features (merge, history) + * are gated behind `capabilities.hasVersionControl=false` + * and never reached in practice. + * Web adapter: Delegates to TanStack Router's `router.navigate(...)` + * for in-app navigation (preserves SPA state, no full + * reload) and `window.open(url, '_blank')` for the + * "open in a new tab" flow. + * + * Why a port: shared UI components like the version-control panel need + * to navigate between pages (`/merge`, `/history`, `/?project_id=…`). + * The web app uses TanStack Router; importing `useNavigate` directly in + * shared code would break the editor at bundle time because the editor + * doesn't depend on `@tanstack/react-router`. Going through a port lets + * each platform implement navigation in its native way while exposing + * the same interface to the shared UI. + */ + +/** + * Search/query params for a navigation. Values may be `undefined`, in + * which case the adapter is expected to omit the key from the URL + * entirely (instead of emitting an empty `key=` pair). + */ +export type NavigationSearch = Record + +export interface NavigationPort { + /** + * Navigate within the app to a route, preserving SPA state where + * possible. On platforms without a router, this is a best-effort + * fallback or a no-op. + */ + navigate(path: string, search?: NavigationSearch): void + + /** + * Open a route or external URL in a new window/tab. + * Web: `window.open(url, '_blank')` (new tab). + * Editor: `window.open(url, '_blank')` (new BrowserWindow). + */ + openInNewWindow(path: string, search?: NavigationSearch): void +} + +/** + * Build a `path?search` URL by serializing only the entries whose value + * is a non-empty string, encoding both keys and values. Returned as an + * absolute path so adapters can hand it directly to `window.open` or + * `window.location.href`. + */ +export function buildNavigationUrl(path: string, search?: NavigationSearch): string { + if (!search) return path + const params = new URLSearchParams() + for (const [key, value] of Object.entries(search)) { + if (value === undefined || value === '') continue + params.set(key, value) + } + const query = params.toString() + return query.length > 0 ? `${path}?${query}` : path +} diff --git a/src/middleware/shared/providers/index.ts b/src/middleware/shared/providers/index.ts index 2f59da468..e699664ea 100644 --- a/src/middleware/shared/providers/index.ts +++ b/src/middleware/shared/providers/index.ts @@ -7,6 +7,7 @@ export { useCompiler, useDebugger, useDevice, + useNavigation, useOrchestrator, useProject, useRuntime, diff --git a/src/middleware/shared/providers/platform-context.tsx b/src/middleware/shared/providers/platform-context.tsx index 005e503a1..ffae56f99 100644 --- a/src/middleware/shared/providers/platform-context.tsx +++ b/src/middleware/shared/providers/platform-context.tsx @@ -98,6 +98,10 @@ export function useVersionControl() { return usePlatform().versionControl } +export function useNavigation() { + return usePlatform().navigation +} + export function useCapabilities() { return usePlatform().capabilities } diff --git a/src/middleware/shared/providers/types.ts b/src/middleware/shared/providers/types.ts index b1cc10831..44dd3de1e 100644 --- a/src/middleware/shared/providers/types.ts +++ b/src/middleware/shared/providers/types.ts @@ -9,6 +9,7 @@ import type { CompilerPort } from '../ports/compiler-port' import type { DebuggerPort } from '../ports/debugger-port' import type { DevicePort } from '../ports/device-port' import type { EsiPort } from '../ports/esi-port' +import type { NavigationPort } from '../ports/navigation-port' import type { OrchestratorPort } from '../ports/orchestrator-port' import type { PlatformCapabilities } from '../ports/platform-capabilities' import type { ProjectPort } from '../ports/project-port' @@ -32,6 +33,7 @@ export interface PlatformPorts { accelerator: AcceleratorPort theme: ThemePort versionControl: VersionControlPort + navigation: NavigationPort capabilities: PlatformCapabilities esi?: EsiPort ai?: AIPort