diff --git a/src/frontend/components/_features/[workspace]/branches/branch-switcher-popover.tsx b/src/frontend/components/_features/[workspace]/branches/branch-switcher-popover.tsx index 2f323691f..c68bd0e28 100644 --- a/src/frontend/components/_features/[workspace]/branches/branch-switcher-popover.tsx +++ b/src/frontend/components/_features/[workspace]/branches/branch-switcher-popover.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { Branch } from '../../../../../middleware/shared/ports/version-control-port' import { useVersionControl } from '../../../../../middleware/shared/providers' +import { useOpenPLCStore } from '../../../../store' import { cn } from '../../../../utils/cn' import { CreateBranchPopover } from './create-branch-popover' @@ -28,6 +29,10 @@ export function BranchSwitcherPopover({ onMerge, }: BranchSwitcherPopoverProps) { const versionControl = useVersionControl() + // Read-only projects (no edit permission) can still switch/browse branches, + // but the write actions (merge, delete, create branch) are removed entirely + // — there's nothing to surface for a user who can't persist them. + const isReadOnly = useOpenPLCStore((s) => s.workspace.isReadOnly) const [branches, setBranches] = useState([]) const [isLoading, setIsLoading] = useState(false) const [filter, setFilter] = useState('') @@ -185,96 +190,101 @@ export function BranchSwitcherPopover({ 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', - )} + {/* Actions menu (3 dots) — reveals merge + delete on hover. + Hidden in read-only: a non-owner can't merge or delete. */} + {!isReadOnly && ( +
+ + + + + + 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' > - - - - Delete - - - - -
+ { + 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 */} -
-
- -
+ {/* Create new branch — hidden in read-only (a non-owner can't create). */} + {!isReadOnly && ( +
+
+ +
+ )}
) diff --git a/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx b/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx index 6ec0ae8e7..5950eef62 100644 --- a/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx +++ b/src/frontend/components/_features/[workspace]/create-element/element-card/index.tsx @@ -128,20 +128,16 @@ const ElementCard = (props: ElementCardProps): ReactNode => { serverActions: { create: createServer }, remoteDeviceActions: { create: createRemoteDevice }, deviceAvailableOptions: { availableBoards }, - modalActions: { openModal }, } = useOpenPLCStore() - const isReadOnly = useOpenPLCStore((state) => state.workspace.isReadOnly) const deviceBoard = useOpenPLCStore((state) => state.deviceDefinitions.configuration.deviceBoard) const [isOpen, setIsOpen] = useState(false) - // Read-only ⇒ the create-element popover/menu just routes to the - // fork-or-cancel modal so the user knows why the affordance exists - // but can't make changes that wouldn't persist. + // Read-only projects (no edit permission) can still create POUs / data types + // / servers / devices in memory — the create actions only mutate the store, + // they don't persist. Nothing reaches the backend until an explicit Save + // (Ctrl+S), which routes through the fork modal. So the popover opens + // normally here. const handleOpen = (next: boolean) => { - if (next && isReadOnly) { - openModal('read-only-project') - return - } setIsOpen(next) } @@ -231,10 +227,6 @@ const ElementCard = (props: ElementCardProps): ReactNode => { } const handleMouseEnter = () => { - if (isReadOnly) { - openModal('read-only-project') - return - } setIsOpen(true) } diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/index.tsx index 9e42c9a9e..ee3f88a48 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/index.tsx @@ -20,7 +20,7 @@ type GraphicalEditorProps = ComponentPropsWithoutRef<'div'> & { isActive?: boolean } -const GraphicalEditor = ({ name, language, readOnly, isActive = true }: GraphicalEditorProps) => { +const GraphicalEditor = ({ name, language, isActive = true }: GraphicalEditorProps) => { const editorComponents = { sfc: SfcEditor, fbd: FbdEditor, @@ -29,11 +29,15 @@ const GraphicalEditor = ({ name, language, readOnly, isActive = true }: Graphica const EditorComponent = editorComponents[language] + // Read-only projects (no edit permission) stay fully interactive: the user + // can add/remove blocks and rewire the diagram in memory. Nothing persists + // until an explicit Save (Ctrl+S), which routes through the fork modal. The + // `readOnly` prop is intentionally ignored here — we no longer overlay the + // editor to block interaction. return (
- {readOnly &&
} -
+
diff --git a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx index 15cfda836..50f537fe9 100644 --- a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx @@ -143,7 +143,6 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType disposable.dispose() }, [editorMounted]) - // Update readOnly when debugger visibility or project read-only flag changes. - // Debugger visibility forces read-only for safety; the project's own - // read-only flag (no edit permission) does the same so users browsing - // someone else's project can't make local modifications they couldn't save. + // Update readOnly when debugger visibility changes. Debugger visibility + // forces read-only for safety. A project's own read-only flag (no edit + // permission) does NOT lock the editor: users browsing someone else's + // project can edit text freely in memory; the explicit Save (Ctrl+S) + // routes through the fork modal instead of persisting. useEffect(() => { - editorRef.current?.updateOptions({ readOnly: isDebuggerVisible || isReadOnly }) - }, [isDebuggerVisible, isReadOnly]) + editorRef.current?.updateOptions({ readOnly: isDebuggerVisible }) + }, [isDebuggerVisible]) // Apply programmatic cursor jumps (e.g. clicking a compile error in // the console) to an already-mounted editor. The onMount path @@ -1240,7 +1240,7 @@ void loop() const monacoEditorUserOptions: monacoEditorOptionsType = { minimap: { enabled: false }, dropIntoEditor: { enabled: true }, - readOnly: isDebuggerVisible || isReadOnly, + readOnly: isDebuggerVisible, // Lock indentation to 4 spaces across every language Monaco // hosts (ST / IL / Python / C++). Without this Monaco's // `detectIndentation` heuristic kicks in on the existing model 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 edb1cc207..f4f0979c4 100644 --- a/src/frontend/components/_features/[workspace]/source-control/commit-details.tsx +++ b/src/frontend/components/_features/[workspace]/source-control/commit-details.tsx @@ -20,6 +20,8 @@ export function CommitDetails({ commit, projectId }: CommitDetailsProps) { project: { meta: { path: storedProjectId }, }, + workspace: { isReadOnly }, + modalActions: { openModal }, sharedWorkspaceActions, } = useOpenPLCStore() const projectPort = useProject() @@ -171,7 +173,11 @@ export function CommitDetails({ commit, projectId }: CommitDetailsProps) { View All Files
- - e.stopPropagation()} - > - - - - 0 && ( + + e.stopPropagation()} > - {popoverOptions.map((option, index) => ( -
{ - option.onClick() - setPopoverOpen(false) - }} - > - {option.icon} -

{option.name}

-
- ))} -
-
-
+ + + + e.stopPropagation()} + > + {popoverOptions.map((option, index) => ( +
{ + option.onClick() + setPopoverOpen(false) + }} + > + {option.icon} +

{option.name}

+
+ ))} +
+
+ + )}
{children && isExpanded && } @@ -517,7 +515,6 @@ const ProjectTreeLeaf = ({ }, workspace: { selectedProjectTreeLeaf, isDebuggerVisible, isReadOnly }, workspaceActions: { setSelectedProjectTreeLeaf }, - modalActions: { openModal }, pouActions: { deleteRequest: deletePouRequest, rename: renamePou, duplicate: duplicatePou }, datatypeActions: { deleteRequest: deleteDatatypeRequest, rename: renameDatatype, duplicate: duplicateDatatype }, serverActions: { deleteRequest: deleteServerRequest, rename: renameServer }, @@ -742,35 +739,29 @@ const ProjectTreeLeaf = ({ const handleLabel = useCallback((label: string | undefined) => unsavedLabel(label, associatedFile), [associatedFile]) const popoverOptions = useMemo(() => { - // Read-only ⇒ every write action funnels into the fork modal. We - // still surface the menu items so the affordance is discoverable - // (the user can read what's there), but each click routes through - // the modal instead of the underlying handler. - const guard = (real: () => void) => () => { - if (isReadOnly) { - openModal('read-only-project') - return - } - real() - } + // Read-only ⇒ no file-level actions. Rename, Duplicate and Delete all + // mutate the repo (rename and duplicate auto-persist, delete removes the + // file), so the whole context menu is removed rather than funnelled to + // the fork modal. In-place editing of the file's contents stays allowed. + if (isReadOnly) return [] return [ { name: 'Rename', - onClick: guard(() => setIsEditing(true)), + onClick: () => setIsEditing(true), icon: , }, { name: 'Duplicate', - onClick: guard(() => void handleDuplicateFile()), + onClick: () => void handleDuplicateFile(), icon: , }, { name: 'Delete', - onClick: guard(() => handleDeleteFile()), + onClick: () => handleDeleteFile(), icon: , }, ] - }, [handleDeleteFile, handleDuplicateFile, setIsEditing, isReadOnly, openModal]) + }, [handleDeleteFile, handleDuplicateFile, setIsEditing, isReadOnly]) useEffect(() => { if (isEditing && inputNameRef.current) { @@ -819,7 +810,7 @@ const ProjectTreeLeaf = ({ )} - {leafLang === 'devPin' || leafLang === 'devConfig' ? null : ( + {leafLang === 'devPin' || leafLang === 'devConfig' || popoverOptions.length === 0 ? null : ( { onOpenChange('read-only-project', open) }} > - + {/* + Base ModalContent centers via `inset-0 m-auto h-[500px]`. Overriding only the + height to `h-auto` leaves `inset-0` in place — with both top:0 and bottom:0 the + box is over-constrained and CSS stretches it to fill `max-h`, hence the giant + empty modal. Switch this modal to translate-centering (`inset-auto` + left/top + 1/2 + -translate-1/2) so the height hugs the content instead. + */} + {step === 'intro' ? ( <> This project is read-only