diff --git a/src/frontend/components/_atoms/generic-table-inputs/generic-combobox-cell.tsx b/src/frontend/components/_atoms/generic-table-inputs/generic-combobox-cell.tsx index c346b3827..ba0d8dab2 100644 --- a/src/frontend/components/_atoms/generic-table-inputs/generic-combobox-cell.tsx +++ b/src/frontend/components/_atoms/generic-table-inputs/generic-combobox-cell.tsx @@ -70,6 +70,7 @@ export const GenericComboboxCell = ({ inputRef.current?.focus() }, 0) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectIsOpen, inputValue]) const isButtonDisabled = @@ -106,7 +107,7 @@ export const GenericComboboxCell = ({ } return result }, - [selectValues], + [], ) // Helper to filter options/groups recursively (moved out for reuse) @@ -134,7 +135,9 @@ export const GenericComboboxCell = ({ } // Flatten filtered options for navigation + // eslint-disable-next-line react-hooks/exhaustive-deps const filteredOptions = useMemo(() => filterOptions(selectValues, inputValue), [selectValues, inputValue]) + // eslint-disable-next-line react-hooks/exhaustive-deps const flatFilteredOptions = useMemo(() => flattenOptions(filteredOptions), [filteredOptions]) // Reset highlight only when dropdown is first opened @@ -142,6 +145,7 @@ export const GenericComboboxCell = ({ if (selectIsOpen) { setHighlightedIndex(flatFilteredOptions.findIndex((opt) => opt.value === value)) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectIsOpen]) // Scroll highlighted option into view diff --git a/src/frontend/components/_atoms/generic-table-inputs/generic-select-cell.tsx b/src/frontend/components/_atoms/generic-table-inputs/generic-select-cell.tsx index b9fde763d..289c5981a 100644 --- a/src/frontend/components/_atoms/generic-table-inputs/generic-select-cell.tsx +++ b/src/frontend/components/_atoms/generic-table-inputs/generic-select-cell.tsx @@ -36,6 +36,7 @@ export const GenericSelectCell = ({ useEffect(() => { scrollToSelectedOption(selectRef, selectIsOpen) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectIsOpen]) return ( 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 6afc3818f..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,10 +1,9 @@ -import { useCallback, useState } from 'react' +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 { BranchSwitcherModal } from './branch-switcher-modal' -import { CreateBranchModal } from './create-branch-modal' +import { BranchSwitcherPopover } from './branch-switcher-popover' import { DeleteBranchModal } from './delete-branch-modal' import { UnsavedChangesWarningModal } from './unsaved-changes-warning-modal' @@ -15,10 +14,11 @@ type BranchStatusBarProps = { export function BranchStatusBar({ projectId, onBranchSwitch }: BranchStatusBarProps) { const versionControl = useVersionControl() + const navigation = useNavigation() const [activeBranchName, setActiveBranch] = useActiveBranch(projectId) + const branchButtonRef = useRef(null) const [showSwitcher, setShowSwitcher] = useState(false) - const [showCreate, setShowCreate] = useState(false) const [showDelete, setShowDelete] = useState(false) const [showUnsavedWarning, setShowUnsavedWarning] = useState(false) const [branchToDelete, setBranchToDelete] = useState(null) @@ -80,6 +80,20 @@ export function BranchStatusBar({ projectId, onBranchSwitch }: BranchStatusBarPr setShowDelete(true) }, []) + const handleMerge = useCallback( + (branch: Branch) => { + // 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=`. + navigation.navigate('/merge', { + project_id: projectId, + source: branch.name, + target: activeBranchName !== branch.name ? activeBranchName : undefined, + }) + }, + [projectId, activeBranchName, navigation], + ) + const handleDeleted = useCallback(() => { if (!versionControl) return if (branchToDelete?.name === activeBranchName) { @@ -101,6 +115,7 @@ export function BranchStatusBar({ projectId, onBranchSwitch }: BranchStatusBarPr <>
- 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/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]/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]/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..77346c649 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,13 @@ 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 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' @@ -17,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', @@ -101,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[] } @@ -257,11 +258,11 @@ function ChangesTreeItem({ - {STATUS_LABEL[node.status ?? ''] ?? node.status} + {node.status ? STATUS_LABEL[node.status] : ''}
) @@ -278,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('') @@ -297,19 +305,19 @@ 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]) + 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,10 +333,19 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { setIsFetching(true) try { const data = await versionControl.getChanges(projectId) - setFiles(data.changes) - versionControlActions.setPendingChangesCount(data.changes.length) + // 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) @@ -339,28 +356,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 +403,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) => { @@ -427,55 +446,23 @@ 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 = files.length > 0 + const hasChanges = visibleFiles.length > 0 const canCommit = message.trim().length > 0 && selectedFiles.size > 0 const handleCommit = async () => { @@ -485,26 +472,51 @@ 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 validPaths = [...selectedFiles].filter((p) => freshFiles.some((f) => f.path === p)) - if (validPaths.length === 0) { + const validUserPaths = [...selectedFiles].filter((p) => freshVisible.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 = freshSystem.map((f) => f.path) + const pathsToCommit = [...validUserPaths, ...freshSystemPaths] + const totalFresh = freshVisible.length + freshSystem.length + await versionControl.createCommit( projectId, message.trim(), - validPaths.length === freshFiles.length ? undefined : validPaths, + pathsToCommit.length === totalFresh ? 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 +535,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 +591,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`}
) } diff --git a/src/frontend/services/save-actions.ts b/src/frontend/services/save-actions.ts index 5efee21dd..79a041d03 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' @@ -19,63 +23,215 @@ import { parseGraphicalPouFromString, parseTextualPouFromString } from '../utils import { serializePouToText } from '../utils/PLC/pou-text-serializer' import { collectDebugVariables, sanitizePou } from '../utils/save-project' import { toast } from '../utils/toast' +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, '/') +// --------------------------------------------------------------------------- +// Project file iteration — single source of truth for path → content +// --------------------------------------------------------------------------- + +type StoreState = ReturnType + +type ProjectFileCategory = 'pou' | 'server' | 'remote-device' | 'device-config' | 'pin-mapping' | 'project-json' + +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) + return JSON.stringify( + { + meta: { name: project.meta.name, type: 'plc-project' }, + data: { + dataTypes: project.data.dataTypes, + pous: [], + configuration: project.data.configurations, + debugVariables, + }, + }, + null, + 2, + ) +} + +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', + } +} + /** - * 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. + * 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. */ -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 })) - : rung.nodes, - })), - }, - }, +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', } } - if (lang === 'fbd' && body.rung) { - const rung = body.rung as Record - return { - ...pou, - body: { - ...pou.body, - value: { - ...body, - rung: { - ...rung, - nodes: Array.isArray(rung.nodes) - ? (rung.nodes as Array>).map((n) => ({ ...n, selected: false })) - : rung.nodes, - }, - }, - }, + for (const d of project.data.remoteDevices ?? []) { + yield { + path: `devices/remote/${d.name}.json`, + content: JSON.stringify(d, null, 2), + category: 'remote-device', } } - return pou + 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', + } } +/** + * 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 serializeProjectFile( + fileName: string, + file: { type: string | null; filePath: 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) + return pou ? [buildPouSpec(pou, state)] : [] + } + + if (file.type === 'device') { + return [ + { + 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), 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), + 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), + category: 'remote-device', + }, + ] + } + + // data-type, resource: live in project.json + return [{ path: 'project.json', content: buildProjectJsonContent(state), category: 'project-json' }] +} + +// --------------------------------------------------------------------------- +// Public file-content builders +// --------------------------------------------------------------------------- + +/** + * 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. + */ +export function buildAllProjectFileContentsPure(): Record { + const state = openPLCStoreBase.getState() + const result: Record = {} + for (const spec of iterateProjectFiles(state)) { + result[spec.path] = spec.content + } + return result +} + +/** + * 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 result +} + +// --------------------------------------------------------------------------- +// Save flows +// --------------------------------------------------------------------------- + /** * Save the entire project (all files, device config, debug variables). * Equivalent to Ctrl+Shift+S / "Save Project" menu item. @@ -86,11 +242,13 @@ 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 + const deletionsBeforeSave = [...pendingDeletions] + setEditingState('save-request') toast({ title: 'Save changes', @@ -99,51 +257,46 @@ export async function executeSaveProject(projectPort: ProjectPort): Promise<{ su }) try { - // Serialize all POUs using the same functions as single-file save - const pouFiles: RawProjectFile[] = project.data.pous.map((pou) => { - 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 { relativePath: `pous/${folder}/${pou.name}${ext}`, content: serializePouToText(sanitized) } - }) - - // Serialize servers as individual JSON files - const serverFiles: RawProjectFile[] = (project.data.servers ?? []).map((s) => ({ - relativePath: `devices/servers/${s.name}.json`, - content: JSON.stringify(s, null, 2), - })) - - // Serialize remote devices as individual JSON files - const remoteDeviceFiles: RawProjectFile[] = (project.data.remoteDevices ?? []).map((d) => ({ - relativePath: `devices/remote/${d.name}.json`, - content: JSON.stringify(d, null, 2), - })) - - // Build project.json — same structure as single-file save - const debugVariables = collectDebugVariables( - project.data.configurations.resource.globalVariables, - project.data.pous, - ) - const projectJson = JSON.stringify( - { - meta: { name: project.meta.name, type: 'plc-project' }, - data: { - dataTypes: project.data.dataTypes, - pous: [], - configuration: project.data.configurations, - debugVariables, - }, - }, - null, - 2, - ) + // 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, projectJson, - deviceConfig: JSON.stringify(deviceDefinitions.configuration, null, 2), - pinMapping: JSON.stringify(deviceDefinitions.pinMapping.pins, null, 2), + deviceConfig, + pinMapping, pouFiles, serverFiles, remoteDeviceFiles, @@ -152,6 +305,23 @@ export async function executeSaveProject(projectPort: ProjectPort): Promise<{ su const res = await projectPort.saveProject(files) if (res.success) { + // Tell the version-control slice exactly which paths were just sent + + // their content. The slice compares against baseline to add or remove + // paths from `changedPaths` (handles the modify-then-save-then-revert + // case correctly without round-tripping to /changes). + const savedRecords = [ + { path: 'project.json', content: projectJson }, + { path: 'devices/configuration.json', content: deviceConfig }, + { path: 'devices/pin-mapping.json', content: pinMapping }, + ...pouFiles.map((f) => ({ path: f.relativePath, content: f.content })), + ...serverFiles.map((f) => ({ path: f.relativePath, content: f.content })), + ...remoteDeviceFiles.map((f) => ({ path: f.relativePath, content: f.content })), + ] + state.versionControlActions.recordSavedFiles({ + saved: savedRecords, + deleted: deletionsBeforeSave, + }) + state.projectActions.clearPendingDeletions() setEditingState('saved') setAllToSaved() @@ -226,78 +396,70 @@ 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. + if (specs.length > 0) { + state.versionControlActions.recordSavedFiles({ + saved: specs.map((spec) => ({ path: spec.path, content: spec.content })), + deleted: [], + }) + } + // Mark only this file as saved updateFile({ name: fileName, saved: true, isNew: false }) markSaved(fileName) diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index 11375a439..c32dfe5a6 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -337,7 +337,16 @@ const createProjectSlice: StateCreator = (se setState( produce((slice: ProjectSlice) => { const pou = slice.project.data.pous.find((p) => p.name === oldName) - if (pou) pou.name = newName + if (pou) { + // Queue the OLD path for deletion. The next save serializes the + // POU under its new path; without this, the old file lingers in + // S3 (orphan-cleanup catches it) but the version-control badge + // wouldn't see the deletion event and would over-count by 1. + const folder = getFolderFromPouType(pou.pouType) + const ext = getExtensionFromLanguage(pou.body.language) + slice.pendingDeletions.push(`pous/${folder}/${oldName}${ext}`) + pou.name = newName + } }), ) }, @@ -765,7 +774,12 @@ const createProjectSlice: StateCreator = (se setState( produce((slice: ProjectSlice) => { const server = slice.project.data.servers?.find((s) => s.name === name) - if (server) server.name = newName + if (server) { + // See `updatePouName` — queue old path so the version-control + // badge doesn't over-count the rename. + slice.pendingDeletions.push(`devices/servers/${name}.json`) + server.name = newName + } }), ) return ok() @@ -1102,6 +1116,10 @@ const createProjectSlice: StateCreator = (se const device = slice.project.data.remoteDevices?.find((d) => d.name === name) if (!device) return + // See `updatePouName` — queue old path so the version-control + // badge doesn't over-count the rename. + slice.pendingDeletions.push(`devices/remote/${name}.json`) + // Update associated system task name for EtherCAT devices if (device.protocol === 'ethercat') { const systemTask = slice.project.data.configurations.resource.tasks.find( diff --git a/src/frontend/store/slices/version-control/slice.ts b/src/frontend/store/slices/version-control/slice.ts index 2922a4f34..a4d32b819 100644 --- a/src/frontend/store/slices/version-control/slice.ts +++ b/src/frontend/store/slices/version-control/slice.ts @@ -1,17 +1,39 @@ import { produce } from 'immer' import { StateCreator } from 'zustand' -import type { SidePanel, VersionControlSlice } from './types' +import type { InitialPendingEntry, SavedFileRecord, SidePanel, VersionControlSlice } from './types' const initialState: VersionControlSlice['versionControl'] = { activePanel: 'explorer', - activeBranch: null, selectedCommitHash: null, + initialPending: [], + baselineContent: {}, + rawLoadedContent: {}, + loadedSerialized: {}, + changedPaths: [], pendingChangesCount: 0, } +function dedupeByPath(entries: InitialPendingEntry[]): InitialPendingEntry[] { + const seen = new Set() + const out: InitialPendingEntry[] = [] + for (const e of entries) { + if (seen.has(e.path)) continue + seen.add(e.path) + out.push(e) + } + return out +} + +function recomputeCount(draft: VersionControlSlice): void { + const paths = new Set() + for (const entry of draft.versionControl.initialPending) paths.add(entry.path) + for (const path of draft.versionControl.changedPaths) paths.add(path) + draft.versionControl.pendingChangesCount = paths.size +} + const createVersionControlSlice: StateCreator = (setState) => ({ - versionControl: { ...initialState }, + versionControl: { ...initialState, baselineContent: {}, rawLoadedContent: {}, loadedSerialized: {} }, versionControlActions: { setActivePanel: (panel: SidePanel) => @@ -21,31 +43,142 @@ const createVersionControlSlice: StateCreator + setSelectedCommitHash: (hash: string | null) => setState( produce((draft) => { - draft.versionControl.activeBranch = branchName + draft.versionControl.selectedCommitHash = hash }), ), - setSelectedCommitHash: (hash: string | null) => + initBaseline: ({ initialPending, baselineContent, rawLoadedContent, loadedSerialized }) => setState( produce((draft) => { - draft.versionControl.selectedCommitHash = hash + draft.versionControl.initialPending = dedupeByPath(initialPending) + // Prefer raw text as baseline for paths where it's available — this + // way "saving without editing" produces byte-identical content to + // what S3 already has, instead of a re-serialized form that would + // diff against HEAD because of formatting drift. + const merged: Record = { ...baselineContent } + if (rawLoadedContent) { + for (const [path, raw] of Object.entries(rawLoadedContent)) { + merged[path] = raw + } + } + draft.versionControl.baselineContent = merged + draft.versionControl.rawLoadedContent = rawLoadedContent ? { ...rawLoadedContent } : {} + // `loadedSerialized` is what was passed as `baselineContent` BEFORE + // the merge — pure serialize at sync point. Used by the save flow + // for "is state == sync state?" comparison without relying on + // file-slice tracking. Falls back to baselineContent if the caller + // didn't provide it (initBaseline was invoked from a path that + // doesn't compute it separately). + draft.versionControl.loadedSerialized = loadedSerialized ? { ...loadedSerialized } : { ...baselineContent } + draft.versionControl.changedPaths = [] + recomputeCount(draft) + }), + ), + + recordSavedFiles: ({ saved, deleted }: { saved: SavedFileRecord[]; deleted: string[] }) => + setState( + produce((draft) => { + const baseline = draft.versionControl.baselineContent + const initialMap = new Map(draft.versionControl.initialPending.map((e) => [e.path, e.status])) + const changedSet = new Set(draft.versionControl.changedPaths) + + for (const { path, content } of saved) { + // Track S3's current state for this path so future "clean" + // saves echo back what's now in S3 (instead of the original + // load-time raw, which would revert the just-saved edit). + draft.versionControl.rawLoadedContent[path] = content + + // Files in initialPending cannot be cleared by user reverts: + // we only have S3 content as baseline, not HEAD content, so we + // can't tell if the user reverted to HEAD. Leave them sticky. + if (initialMap.has(path)) continue + + if (baseline[path] !== undefined && baseline[path] === content) { + changedSet.delete(path) + } else { + changedSet.add(path) + } + } + + for (const path of deleted) { + const initialStatus = initialMap.get(path) + if (initialStatus !== undefined) { + // Path was already pending at last sync. Status decides the outcome: + // - 'added': file was in S3 but not HEAD. Deleting it brings S3 + // back to "no file there", same as HEAD → not pending. + // - 'modified': file was in S3 with different content from HEAD. + // Deleting still leaves S3 ≠ HEAD (now as a deletion). + // Stays pending. + // - 'deleted': weird case (already gone from S3). Stays pending. + if (initialStatus === 'added') { + initialMap.delete(path) + changedSet.delete(path) + } + // 'modified' / 'deleted' → no-op: keep in initialPending. + } else { + // Not in initialPending. Whether this deletion is a new pending + // change depends on whether the path was in the baseline: + // - baseline has it → file was in HEAD → deletion is pending. + // - baseline doesn't → file was added in this session, never + // committed, now being deleted → cancels out, not pending. + if (baseline[path] !== undefined) { + changedSet.add(path) + } else { + changedSet.delete(path) + } + } + // The baseline entry and raw mirror are no longer relevant — + // drop both so a future re-creation of the same path starts + // from a clean slate. + delete draft.versionControl.baselineContent[path] + delete draft.versionControl.rawLoadedContent[path] + } + + draft.versionControl.initialPending = Array.from(initialMap, ([path, status]) => ({ path, status })) + draft.versionControl.changedPaths = Array.from(changedSet) + recomputeCount(draft) + }), + ), + + syncFromChanges: (pendingChanges: InitialPendingEntry[]) => + setState( + produce((draft) => { + draft.versionControl.initialPending = dedupeByPath(pendingChanges) + draft.versionControl.changedPaths = [] + recomputeCount(draft) }), ), - setPendingChangesCount: (count: number) => + commitBaseline: ({ newBaseline, loadedSerialized }) => setState( produce((draft) => { - draft.versionControl.pendingChangesCount = count + // After commit, S3 == HEAD == newBaseline. Baseline and the raw + // mirror reflect actual S3 content (mixed: raw for files that + // stayed clean across commit, serialized for files that were + // edited and uploaded). `loadedSerialized` is the pure serialize + // of current state — needed so the save flow can later detect + // "state hasn't changed since this commit" via byte-equality. + draft.versionControl.baselineContent = { ...newBaseline } + draft.versionControl.rawLoadedContent = { ...newBaseline } + draft.versionControl.loadedSerialized = { ...loadedSerialized } + draft.versionControl.initialPending = [] + draft.versionControl.changedPaths = [] + draft.versionControl.pendingChangesCount = 0 }), ), clearVersionControlState: () => setState( produce((draft) => { - draft.versionControl = { ...initialState } + draft.versionControl = { + ...initialState, + baselineContent: {}, + rawLoadedContent: {}, + loadedSerialized: {}, + } }), ), }, diff --git a/src/frontend/store/slices/version-control/types.ts b/src/frontend/store/slices/version-control/types.ts index 4f7a5f538..3e3255d03 100644 --- a/src/frontend/store/slices/version-control/types.ts +++ b/src/frontend/store/slices/version-control/types.ts @@ -1,19 +1,101 @@ export type SidePanel = 'explorer' | 'source-control' +export type PendingChangeStatus = 'added' | 'modified' | 'deleted' + +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 + * status the backend reported ('added' | 'modified' | 'deleted'). Status + * is needed so a delete event can correctly clear an "added" entry but + * keep a "modified" entry (the deletion is still pending vs HEAD, just + * with a different status). + * + * Sticky — only commit/restore/discard or a fresh /changes response + * fully replaces this list; targeted updates happen via recordSavedFiles + * when a deletion takes a path out of initialPending entirely. + */ + initialPending: InitialPendingEntry[] + /** + * Per-path serialized snapshot taken at the last sync point. Used by + * `recordSavedFiles` to detect "user reverted to baseline" and remove + * the path from `changedPaths`, plus to distinguish "delete of a + * session-added file" from "delete of a HEAD-tracked file". + * Refreshed on commit/restore/discard. + */ + baselineContent: Record + /** + * Raw file text as returned by the backend at load time (or the content + * uploaded at the last sync point — load/commit). Used by the save flow + * to echo back byte-identical content for unedited files, preventing + * parse-serialize formatting drift from showing every POU as "modified" + * vs HEAD on the first save after open. + */ + rawLoadedContent: Record + /** + * Pure-serialized snapshot of project state at the last sync point + * (load or commit). Lets the save flow detect "state hasn't changed + * since sync" without depending on file-slice tracking — necessary for + * special files like `project.json` and `devices/*.json` that don't + * have file-slice entries but still benefit from the raw fallback. + */ + loadedSerialized: Record + /** + * Paths whose latest save produced content that differs from baseline. + * Toggled by save events: added when content differs, removed when it + * matches baseline (the modify-then-save-then-revert-then-save case). + */ + changedPaths: string[] + /** Derived: |unique(initialPending paths ∪ changedPaths)|. */ pendingChangesCount: number } } +export type SavedFileRecord = { path: string; content: string } + export type VersionControlActions = { setActivePanel: (panel: SidePanel) => void - setActiveBranch: (branchName: string | null) => void setSelectedCommitHash: (hash: string | null) => void - setPendingChangesCount: (count: number) => void + /** + * Snapshot baseline + initial pending at the last "in-sync" point + * (project load, after restore, after discard). + * + * `baselineContent` is what the upload should produce when state matches + * the sync point (used for badge tracking). + * `rawLoadedContent` is the actual S3/HEAD content per path (used by the + * save flow to upload byte-identical content for unchanged files). + * `loadedSerialized` is `serialize(state)` at the sync point — used to + * detect "state == sync state?" via comparison at save time. + */ + initBaseline: (args: { + initialPending: InitialPendingEntry[] + baselineContent: Record + rawLoadedContent?: Record + loadedSerialized?: Record + }) => void + /** + * Update `changedPaths` based on what the save just sent. Files matching + * baseline are removed; files differing are added; deletions are folded + * back into initialPending or changedPaths depending on their original + * status (see slice implementation for the case-by-case logic). + */ + recordSavedFiles: (args: { saved: SavedFileRecord[]; deleted: string[] }) => void + /** + * Reset initialPending to the authoritative answer from /changes. Used + * when the source-control panel re-fetches and after a commit. + */ + syncFromChanges: (pendingChanges: InitialPendingEntry[]) => void + /** + * After a successful commit: refresh baseline to what was actually written + * to S3 (mixed of raw + serialized) and `loadedSerialized` to the pure + * serialize of current state (used by save flow for state-equality + * detection). Clear initialPending and changedPaths. + */ + commitBaseline: (args: { newBaseline: Record; loadedSerialized: Record }) => void clearVersionControlState: () => void } diff --git a/src/frontend/utils/sanitize-branch-name.ts b/src/frontend/utils/sanitize-branch-name.ts new file mode 100644 index 000000000..1f735c59c --- /dev/null +++ b/src/frontend/utils/sanitize-branch-name.ts @@ -0,0 +1,166 @@ +/** + * Sanitize a user-typed branch name into a value that satisfies our git + * branch validation rules (mirrored from the backend's `validateBranchName`). + * Mirrors the spirit of VS Code's behavior: rather than rejecting a typo, + * show the user what their input *will become* so they can adjust live. + * + * Rules applied (in order): + * 1. Strip combining diacritics (`café` → `cafe`). + * 2. Strip remaining non-ASCII (kanji, emojis, etc.) — git allows them in + * principle but they break too many downstream tools, and the backend + * now rejects them. + * 3. Replace whitespace and the forbidden chars (`~^:?*[]\`) with `-`. + * 4. Collapse `..` and `@{` sequences. + * 5. Collapse runs of `-` into a single `-`. + * 6. Strip leading/trailing `.`, `/`, `-`. + * 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. + */ + +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.', +} + +// 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). + let result = withoutDiacritics.replace(/[^\x20-\x7E]/g, '') + if (result !== withoutDiacritics) transforms.add('non-ascii-stripped') + + // Whitespace and forbidden-anywhere characters → '-'. + // 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 + .split('') + .filter((c) => { + const code = c.charCodeAt(0) + return code >= 0x20 && code !== 0x7f + }) + .join('') + + // Collapse runs of '-' to a single '-'. + 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) + transforms.add('truncated') + } + + return { result, transforms } +} + +export function sanitizeBranchName(input: string): string { + return sanitizeBranchNameDetailed(input).result +} + +export type BranchNameFeedback = { + sanitized: string + /** True when the user-typed input had to be transformed by the sanitizer. */ + changed: boolean + /** + * User-facing notes about what was rewritten. Each note is a short, neutral + * sentence — combine in a list under the input. + */ + notes: string[] + /** + * Hard error preventing submission (independent of `changed`): + * empty after sanitization, or stripped to nothing. + */ + error: string | null +} + +/** + * Inspect the user input and return both the sanitized form and the list of + * "changes" (e.g. "Spaces will be replaced with '-'"). The CreateBranchPopover + * uses this to render a live preview without reaching the backend. + */ +export function getBranchNameFeedback(input: string): BranchNameFeedback { + const { result: sanitized, transforms } = sanitizeBranchNameDetailed(input) + const notes: string[] = [] + for (const kind of NOTE_ORDER) { + if (transforms.has(kind)) notes.push(TRANSFORM_NOTES[kind]) + } + + let error: string | null = null + if (input.trim().length > 0 && sanitized.length === 0) { + error = 'No valid characters left after sanitization.' + } + + 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 diff --git a/src/frontend/utils/system-files.ts b/src/frontend/utils/system-files.ts new file mode 100644 index 000000000..77123b5ad --- /dev/null +++ b/src/frontend/utils/system-files.ts @@ -0,0 +1,13 @@ +/** + * Files generated/managed by infrastructure that the end user shouldn't see + * in the source-control UI (pending changes, commit details). Currently: + * + * - git-data.tar.gz: legacy backup archive from before the flat-S3 migration. + * When an old project is opened, the migration removes it from S3, which + * surfaces as a "deleted" file in git status. Users wouldn't recognize it. + */ +const SYSTEM_FILE_PATTERNS: readonly RegExp[] = [/(^|\/)git-data\.tar\.gz$/] + +export function isSystemFile(path: string): boolean { + return SYSTEM_FILE_PATTERNS.some((re) => re.test(path)) +} diff --git a/src/frontend/utils/version-control-content.ts b/src/frontend/utils/version-control-content.ts new file mode 100644 index 000000000..a604a1998 --- /dev/null +++ b/src/frontend/utils/version-control-content.ts @@ -0,0 +1,28 @@ +/** + * Minimal shape of the version-control state slice that `pickContentForSave` + * needs. Defined here so the helper stays in `utils` (which can't import + * from `store` per architecture rules) — callers pass `state.versionControl`. + */ +export type VersionControlSyncState = { + loadedSerialized: Record + rawLoadedContent: Record +} + +/** + * Decide what content to upload for a given path. If the freshly serialized + * value matches the snapshot from the last sync point, the user hasn't + * effectively touched this file — echo back the raw text so S3 stays + * byte-identical to HEAD (no parse-serialize drift). Otherwise upload the + * fresh serialization. + * + * Works for any path, including files without file-slice tracking + * (`project.json`, `devices/configuration.json`, `devices/pin-mapping.json`). + */ +export function pickContentForSave(path: string, freshSerialized: string, syncState: VersionControlSyncState): string { + const loadedSer = syncState.loadedSerialized[path] + const raw = syncState.rawLoadedContent[path] + if (loadedSer !== undefined && freshSerialized === loadedSer && raw !== undefined) { + return raw + } + return freshSerialized +} 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/ports/project-port.ts b/src/middleware/shared/ports/project-port.ts index 78d683e95..96998ac55 100644 --- a/src/middleware/shared/ports/project-port.ts +++ b/src/middleware/shared/ports/project-port.ts @@ -48,6 +48,13 @@ export interface ProjectResponse { devicePinMapping?: DevicePin[] /** Warnings from parsing (e.g. dropped files that failed validation). */ warnings?: string[] + /** + * Raw file contents as returned by the backend (path → text), captured + * before parsing. Used by the save flow to upload byte-identical content + * for files the user didn't edit, avoiding phantom "modified" diffs that + * arise from parse-serialize formatting drift. + */ + rawLoadedFiles?: Record } error?: { title: string 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 } 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