From 4aba3ead90161dacced48045434b1da67c1f41bd Mon Sep 17 00:00:00 2001 From: JulioSergioFS Date: Fri, 29 May 2026 11:25:13 -0300 Subject: [PATCH] feat: read-only project mode (synced from openplc-web) --- .../branches/create-branch-modal.tsx | 13 + .../branches/create-branch-popover.tsx | 9 + .../branches/delete-branch-modal.tsx | 12 + .../create-element/element-card/index.tsx | 19 +- .../[workspace]/editor/graphical/index.tsx | 4 +- .../[workspace]/editor/monaco/index.tsx | 12 +- .../source-control/changes-section.tsx | 18 +- .../_molecules/menu-bar/menus/file.tsx | 15 +- .../_molecules/project-tree/index.tsx | 66 +++-- .../modals/read-only-project-modal.tsx | 237 ++++++++++++++++++ .../workspace-activity-bar/default.tsx | 10 +- .../components/_templates/app-layout.tsx | 2 + src/frontend/screens/workspace-screen.tsx | 2 + src/frontend/services/save-actions.ts | 13 + .../store/__tests__/shared-slice.test.ts | 24 ++ .../store/__tests__/workspace-slice.test.ts | 9 + src/frontend/store/slices/modal/slice.ts | 1 + src/frontend/store/slices/modal/types.ts | 5 + src/frontend/store/slices/shared/slice.ts | 5 + src/frontend/store/slices/shared/types.ts | 7 + src/frontend/store/slices/workspace/slice.ts | 10 + src/frontend/store/slices/workspace/types.ts | 12 + src/middleware/shared/ports/project-port.ts | 65 +++++ 23 files changed, 531 insertions(+), 39 deletions(-) create mode 100644 src/frontend/components/_organisms/modals/read-only-project-modal.tsx diff --git a/src/frontend/components/_features/[workspace]/branches/create-branch-modal.tsx b/src/frontend/components/_features/[workspace]/branches/create-branch-modal.tsx index 9df0f895f..45fc13cf2 100644 --- a/src/frontend/components/_features/[workspace]/branches/create-branch-modal.tsx +++ b/src/frontend/components/_features/[workspace]/branches/create-branch-modal.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { useVersionControl } from '../../../../../middleware/shared/providers' +import { useOpenPLCStore } from '../../../../store' type CreateBranchModalProps = { isOpen: boolean @@ -11,6 +12,8 @@ type CreateBranchModalProps = { export function CreateBranchModal({ isOpen, projectId, onClose, onCreated }: CreateBranchModalProps) { const versionControl = useVersionControl() + const isReadOnly = useOpenPLCStore((s) => s.workspace.isReadOnly) + const openReadOnlyModal = useOpenPLCStore((s) => s.modalActions.openModal) const [name, setName] = useState('') const [error, setError] = useState('') const [isPending, setIsPending] = useState(false) @@ -31,6 +34,16 @@ export function CreateBranchModal({ isOpen, projectId, onClose, onCreated }: Cre } }, [isOpen]) + // Read-only ⇒ close this modal and surface the read-only/fork modal + // instead. A useEffect (not an early-return rewrite) avoids breaking + // hook ordering for the rest of the component when isOpen flips. + useEffect(() => { + if (isOpen && isReadOnly) { + onClose() + openReadOnlyModal('read-only-project') + } + }, [isOpen, isReadOnly, onClose, openReadOnlyModal]) + if (!isOpen) return null const handleSubmit = (e: React.FormEvent) => { diff --git a/src/frontend/components/_features/[workspace]/branches/create-branch-popover.tsx b/src/frontend/components/_features/[workspace]/branches/create-branch-popover.tsx index fc7d17bb1..3aa2c01a2 100644 --- a/src/frontend/components/_features/[workspace]/branches/create-branch-popover.tsx +++ b/src/frontend/components/_features/[workspace]/branches/create-branch-popover.tsx @@ -2,6 +2,7 @@ import * as Popover from '@radix-ui/react-popover' import { useMemo, useState } from 'react' import { useVersionControl } from '../../../../../middleware/shared/providers' +import { useOpenPLCStore } from '../../../../store' import { cn } from '../../../../utils/cn' import { getBranchNameFeedback } from '../../../../utils/sanitize-branch-name' @@ -13,6 +14,8 @@ type CreateBranchPopoverProps = { export function CreateBranchPopover({ projectId, onCreated, onCloseParent }: CreateBranchPopoverProps) { const versionControl = useVersionControl() + const isReadOnly = useOpenPLCStore((s) => s.workspace.isReadOnly) + const openModal = useOpenPLCStore((s) => s.modalActions.openModal) const [isOpen, setIsOpen] = useState(false) const [name, setName] = useState('') const [error, setError] = useState('') @@ -61,6 +64,12 @@ export function CreateBranchPopover({ projectId, onCreated, onCloseParent }: Cre { + // Read-only ⇒ redirect to the fork-or-cancel modal instead of + // opening a branch form the backend would 403 anyway. + if (open && isReadOnly) { + openModal('read-only-project') + return + } setIsOpen(open) if (!open) { setName('') diff --git a/src/frontend/components/_features/[workspace]/branches/delete-branch-modal.tsx b/src/frontend/components/_features/[workspace]/branches/delete-branch-modal.tsx index 4b6b91e3c..3a8f51b6e 100644 --- a/src/frontend/components/_features/[workspace]/branches/delete-branch-modal.tsx +++ b/src/frontend/components/_features/[workspace]/branches/delete-branch-modal.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import type { Branch } from '../../../../../middleware/shared/ports/version-control-port' import { useVersionControl } from '../../../../../middleware/shared/providers' +import { useOpenPLCStore } from '../../../../store' import { toast } from '../../../../utils/toast' type DeleteBranchModalProps = { @@ -14,6 +15,8 @@ type DeleteBranchModalProps = { export function DeleteBranchModal({ isOpen, projectId, branch, onClose, onDeleted }: DeleteBranchModalProps) { const versionControl = useVersionControl() + const isReadOnly = useOpenPLCStore((s) => s.workspace.isReadOnly) + const openReadOnlyModal = useOpenPLCStore((s) => s.modalActions.openModal) const [isPending, setIsPending] = useState(false) useEffect(() => { @@ -25,6 +28,15 @@ export function DeleteBranchModal({ isOpen, projectId, branch, onClose, onDelete return () => document.removeEventListener('keydown', handleKeyDown) }, [isOpen, isPending, onClose]) + // Same pattern as CreateBranchModal — if a read-only project is open, + // close this dialog and route the user to the fork affordance. + useEffect(() => { + if (isOpen && isReadOnly) { + onClose() + openReadOnlyModal('read-only-project') + } + }, [isOpen, isReadOnly, onClose, openReadOnlyModal]) + if (!isOpen || !branch) return null const handleDelete = () => { 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 b09eff4d9..6ec0ae8e7 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,10 +128,23 @@ 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. + const handleOpen = (next: boolean) => { + if (next && isReadOnly) { + openModal('read-only-project') + return + } + setIsOpen(next) + } + const currentBoardInfo = availableBoards.get(deviceBoard) const isArduinoTarget = checkIsArduinoTarget(currentBoardInfo) const isSimulator = isSimulatorTarget(currentBoardInfo) @@ -218,11 +231,15 @@ const ElementCard = (props: ElementCardProps): ReactNode => { } const handleMouseEnter = () => { + if (isReadOnly) { + openModal('read-only-project') + return + } setIsOpen(true) } return ( - +
- {readOnly && ( -
- )} + {readOnly &&
}
diff --git a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx index c8b2bc264..f9cc782af 100644 --- a/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/monaco/index.tsx @@ -143,6 +143,7 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType disposable.dispose() }, [editorMounted]) - // Update readOnly when debugger visibility changes (editor-only) + // 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. useEffect(() => { - editorRef.current?.updateOptions({ readOnly: isDebuggerVisible }) - }, [isDebuggerVisible]) + editorRef.current?.updateOptions({ readOnly: isDebuggerVisible || isReadOnly }) + }, [isDebuggerVisible, isReadOnly]) // Apply programmatic cursor jumps (e.g. clicking a compile error in // the console) to an already-mounted editor. The onMount path @@ -1273,7 +1277,7 @@ void loop() const monacoEditorUserOptions: monacoEditorOptionsType = { minimap: { enabled: false }, dropIntoEditor: { enabled: true }, - readOnly: isDebuggerVisible, + readOnly: isDebuggerVisible || isReadOnly, // 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/changes-section.tsx b/src/frontend/components/_features/[workspace]/source-control/changes-section.tsx index 77346c649..3727f9c46 100644 --- a/src/frontend/components/_features/[workspace]/source-control/changes-section.tsx +++ b/src/frontend/components/_features/[workspace]/source-control/changes-section.tsx @@ -282,6 +282,8 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { tabsActions: { updateTabs }, editorActions: { setEditor, addModel, getEditorFromEditors }, } = useOpenPLCStore() + const isReadOnly = useOpenPLCStore((s) => s.workspace.isReadOnly) + const openReadOnlyModal = useOpenPLCStore((s) => s.modalActions.openModal) const pous = project.data.pous @@ -467,6 +469,12 @@ export function ChangesSection({ projectId }: ChangesSectionProps) { const handleCommit = async () => { if (!canCommit || !versionControl) return + // No edit permission ⇒ open the fork-or-cancel affordance instead + // of letting the backend 403 the commit silently. + if (isReadOnly) { + openReadOnlyModal('read-only-project') + return + } setIsCommitting(true) setErrorMessage(null) @@ -678,15 +686,17 @@ export function ChangesSection({ projectId }: ChangesSectionProps) {
+ +
+ + ) : ( + <> + Fork project +

+ Pick a folder in your workspace where the fork should live. +

+ +
+ {foldersLoading &&
Loading folders…
} + {foldersError &&
{foldersError}
} + {!foldersLoading && !foldersError && flatFolderRows.length === 0 && ( +
No folders available.
+ )} + {flatFolderRows.map((row) => { + const selected = selectedFolderId === row.id + return ( + + ) + })} +
+ + + + {forkError &&

{forkError}

} + +
+ + +
+ + )} + + + ) +} + +/** + * Depth-first flatten of the folder hierarchy so the picker can render + * a single vertical list with indentation per nesting level — simpler + * than a true tree component and visually equivalent for small org + * hierarchies (which is what the editor's caller surface produces). + */ +function flattenFolders( + folders: ProjectFolder[], + depth: number, +): Array<{ id: string; name: string; type: string; depth: number }> { + const rows: Array<{ id: string; name: string; type: string; depth: number }> = [] + for (const folder of folders) { + rows.push({ id: folder.id, name: folder.name, type: folder.type, depth }) + if (folder.children && folder.children.length > 0) { + rows.push(...flattenFolders(folder.children, depth + 1)) + } + } + return rows +} + +export { ReadOnlyProjectModal } diff --git a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx index 66b35e511..68a16896a 100644 --- a/src/frontend/components/_organisms/workspace-activity-bar/default.tsx +++ b/src/frontend/components/_organisms/workspace-activity-bar/default.tsx @@ -88,6 +88,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa const jwtToken = useOpenPLCStore((state) => state.runtimeConnection.jwtToken) const editingState = useOpenPLCStore((state) => state.workspace.editingState) const isDebuggerVisible = useOpenPLCStore((state) => state.workspace.isDebuggerVisible) + const isReadOnly = useOpenPLCStore((state) => state.workspace.isReadOnly) const currentBoardInfo = availableBoards.get(deviceDefinitions.configuration.deviceBoard) const isSimulatorBoard = resolveTargetCapabilities(currentBoardInfo).isInProcessSimulator @@ -138,7 +139,13 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa async (overrides?: { compileOnly?: boolean; cleanBuild?: boolean }) => { if (isCompiling) return - if (editingState === 'unsaved') { + // Read-only projects can't save — but Run/Build must still work since + // we want the viewer to be able to compile and run the project on + // their own device. The Monaco/graphical gates prevent the user from + // actually modifying anything, so `editingState` should stay 'saved' + // here; the explicit isReadOnly check is belt-and-suspenders so a + // stray dirty flag doesn't trip the save and 403 the build. + if (editingState === 'unsaved' && !isReadOnly) { const saved = await executeSave() if (!saved) return } @@ -222,6 +229,7 @@ export const DefaultWorkspaceActivityBar = ({ zoom }: DefaultWorkspaceActivityBa isCompiling, editingState, executeSave, + isReadOnly, jwtToken, ], ) diff --git a/src/frontend/components/_templates/app-layout.tsx b/src/frontend/components/_templates/app-layout.tsx index 110cd4fac..ce12075cf 100644 --- a/src/frontend/components/_templates/app-layout.tsx +++ b/src/frontend/components/_templates/app-layout.tsx @@ -14,6 +14,7 @@ import { DebuggerMessageModal } from '../_organisms/modals/debugger-message-moda import { ConfirmDeleteElementModal } from '../_organisms/modals/delete-confirmation-modal' import { MissingLibrariesModal } from '../_organisms/modals/missing-libraries-modal' import { QuitApplicationModal } from '../_organisms/modals/quit-application-modal' +import { ReadOnlyProjectModal } from '../_organisms/modals/read-only-project-modal' import { RuntimeConnectionLostModal } from '../_organisms/modals/runtime-connection-lost-modal' import type { SaveChangesFileModalData } from '../_organisms/modals/save-changes-file-modal' import { SaveChangesFileModal } from '../_organisms/modals/save-changes-file-modal' @@ -119,6 +120,7 @@ const AppLayout = ({ children, ...rest }: AppLayoutProps): ReactNode => { {modals?.['runtime-connection-lost']?.open === true && } {modals?.['debugger-message']?.open === true && } {modals?.['missing-libraries']?.open === true && } + {modals?.['read-only-project']?.open === true && } {modals?.['runtime-login']?.open === true && } {modals?.['runtime-create-user']?.open === true && } {modals?.['runtime-discover-devices']?.open === true && } diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index 3190839ee..df51a9f78 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -82,6 +82,7 @@ const WorkspaceScreen = () => { const pous = useOpenPLCStore(useCallback((s) => s.project.data.pous, [])) const projectPath = useOpenPLCStore(useCallback((s) => s.project.meta.path, [])) const projectType = useOpenPLCStore(useCallback((s) => s.project.meta.type, [])) + const isReadOnly = useOpenPLCStore(useCallback((s) => s.workspace.isReadOnly, [])) // Project-type capability matrix. Combines with `capabilities` // (host platform features) below to decide what affordances render // in this workspace shell. @@ -650,6 +651,7 @@ const WorkspaceScreen = () => { name={model.meta.name} language={model.meta.language} isActive={isActive} + readOnly={isReadOnly} /> )}
diff --git a/src/frontend/services/save-actions.ts b/src/frontend/services/save-actions.ts index 2a907bbc5..c5003ca6d 100644 --- a/src/frontend/services/save-actions.ts +++ b/src/frontend/services/save-actions.ts @@ -313,6 +313,14 @@ export async function executeSaveProject( capabilities: PlatformCapabilities, ): Promise<{ success: boolean }> { const state = openPLCStoreBase.getState() + // Read-only gate. The Monaco / graphical editors are already in + // read-only mode, but explicit Save shortcuts (Ctrl+S, menu File → + // Save) still funnel through here. Surface the modal so the user + // gets the "fork me" affordance instead of a silent no-op. + if (state.workspace.isReadOnly) { + state.modalActions.openModal('read-only-project') + return { success: false } + } const { project, pendingDeletions } = state const { setEditingState } = state.workspaceActions const { setAllToSaved } = state.fileActions @@ -473,6 +481,11 @@ export async function executeSaveFile( capabilities: PlatformCapabilities, ): Promise<{ success: boolean }> { const state = openPLCStoreBase.getState() + // See executeSaveProject for rationale — same read-only gate. + if (state.workspace.isReadOnly) { + state.modalActions.openModal('read-only-project') + return { success: false } + } const { project, files } = state const { setEditingState } = state.workspaceActions const { updateFile, checkIfAllFilesAreSaved } = state.fileActions diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 91e159d96..846528837 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -1921,6 +1921,30 @@ describe('createSharedSlice', () => { expect(flow.updated).toBe(false) }) }) + + // ----------------------------------------------------------------------- + // canEdit → workspace.isReadOnly + // ----------------------------------------------------------------------- + it('sets workspace.isReadOnly=true when canEdit is false', () => { + const data = { ...makeMinimalProjectResponse(), canEdit: false } + store.getState().sharedWorkspaceActions.handleOpenProjectResponse(data) + expect(store.getState().workspace.isReadOnly).toBe(true) + }) + + it('keeps workspace.isReadOnly=false when canEdit is true', () => { + // Pre-seed read-only so we observe the reset path, not just the default. + store.getState().workspaceActions.setReadOnly(true) + const data = { ...makeMinimalProjectResponse(), canEdit: true } + store.getState().sharedWorkspaceActions.handleOpenProjectResponse(data) + expect(store.getState().workspace.isReadOnly).toBe(false) + }) + + it('treats absent canEdit as editable (desktop / dev-local default)', () => { + store.getState().workspaceActions.setReadOnly(true) + const data = makeMinimalProjectResponse() + store.getState().sharedWorkspaceActions.handleOpenProjectResponse(data) + expect(store.getState().workspace.isReadOnly).toBe(false) + }) }) }) diff --git a/src/frontend/store/__tests__/workspace-slice.test.ts b/src/frontend/store/__tests__/workspace-slice.test.ts index c7ca130c6..c25d5d7b9 100644 --- a/src/frontend/store/__tests__/workspace-slice.test.ts +++ b/src/frontend/store/__tests__/workspace-slice.test.ts @@ -570,14 +570,22 @@ describe('createWorkspaceSlice', () => { store.getState().workspaceActions.setPlcLogsLastId(5) store.getState().workspaceActions.setProjectLoading(true, 'Loading project...') + store.getState().workspaceActions.setReadOnly(true) expect(store.getState().workspace.isProjectLoading).toBe(true) expect(store.getState().workspace.projectLoadingMessage).toBe('Loading project...') + expect(store.getState().workspace.isReadOnly).toBe(true) store.getState().workspaceActions.setProjectLoading(false) + store.getState().workspaceActions.setReadOnly(false) expect(store.getState().workspace.isProjectLoading).toBe(false) expect(store.getState().workspace.projectLoadingMessage).toBe('') + expect(store.getState().workspace.isReadOnly).toBe(false) + + // setReadOnly(true) again so clearWorkspace's reset path is exercised + // — keeps the assertion below honest about the reset. + store.getState().workspaceActions.setReadOnly(true) store.getState().workspaceActions.clearWorkspace() @@ -609,5 +617,6 @@ describe('createWorkspaceSlice', () => { searchTerm: '', timestampFormat: 'full', }) + expect(workspace.isReadOnly).toBe(false) }) }) diff --git a/src/frontend/store/slices/modal/slice.ts b/src/frontend/store/slices/modal/slice.ts index c41faf97b..2eb342055 100644 --- a/src/frontend/store/slices/modal/slice.ts +++ b/src/frontend/store/slices/modal/slice.ts @@ -23,6 +23,7 @@ const ALL_MODAL_TYPES: ModalTypes[] = [ 'debugger-message', 'debugger-ip-input', 'missing-libraries', + 'read-only-project', ] function createDefaultModals() { diff --git a/src/frontend/store/slices/modal/types.ts b/src/frontend/store/slices/modal/types.ts index ef4ad1a5d..fd8ee1303 100644 --- a/src/frontend/store/slices/modal/types.ts +++ b/src/frontend/store/slices/modal/types.ts @@ -22,6 +22,11 @@ export type ModalTypes = | 'debugger-message' | 'debugger-ip-input' | 'missing-libraries' + /** Surfaced whenever the user tries a write action (save, commit, + * create/delete branch, create/rename/delete POU) on a project they + * don't have edit permission on. Shows the "this project belongs to + * someone else" message and the inline Fork flow. */ + | 'read-only-project' export type ModalsState = Record diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index ae5d13abc..aa535f2c8 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -511,6 +511,11 @@ const createSharedSlice: StateCreator = (s handleOpenProjectResponse: (data) => { getState().sharedWorkspaceActions.clearStatesOnCloseProject() getState().workspaceActions.setEditingState('saved') + // Apply the edit-permission flag from the backend. `canEdit === + // false` ⇒ read-only mode; `true` or `undefined` ⇒ editable. + // clearStatesOnCloseProject above already reset to `false`, so an + // editable project just stays in that default. + getState().workspaceActions.setReadOnly(data.canEdit === false) // Log any parsing warnings to the app console (after clear so they aren't wiped) if (data.warnings) { diff --git a/src/frontend/store/slices/shared/types.ts b/src/frontend/store/slices/shared/types.ts index b30e1e2c5..e5879f368 100644 --- a/src/frontend/store/slices/shared/types.ts +++ b/src/frontend/store/slices/shared/types.ts @@ -129,6 +129,13 @@ export type OpenProjectResponseData = { devicePinMapping?: DevicePin[] /** Warnings from parsing (e.g. dropped files that failed validation). */ warnings?: string[] + /** + * Edit permission flag forwarded from `ProjectResponse.data.canEdit`. + * `false` puts the workspace in read-only mode; `true` / `undefined` + * keep it fully editable. Absent ⇒ desktop editor or dev-local; both + * have no remote permission concept so the editor stays unrestricted. + */ + canEdit?: boolean } export type SharedWorkspaceActions = { diff --git a/src/frontend/store/slices/workspace/slice.ts b/src/frontend/store/slices/workspace/slice.ts index 0fb87fe44..1f4f2c09b 100644 --- a/src/frontend/store/slices/workspace/slice.ts +++ b/src/frontend/store/slices/workspace/slice.ts @@ -58,6 +58,8 @@ const createWorkspaceSlice: StateCreator // Project loading state isProjectLoading: false, projectLoadingMessage: '', + // Read-only mode (no edit permission on the open project) + isReadOnly: false, }, workspaceActions: { @@ -183,6 +185,7 @@ const createWorkspaceSlice: StateCreator } workspace.isProjectLoading = false workspace.projectLoadingMessage = '' + workspace.isReadOnly = false }), ) }, @@ -442,6 +445,13 @@ const createWorkspaceSlice: StateCreator }), ) }, + setReadOnly: (value: boolean) => { + setState( + produce(({ workspace }: WorkspaceSlice) => { + workspace.isReadOnly = value + }), + ) + }, }, }) diff --git a/src/frontend/store/slices/workspace/types.ts b/src/frontend/store/slices/workspace/types.ts index 1714b7e13..9adc069fe 100644 --- a/src/frontend/store/slices/workspace/types.ts +++ b/src/frontend/store/slices/workspace/types.ts @@ -114,6 +114,16 @@ export type WorkspaceState = { // Project loading state isProjectLoading: boolean projectLoadingMessage: string + /** + * True when the currently open project is read-only for the viewer + * (no edit permission). Drives every write-action gate in the UI + * — Monaco/graphical readOnly, Save/Commit/Branch dialog, project- + * tree menu items, etc. Set by `handleOpenProjectResponse` from + * the `canEdit` flag returned by the backend; reset to `false` on + * project close so a subsequent open of an editable project comes + * up unrestricted. + */ + isReadOnly: boolean } } @@ -179,6 +189,8 @@ export type WorkspaceActions = { removeDebugVariable: (compositeKey: string) => void // Project loading setProjectLoading: (isLoading: boolean, message?: string) => void + // Read-only mode (no edit permission on the open project) + setReadOnly: (value: boolean) => void } export type WorkspaceSlice = WorkspaceState & { diff --git a/src/middleware/shared/ports/project-port.ts b/src/middleware/shared/ports/project-port.ts index 5cf249c88..7c1111e35 100644 --- a/src/middleware/shared/ports/project-port.ts +++ b/src/middleware/shared/ports/project-port.ts @@ -55,6 +55,13 @@ export interface ProjectResponse { * arise from parse-serialize formatting drift. */ rawLoadedFiles?: Record + /** + * Whether the current user has edit permission on this project. Drives + * the editor's read-only gating (Monaco/graphical/save/commit/branch). + * Absent ⇒ treated as `true` (desktop editor and dev:local mode have no + * remote permission concept and must remain fully editable). + */ + canEdit?: boolean } error?: { title: string @@ -141,10 +148,49 @@ export interface RawProjectFiles { serverFiles: RawProjectFile[] /** Raw remote device config files from devices/remote/ */ remoteDeviceFiles: RawProjectFile[] + /** See {@link ProjectResponse.data.canEdit}. Carried through the + * raw layer so adapters that build `ProjectResponse` from a raw + * fetch don't have to round-trip the details endpoint twice. */ + canEdit?: boolean } error?: { title: string; description: string } } +/** + * Folder in the user's namespace, used by the read-only project modal's + * Fork flow to let the user pick where the fork should land. Mirrors the + * shape returned by autonomy-edge `GET /folders?includeHierarchy=true`. + */ +export interface ProjectFolder { + id: string + name: string + /** Backend folder kind. The well-known value `'root'` identifies the + * implicit user-root folder (shown as "Root (/)" in the picker); any + * other string is a normal user-created folder type from + * autonomy-edge's `/folders` endpoint. */ + type: string + parentId: string | null + children?: ProjectFolder[] +} + +/** Params for {@link ProjectPort.forkProject}. */ +export interface ForkProjectParams { + projectId: string + destinationFolderId: string + /** Optional rename. When forking a project that already lives in the + * caller's namespace the backend requires a name different from the + * source; otherwise it falls back to " (N)". */ + name?: string +} + +/** Result of {@link ProjectPort.forkProject}. On success the new project + * id is surfaced so the editor can navigate the URL to `?project_id=`. */ +export interface ForkProjectResponse { + success: boolean + data?: { projectId: string } + error?: { title: string; description: string } +} + export interface ProjectPort { /** Create a new project. */ createProject(params: CreateProjectParams): Promise @@ -222,4 +268,23 @@ export interface ProjectPort { * Web: not applicable (never fires). */ onFileExternalChange?(callback: (filePath: string) => void): Unsubscribe + + /** + * Fork a project into the caller's namespace. Optional — only the + * web adapter implements this (desktop editor has no remote project + * concept). Returns `{ success: true, data: { projectId } }` on success + * so the caller can navigate to the new project. + */ + forkProject?(params: ForkProjectParams): Promise + + /** + * List the caller's folders (root + nested hierarchy) so the fork + * destination picker can render a tree. Optional — desktop editor + * returns `{ success: false, error }` since it has no remote folders. + */ + listMyFolders?(): Promise<{ + success: boolean + data?: ProjectFolder[] + error?: { title: string; description: string } + }> }