Skip to content
Closed
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,8 +1,9 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
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'

Expand All @@ -28,6 +29,10 @@
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<Branch[]>([])
const [isLoading, setIsLoading] = useState(false)
const [filter, setFilter] = useState('')
Expand Down Expand Up @@ -185,96 +190,101 @@
default
</span>
)}
{/* Actions menu (3 dots) — reveals merge + delete on hover. */}
<div className='shrink-0' data-branch-actions>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
className='rounded p-0.5 opacity-0 transition-opacity hover:bg-neutral-200 group-hover:opacity-100 dark:hover:bg-neutral-800'
title='More actions'
>
<svg
className='h-3.5 w-3.5 text-neutral-500 dark:text-neutral-400'
viewBox='0 0 16 16'
fill='currentColor'
>
<path d='M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM1.5 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm13 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z' />
</svg>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
side='right'
align='start'
sideOffset={4}
onCloseAutoFocus={(e) => 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'
>
<DropdownMenu.Item
disabled={isActive}
onSelect={(e) => {
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 && (
<div className='shrink-0' data-branch-actions>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
className='rounded p-0.5 opacity-0 transition-opacity hover:bg-neutral-200 group-hover:opacity-100 dark:hover:bg-neutral-800'
title='More actions'
>
<svg
className={cn(
'h-3.5 w-3.5',
isActive ? 'text-neutral-400 dark:text-neutral-600' : 'text-blue-500',
)}
className='h-3.5 w-3.5 text-neutral-500 dark:text-neutral-400'
viewBox='0 0 16 16'
fill='currentColor'
>
<path d='M5 3.254V3.25v.005a.75.75 0 1 1 0-.005v.004zm.45 1.9a2.25 2.25 0 1 0-1.95.218v5.256a2.25 2.25 0 1 0 1.5 0V7.123A5.735 5.735 0 0 0 9.25 9h1.378a2.251 2.251 0 1 0 0-1.5H9.25a4.25 4.25 0 0 1-3.8-2.346zM12.75 9a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm-8.5 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5z' />
<path d='M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM1.5 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm13 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z' />
</svg>
<span>
Merge <span className='font-mono'>{branch.name}</span> into{' '}
<span className='font-mono'>{currentBranchName}</span>
</span>
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={branch.isDefault}
onSelect={(e) => {
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',
)}
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
side='right'
align='start'
sideOffset={4}
onCloseAutoFocus={(e) => 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'
>
<svg className='h-3.5 w-3.5' viewBox='0 0 16 16' fill='currentColor'>
<path d='M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25ZM4.005 5.073a.75.75 0 0 1 .673.627l.79 5.532a.75.75 0 0 0 .742.643h3.58a.75.75 0 0 0 .742-.643l.79-5.532a.75.75 0 0 1 1.49.214l-.79 5.532A2.25 2.25 0 0 1 9.79 13.5H6.21a2.25 2.25 0 0 1-2.23-1.928l-.79-5.532a.75.75 0 0 1 .626-.867Z' />
</svg>
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
<DropdownMenu.Item
disabled={isActive}
onSelect={(e) => {
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',
)}
>
<svg
className={cn(
'h-3.5 w-3.5',
isActive ? 'text-neutral-400 dark:text-neutral-600' : 'text-blue-500',
)}
viewBox='0 0 16 16'
fill='currentColor'
>
<path d='M5 3.254V3.25v.005a.75.75 0 1 1 0-.005v.004zm.45 1.9a2.25 2.25 0 1 0-1.95.218v5.256a2.25 2.25 0 1 0 1.5 0V7.123A5.735 5.735 0 0 0 9.25 9h1.378a2.251 2.251 0 1 0 0-1.5H9.25a4.25 4.25 0 0 1-3.8-2.346zM12.75 9a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5zm-8.5 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5z' />
</svg>
<span>
Merge <span className='font-mono'>{branch.name}</span> into{' '}
<span className='font-mono'>{currentBranchName}</span>
</span>
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={branch.isDefault}
onSelect={(e) => {
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',
)}
>
<svg className='h-3.5 w-3.5' viewBox='0 0 16 16' fill='currentColor'>
<path d='M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25ZM4.005 5.073a.75.75 0 0 1 .673.627l.79 5.532a.75.75 0 0 0 .742.643h3.58a.75.75 0 0 0 .742-.643l.79-5.532a.75.75 0 0 1 1.49.214l-.79 5.532A2.25 2.25 0 0 1 9.79 13.5H6.21a2.25 2.25 0 0 1-2.23-1.928l-.79-5.532a.75.75 0 0 1 .626-.867Z' />
</svg>
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
)}
</div>
)
})}
</div>

