Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'

import { useVersionControl } from '../../../../../middleware/shared/providers'
import { useOpenPLCStore } from '../../../../store'

type CreateBranchModalProps = {
isOpen: boolean
Expand All @@ -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)
Expand All @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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('')
Expand Down Expand Up @@ -61,6 +64,12 @@ export function CreateBranchPopover({ projectId, onCreated, onCloseParent }: Cre
<Popover.Root
open={isOpen}
onOpenChange={(open) => {
// 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('')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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(() => {
Expand All @@ -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 = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -218,11 +231,15 @@ const ElementCard = (props: ElementCardProps): ReactNode => {
}

const handleMouseEnter = () => {
if (isReadOnly) {
openModal('read-only-project')
return
}
setIsOpen(true)
}

return (
<Popover.Root open={isOpen} onOpenChange={setIsOpen}>
<Popover.Root open={isOpen} onOpenChange={handleOpen}>
<Popover.Trigger
onMouseEnter={handleMouseEnter}
id={`create-${target}-trigger`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@ const GraphicalEditor = ({ name, language, readOnly, isActive = true }: Graphica
return (
<GraphicalEditorActiveProvider pouName={name} isActive={isActive}>
<div className='relative h-full w-full overflow-y-auto'>
{readOnly && (
<div className='absolute inset-0 z-10 cursor-not-allowed' title='Read-only: viewing historical commit' />
)}
{readOnly && <div className='absolute inset-0 z-10 cursor-not-allowed' title='Read-only' />}
<div className={`h-full w-full${readOnly ? ' pointer-events-none' : ''}`}>
<EditorComponent />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType<typeof PrimitiveEdit
workspace: {
systemConfigs: { shouldUseDarkMode },
isDebuggerVisible,
isReadOnly,
fbSelectedInstance,
fbDebugInstances,
},
Expand Down Expand Up @@ -428,10 +429,13 @@ const MonacoEditor = (props: monacoEditorProps): ReturnType<typeof PrimitiveEdit
return () => 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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -678,15 +686,17 @@ export function ChangesSection({ projectId }: ChangesSectionProps) {
</div>
<div className='flex gap-2'>
<button
onClick={() => void handleCommit()}
disabled={!canCommit || isCommitting}
onClick={() => (isReadOnly ? openReadOnlyModal('read-only-project') : void handleCommit())}
disabled={(!canCommit && !isReadOnly) || isCommitting}
title={isReadOnly ? 'Read-only project — fork to commit' : undefined}
className='flex-1 rounded-md bg-blue-500 px-3 py-1.5 text-xs font-medium text-white transition-colors duration-150 hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50'
>
{isCommitting ? 'Committing...' : 'Commit'}
</button>
<button
onClick={() => setShowDiscardModal(true)}
disabled={selectedFiles.size === 0 || isDiscarding}
onClick={() => (isReadOnly ? openReadOnlyModal('read-only-project') : setShowDiscardModal(true))}
disabled={(selectedFiles.size === 0 && !isReadOnly) || isDiscarding}
title={isReadOnly ? 'Read-only project — fork to discard' : undefined}
className='rounded-md bg-neutral-100 px-3 py-1.5 text-xs font-medium text-neutral-700 transition-colors duration-150 hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-red-900/30 dark:hover:text-red-400'
>
Discard
Expand Down
15 changes: 14 additions & 1 deletion src/frontend/components/_molecules/menu-bar/menus/file.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ export const FileMenu = () => {
const capabilities = useCapabilities()
const {
editor: activeEditor,
workspace: { editingState },
workspace: { editingState, isReadOnly },
sharedWorkspaceActions: { closeProject },
modalActions: { openModal },
} = useOpenPLCStore()

const { handleRemoveTab, selectedTab, setSelectedTab } = useHandleRemoveTab()
Expand All @@ -27,13 +28,25 @@ export const FileMenu = () => {

const isSaving = editingState === 'save-request'

// Read-only projects: route the click to the fork modal directly so
// we don't even briefly toggle 'save-request' → 'unsaved' in the
// editingState flag. The save-actions helpers gate this too, but
// doing it here keeps the menu honest about what the click will do.
const handleSave = () => {
if (isReadOnly) {
openModal('read-only-project')
return
}
if (activeEditor.meta.name && !isSaving) {
void executeSaveActiveFile(projectPort, capabilities)
}
}

const handleSaveProject = () => {
if (isReadOnly) {
openModal('read-only-project')
return
}
if (!isSaving) {
void executeSaveProject(projectPort, capabilities)
}
Expand Down
66 changes: 41 additions & 25 deletions src/frontend/components/_molecules/project-tree/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,9 @@ const ProjectTreeExpandableLeaf = ({
editor: {
meta: { name },
},
workspace: { selectedProjectTreeLeaf, isDebuggerVisible },
workspace: { selectedProjectTreeLeaf, isDebuggerVisible, isReadOnly },
workspaceActions: { setSelectedProjectTreeLeaf },
modalActions: { openModal },
remoteDeviceActions: { deleteRequest: deleteRemoteDeviceRequest, rename: renameRemoteDevice },
fileActions: { getFile },
} = useOpenPLCStore()
Expand Down Expand Up @@ -318,20 +319,29 @@ const ProjectTreeExpandableLeaf = ({
}, [inputNameRef, isEditing])

const popoverOptions = useMemo(
() => [
{
name: 'Rename',
onClick: () => setIsEditing(true),
icon: <PencilIcon className='h-4 w-4 stroke-brand dark:stroke-brand-light' />,
},
{
name: 'Delete',
onClick: () => handleDeleteFile(),
icon: <CloseIcon className='h-4 w-4 stroke-brand dark:stroke-brand-light' />,
},
],
() => {
const guard = (real: () => void) => () => {
if (isReadOnly) {
openModal('read-only-project')
return
}
real()
}
return [
{
name: 'Rename',
onClick: guard(() => setIsEditing(true)),
icon: <PencilIcon className='h-4 w-4 stroke-brand dark:stroke-brand-light' />,
},
{
name: 'Delete',
onClick: guard(() => handleDeleteFile()),
icon: <CloseIcon className='h-4 w-4 stroke-brand dark:stroke-brand-light' />,
},
]
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[label],
[label, isReadOnly, openModal],
)
Comment on lines +322 to 345

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Incomplete dependency list in useMemo for popoverOptions.

The guard function closes over setIsEditing (line 333) and handleDeleteFile (line 338), but the dependency array on line 344 only includes [label, isReadOnly, openModal]. While the ESLint rule is disabled, this creates a risk of stale closures if those handlers change.

Consider either:

  1. Including all captured functions in the dependency list, or
  2. Accepting the current behavior if the handlers are stable and performance is prioritized.

As per coding guidelines, components should maintain correctness — stale closures can lead to unexpected behavior.

📋 Suggested fix: add missing dependencies
   },
   // eslint-disable-next-line react-hooks/exhaustive-deps
-  [label, isReadOnly, openModal],
+  [label, isReadOnly, openModal, setIsEditing, handleDeleteFile],
 )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
() => {
const guard = (real: () => void) => () => {
if (isReadOnly) {
openModal('read-only-project')
return
}
real()
}
return [
{
name: 'Rename',
onClick: guard(() => setIsEditing(true)),
icon: <PencilIcon className='h-4 w-4 stroke-brand dark:stroke-brand-light' />,
},
{
name: 'Delete',
onClick: guard(() => handleDeleteFile()),
icon: <CloseIcon className='h-4 w-4 stroke-brand dark:stroke-brand-light' />,
},
]
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[label],
[label, isReadOnly, openModal],
)
() => {
const guard = (real: () => void) => () => {
if (isReadOnly) {
openModal('read-only-project')
return
}
real()
}
return [
{
name: 'Rename',
onClick: guard(() => setIsEditing(true)),
icon: <PencilIcon className='h-4 w-4 stroke-brand dark:stroke-brand-light' />,
},
{
name: 'Delete',
onClick: guard(() => handleDeleteFile()),
icon: <CloseIcon className='h-4 w-4 stroke-brand dark:stroke-brand-light' />,
},
]
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[label, isReadOnly, openModal, setIsEditing, handleDeleteFile],
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/components/_molecules/project-tree/index.tsx` around lines 322 -
345, The useMemo that builds popoverOptions defines guard which closes over
setIsEditing and handleDeleteFile but the dependency array only lists [label,
isReadOnly, openModal], risking stale closures; update the dependency array for
the useMemo that returns popoverOptions to include setIsEditing and
handleDeleteFile (and any other functions/values referenced inside guard) or, if
you intentionally want them stable, document/ensure those handlers are memoized
and remove the eslint-disable comment so dependencies remain correct; reference
the useMemo creating popoverOptions, the guard function, setIsEditing, and
handleDeleteFile when making the change.


return (
Expand Down Expand Up @@ -505,8 +515,9 @@ const ProjectTreeLeaf = ({
editor: {
meta: { name },
},
workspace: { selectedProjectTreeLeaf, isDebuggerVisible },
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 },
Expand Down Expand Up @@ -731,30 +742,35 @@ 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()
}
return [
{
name: 'Rename',
onClick: () => {
setIsEditing(true)
},
onClick: guard(() => setIsEditing(true)),
icon: <PencilIcon className='h-4 w-4 stroke-brand dark:stroke-brand-light' />,
},
{
name: 'Duplicate',
onClick: () => {
void handleDuplicateFile()
},
onClick: guard(() => void handleDuplicateFile()),
icon: <DuplicateIcon className='h-4 w-4 stroke-brand dark:stroke-brand-light' />,
},
{
name: 'Delete',
onClick: () => {
handleDeleteFile()
},
onClick: guard(() => handleDeleteFile()),
icon: <CloseIcon className='h-4 w-4 stroke-brand dark:stroke-brand-light' />,
},
]
}, [handleDeleteFile, handleDuplicateFile, setIsEditing])
}, [handleDeleteFile, handleDuplicateFile, setIsEditing, isReadOnly, openModal])

useEffect(() => {
if (isEditing && inputNameRef.current) {
Expand Down
Loading
Loading