{/* Create new branch */}
<div className='px-2 pb-2'>
<div className='mb-1 h-[1px] w-full bg-neutral-200 dark:!bg-neutral-850' />
<CreateBranchPopover projectId={projectId} onCloseParent={handleClose} />
</div>
{/* Create new branch — hidden in read-only (a non-owner can't create). */}
{!isReadOnly && (
<div className='px-2 pb-2'>
<div className='mb-1 h-[1px] w-full bg-neutral-200 dark:!bg-neutral-850' />
<CreateBranchPopover projectId={projectId} onCloseParent={handleClose} />
</div>
)}
</div>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/* eslint-disable @typescript-eslint/no-misused-promises */
import * as Popover from '@radix-ui/react-popover'

Expand Down Expand Up @@ -128,20 +128,16 @@
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)
}

Expand Down Expand Up @@ -231,10 +227,6 @@
}

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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { ComponentPropsWithoutRef } from 'react'

import { GraphicalEditorActiveProvider } from './active-context'
Expand All @@ -20,7 +20,7 @@
isActive?: boolean
}

const GraphicalEditor = ({ name, language, readOnly, isActive = true }: GraphicalEditorProps) => {
const GraphicalEditor = ({ name, language, isActive = true }: GraphicalEditorProps) => {
const editorComponents = {
sfc: SfcEditor,
fbd: FbdEditor,
Expand All @@ -29,11 +29,15 @@

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 (
<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' />}
<div className={`h-full w-full${readOnly ? ' pointer-events-none' : ''}`}>
<div className='h-full w-full'>
<EditorComponent />
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import './configs'

import { Editor as PrimitiveEditor } from '@monaco-editor/react'
Expand Down Expand Up @@ -143,7 +143,6 @@
workspace: {
systemConfigs: { shouldUseDarkMode },
isDebuggerVisible,
isReadOnly,
fbSelectedInstance,
fbDebugInstances,
},
Expand Down Expand Up @@ -429,13 +428,14 @@
return () => 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
Expand Down Expand Up @@ -1240,7 +1240,7 @@
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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { ChevronDown, ChevronRight, File } from 'lucide-react'
import { useState } from 'react'

Expand All @@ -20,6 +20,8 @@
project: {
meta: { path: storedProjectId },
},
workspace: { isReadOnly },
modalActions: { openModal },
sharedWorkspaceActions,
} = useOpenPLCStore()
const projectPort = useProject()
Expand Down Expand Up @@ -171,7 +173,11 @@
View All Files
</button>
<button
onClick={() => setShowRestoreModal(true)}
// Restore overwrites the working tree from a past commit — a write
// action. In read-only (no edit permission) route straight to the
// fork modal instead of the restore confirmation, same as Commit/
// Stash/Discard.
onClick={() => (isReadOnly ? openModal('read-only-project') : setShowRestoreModal(true))}
className='w-full rounded-md bg-neutral-100 px-3 py-1.5 text-xs font-medium text-neutral-700 transition-colors duration-150 hover:bg-yellow-50 hover:text-yellow-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-yellow-900/30 dark:hover:text-yellow-400'
>
Restore to This Version
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { Archive, ChevronDown, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'

Expand Down Expand Up @@ -91,7 +91,7 @@
else await versionControl.applyStash(projectId, stash.hash)
await reloadProject()
await fetchStashes()
toast({ title: pop ? 'Stash popped' : 'Stash applied', variant: 'default' })
toast({ title: pop ? 'Stash applied & removed' : 'Stash applied', variant: 'default' })
} catch (error) {
if (error instanceof StashConflictError) {
toast({
Expand All @@ -100,7 +100,7 @@
variant: 'fail',
})
} else {
toast({ title: pop ? 'Failed to pop stash' : 'Failed to apply stash', variant: 'fail' })
toast({ title: pop ? 'Failed to apply & remove stash' : 'Failed to apply stash', variant: 'fail' })
}
} finally {
setBusyRef(null)
Expand Down Expand Up @@ -184,7 +184,7 @@
className='flex cursor-pointer items-center gap-1 rounded-md bg-blue-500 px-2 py-1 text-[11px] font-medium text-white transition-colors hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50'
>
<ChevronDown className='h-3 w-3' />
Pop
Apply & remove
</button>
<button
onClick={() => setDropTarget(stash)}
Expand Down
Loading
Loading