fix(ui-workspace): keep the per-resource access gates on the whenAccess client

Master's rows and menus carry no answer of their own, so the served-page seams
thread the Slot-standard access Hooks through the workspace browser and the
session actions: New Session, workspace rename/delete/reorder, and a Session's
rename, pin, archive, and reorder appear only while their own resource allows
them. The rows spec keeps master's pointer-armed rename helper and supplies the
allow-all answers as props.
This commit is contained in:
mektpoy committed 2026-10-22 21:44:25 +08:00
1 parent 0d521bdd0d
commit 55deb7a6c6
3 files changed
+194 -51

No files matched your search

+10 -10
View File
@@ -18,8 +18,8 @@
en: 650a0b951c92626e
zh: 8b059c0678656a79
/deepseek-ai-dsh-client-ui-workspace/use-this-package/reordering-and-view-options:
en: febc46e6c0bdc58d
zh: 1e5196e31d6e8b06
en: 8673afc1d72ad077
zh: 9fbb83a4029c5d10
/deepseek-ai-dsh-client-ui-workspace/use-this-package/workspace-hierarchy:
en: a94394a5a9f70ed8
zh: 62ce596a95395ad8
@@ -27,14 +27,14 @@
en: 29facc4eae028dae
zh: 754ebac4cc294e65
/deepseek-ai-dsh-client-ui-workspace/use-this-package/managing-sessions:
en: 9e36b54394c1d8b6
zh: 34d464a7eddfc58d
en: 353ed9b66fab6486
zh: e38a4a37627a9426
/deepseek-ai-dsh-client-ui-workspace/use-this-package/pending-interactions:
en: 9fb88260380bf093
zh: 3bace2b96ecd4a82
/deepseek-ai-dsh-client-ui-workspace/use-this-package/active-schedule-markers:
en: 24888375e0d70b50
zh: 692b880a1ab05d94
en: ac15c05cb045f748
zh: 6330172c7231b2b8
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation:
en: 96a37e74555a36b7
zh: d03941523509a5cf
@@ -42,8 +42,8 @@
en: 868904dbfb5713f8
zh: 225906bfef7398bf
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/session-row-actions:
en: c98df20bff0173f6
zh: 98a26e175ab3debc
en: 5bbba6e462faf3dc
zh: 4734263208ee07c9
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/session-row-actions/packaged-client-plugin:
en: 36934671b80b11ae
zh: 8e0aea6162e35e77
@@ -51,8 +51,8 @@
en: ad4a333892f2fe92
zh: 3a670fd401f4d7a7
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/view-state:
en: faedf74d08113970
zh: c5f383727a65a1b1
en: b62b5a344b962c45
zh: 6c773bece95c3621
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/hover-cards:
en: 8bcb7bd90d7c5773
zh: 17663be926a099d2
@@ -23,6 +23,7 @@ import {
import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path'
import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { MenuOpenState, WorkspaceBrowserProps } from '../contract/slots.ts'
import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts'
import css from './Rows.module.css'
@@ -203,21 +204,8 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
}
/**
* Project (workspace) header row: folder + title;
* hover reveals the chevron and create button, and dwelling on a real
* Workspace shows its hover card (the ungrouped bucket has none).
* `containsCurrent` arrives on the node (derivation fact, no renderer scan).
* @param props.group - derived group node.
* @param props.containsCurrentDescendant - highlight an ancestor even when its subtree is collapsed.
* @param props.onToggle - expand/collapse the group.
* @param props.onCreate - start a frontend Session inside this Workspace.
* @param props.drag - optional workspace-row drag wiring.
* @param props.home - host account home for POSIX hover-path abbreviation.
* @param props.t - the browser root's locale seat.
* @returns the row element.
*/
export function ProjectRowItem({ group, containsCurrentDescendant = false, onToggle, onCreate, actions, drag, home, newShortcut, t }: {
/** Owner props of one Workspace header row; the access Hooks are the browser root's own standard seats. */
type ProjectRowItemProps = {
group: GroupNode
newShortcut?: ShortcutCatalogEntry | undefined
containsCurrentDescendant?: boolean
@@ -230,7 +218,62 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog
/** Host account home; POSIX home-rooted hover paths display as `~`. */
home?: string | undefined
t: RowTranslate
}) {
} & Pick<WorkspaceBrowserProps, 'useRemoteAccess' | 'useWorkspaceAccess'>
/** Which of one header row's actions the caller's access snapshot allows. */
interface ProjectRowAccess {
readonly create: boolean
readonly rename: boolean
readonly delete: boolean
readonly reorder: boolean
}
/**
* Project (workspace) header row: folder + title;
* hover reveals the chevron and create button, and dwelling on a real
* Workspace shows its hover card (the ungrouped bucket has none).
* `containsCurrent` arrives on the node (derivation fact, no renderer scan).
* The create button, each Workspace menu row, and the drag handle are absent
* while the access snapshot excludes their endpoint: a real Workspace asks
* its own resource, and the ungrouped bucket asks the global resource.
* @param props.group - derived group node.
* @param props.containsCurrentDescendant - highlight an ancestor even when its subtree is collapsed.
* @param props.onToggle - expand/collapse the group.
* @param props.onCreate - start a frontend Session inside this Workspace.
* @param props.drag - optional workspace-row drag wiring.
* @param props.home - host account home for POSIX hover-path abbreviation.
* @param props.t - the browser root's locale seat.
* @returns the row element.
*/
export function ProjectRowItem(props: ProjectRowItemProps) {
const workspaceId = props.group.workspaceId
return workspaceId === undefined
? <UngroupedProjectRow {...props} />
: <WorkspaceProjectRow {...props} workspaceId={workspaceId} />
}
/** The ungrouped bucket's header: no Workspace resource, so starting a Session there asks the global resource. */
function UngroupedProjectRow(props: ProjectRowItemProps) {
const create = props.useRemoteAccess('session/create')
return <ProjectRow {...props} access={{ create, rename: false, delete: false, reorder: false }} />
}
/** A real Workspace's header: every action asks that Workspace's resource. */
function WorkspaceProjectRow({ workspaceId, ...props }: ProjectRowItemProps & { workspaceId: WorkspaceId }) {
const { useWorkspaceAccess } = props
const access: ProjectRowAccess = {
create: useWorkspaceAccess(workspaceId, 'session/create'),
rename: useWorkspaceAccess(workspaceId, 'workspace/rename'),
delete: useWorkspaceAccess(workspaceId, 'workspace/delete'),
reorder: useWorkspaceAccess(workspaceId, 'workspace/insertBefore'),
}
return <ProjectRow {...props} access={access} />
}
function ProjectRow({
group, containsCurrentDescendant = false, onToggle, onCreate, actions, drag: offeredDrag, home, newShortcut, t, access,
}: ProjectRowItemProps & { access: ProjectRowAccess }) {
const drag = access.reorder ? offeredDrag : undefined
const row = group
// The ungrouped bucket has no workspace title: its label is dictionary copy.
const label = row.workspaceId === undefined ? t('group.ungrouped') : row.label
@@ -238,8 +281,8 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog
const [menuOpen, setMenuOpen] = useState(false)
const dragPreview = useRowDragPreview(css.dragPreview as string)
const workspaceMenuItems = [
{ id: 'rename', label: t('rename'), icon: <IconEditOutlineRegular /> },
{ id: 'delete', label: t('delete.workspace'), icon: <IconTrashOutlineRegular />, danger: true },
...access.rename ? [{ id: 'rename', label: t('rename'), icon: <IconEditOutlineRegular /> }] : [],
...access.delete ? [{ id: 'delete', label: t('delete.workspace'), icon: <IconTrashOutlineRegular />, danger: true }] : [],
]
const ownRow = (
<div
@@ -269,7 +312,7 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog
<span className={css.title}>{label}</span>
</span>
<span className={css.rowActions}>
{actions !== undefined && (
{actions !== undefined && workspaceMenuItems.length > 0 && (
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
@@ -297,17 +340,19 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog
)}
/>
)}
<Tooltip label={t('actions.newSession')} shortcutKeys={newShortcut?.keys} side="bottom" align="end" delayMs={500} portal>
<button
type="button"
className={css.iconButton}
aria-keyshortcuts={newShortcut?.aria}
aria-label={t('actions.newSession.aria', { name: label })}
onClick={(e) => { e.stopPropagation(); onCreate() }}
>
<IconNewChatOutlineRegular />
</button>
</Tooltip>
{access.create && (
<Tooltip label={t('actions.newSession')} shortcutKeys={newShortcut?.keys} side="bottom" align="end" delayMs={500} portal>
<button
type="button"
className={css.iconButton}
aria-keyshortcuts={newShortcut?.aria}
aria-label={t('actions.newSession.aria', { name: label })}
onClick={(e) => { e.stopPropagation(); onCreate() }}
>
<IconNewChatOutlineRegular />
</button>
</Tooltip>
)}
</span>
</div>
)
@@ -547,13 +592,16 @@ export function SearchResultItem({ result, currentId, onOpen, onUnarchive, t }:
* (`sidebar.workspaces.session.menu.item` / `sidebar.workspaces.session.row.action`),
* its leading and trailing decorations, and its hover-card section.
* @param props.onReveal - scroll this row into view after search navigation, then acknowledge it.
* @param props.drag - optional row-drag target wiring; blank rows cannot start a drag.
* @param props.drag - optional row-drag target wiring; blank rows, and rows whose access excludes
* `workspace/insertSessionBefore`, cannot start a drag.
* @param props.home - host account home for POSIX hover-path abbreviation.
* @param props.useSessionAccess - the browser root's Session access Hook; the title double-click
* renames only while it allows `session/rename`.
* @param props.t - the browser root's locale seat.
* @returns the session row.
*/
export function SessionNodeItem({
node, currentId, now, onOpen, onRenameRequest, renderSlot, onReveal, drag, home, t,
node, currentId, now, onOpen, onRenameRequest, renderSlot, onReveal, drag, home, useSessionAccess, t,
}: {
node: SessionNode
currentId: string | undefined
@@ -568,7 +616,7 @@ export function SessionNodeItem({
/** Host account home; POSIX home-rooted hover paths display as `~`. */
home?: string | undefined
t: RowTranslate
} & PropsRenderSlots<
} & Pick<WorkspaceBrowserProps, 'useSessionAccess'> & PropsRenderSlots<
| 'sidebar.workspaces.session.menu.item'
| 'sidebar.workspaces.session.row.action'
| 'sidebar.session.row.trailing'
@@ -584,7 +632,11 @@ export function SessionNodeItem({
// Archived rows hold their in-place grayed slot, so manual reorder cannot
// move them. Pinned rows drag within the pinned block: the browser gates
// their drop targets to fellow pinned rows.
const draggable = drag !== undefined && !row.blank && !row.archived
// Renaming from the title and starting a drag ask the row's own access snapshot; a row that
// cannot start a drag still accepts drops from rows that can.
const renamable = useSessionAccess(node.id, 'session/rename')
const reorderable = useSessionAccess(node.id, 'workspace/insertSessionBefore')
const draggable = drag !== undefined && reorderable && !row.blank && !row.archived
const [menuOpen, setMenuOpen] = useState(false)
const dragPreview = useRowDragPreview(css.dragPreview as string)
// The menu's open state, bound into the row entries' `useMenuOpenState` hook.
@@ -670,7 +722,7 @@ export function SessionNodeItem({
// One shared function and options: repeated presses add one listener.
window.addEventListener('blur', disarmRename, { once: true })
}}
onDoubleClick={row.blank
onDoubleClick={row.blank || !renamable
? undefined
: (e) => {
e.stopPropagation()
@@ -4,7 +4,7 @@ import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-l
import type { ComponentProps } from 'react'
import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { HoverCardGroup, MenuItemButton, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
@@ -12,7 +12,7 @@ import type { MenuOpenState, SessionRowOwnerProps } from '../src/client/contract
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { RowDragProps } from '../src/client/rows/Rows.tsx'
import {
ProjectRowItem, SearchResultItem, SessionNodeItem as SessionNodeItemComponent,
ProjectRowItem as ProjectRowItemComponent, SearchResultItem, SessionNodeItem as SessionNodeItemComponent,
} from '../src/client/rows/Rows.tsx'
import type { GroupNode, SearchResultNode, SessionNode } from '../src/client/tree.ts'
import { en, zh } from '../src/client/locales.ts'
@@ -34,6 +34,7 @@ type RowSlotName =
| 'sidebar.session.row.hover'
type RowRenderSlot = PropsRenderSlots<RowSlotName>['renderSlot']
type SessionNodeItemProps = ComponentProps<typeof SessionNodeItemComponent>
type ProjectRowItemProps = ComponentProps<typeof ProjectRowItemComponent>
const renderNoRowEntries: RowRenderSlot = () => null
@@ -48,10 +49,24 @@ function realDoubleClick(el: Element): void {
fireEvent.doubleClick(el)
}
function SessionNodeItem({ renderSlot = renderNoRowEntries, onRenameRequest = () => {}, ...props }: Omit<
SessionNodeItemProps, 'renderSlot' | 'onRenameRequest'
> & Partial<Pick<SessionNodeItemProps, 'renderSlot' | 'onRenameRequest'>>) {
return <SessionNodeItemComponent {...props} renderSlot={renderSlot} onRenameRequest={onRenameRequest} />
function SessionNodeItem({
renderSlot = renderNoRowEntries, onRenameRequest = () => {}, useSessionAccess = allowAllAccess.useSessionAccess, ...props
}: Omit<
SessionNodeItemProps, 'renderSlot' | 'onRenameRequest' | 'useSessionAccess'
> & Partial<Pick<SessionNodeItemProps, 'renderSlot' | 'onRenameRequest' | 'useSessionAccess'>>) {
return (
<SessionNodeItemComponent
{...props} renderSlot={renderSlot} onRenameRequest={onRenameRequest} useSessionAccess={useSessionAccess}
/>
)
}
// Direct header specs hold every access answer unless the case refuses one.
function ProjectRowItem({
useRemoteAccess = allowAllAccess.useRemoteAccess, useWorkspaceAccess = allowAllAccess.useWorkspaceAccess, ...props
}: Omit<ProjectRowItemProps, 'useRemoteAccess' | 'useWorkspaceAccess'>
& Partial<Pick<ProjectRowItemProps, 'useRemoteAccess' | 'useWorkspaceAccess'>>) {
return <ProjectRowItemComponent {...props} useRemoteAccess={useRemoteAccess} useWorkspaceAccess={useWorkspaceAccess} />
}
/** Half detection reads the row rect; jsdom rects are all-zero by default. */
@@ -617,6 +632,82 @@ describe('workspace browser rows', () => {
expect(screen.queryByRole('menu')).toBeNull()
})
it('workspace row offers only the actions its Workspace access snapshot allows', () => {
const group: GroupNode = {
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
}
const allowed = new Set<string>(['workspace/rename'])
const useWorkspaceAccess = vi.fn((_workspaceId: WorkspaceId, endpoint: string) => allowed.has(endpoint))
const drag = { start: vi.fn(), end: vi.fn() }
const props = {
group, onToggle: vi.fn(), onCreate: vi.fn(), actions: { rename: vi.fn(), delete: vi.fn() }, drag, t,
}
const view = render(<ProjectRowItem {...props} useWorkspaceAccess={useWorkspaceAccess} />)
expect(useWorkspaceAccess).toHaveBeenCalledWith(wid('project'), 'session/create')
// No New Session button, no drag handle, and a menu holding only the allowed rename row.
expect(screen.queryByRole('button', { name: '在“Project”中新建会话' })).toBeNull()
expect(screen.getByRole('treeitem').getAttribute('draggable')).toBe('false')
fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
expect(screen.getByRole('menuitem', { name: '重命名' })).toBeTruthy()
expect(screen.queryByRole('menuitem', { name: '删除工作区' })).toBeNull()
fireEvent.keyDown(document, { key: 'Escape' })
// Without rename either, the header carries no menu at all.
allowed.clear()
view.rerender(<ProjectRowItem {...props} useWorkspaceAccess={useWorkspaceAccess} />)
expect(screen.queryByRole('button', { name: '工作区“Project”的操作' })).toBeNull()
// Every answer allowed restores the button, the full menu, and the drag handle.
for (const endpoint of ['session/create', 'workspace/rename', 'workspace/delete', 'workspace/insertBefore']) {
allowed.add(endpoint)
}
view.rerender(<ProjectRowItem {...props} useWorkspaceAccess={useWorkspaceAccess} />)
expect(screen.getByRole('treeitem').getAttribute('draggable')).toBe('true')
fireEvent.click(screen.getByRole('button', { name: '在“Project”中新建会话' }))
expect(props.onCreate).toHaveBeenCalledOnce()
})
it('ungrouped bucket asks the global resource whether it may start a Session', () => {
const group: GroupNode = {
key: 'ungrouped', workspaceId: undefined, cwd: undefined, createdAt: undefined, label: '',
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
}
const useRemoteAccess = vi.fn((action: string) => action !== 'session/create')
const useWorkspaceAccess = vi.fn(() => true)
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} t={t}
useRemoteAccess={useRemoteAccess} useWorkspaceAccess={useWorkspaceAccess} />)
expect(useRemoteAccess).toHaveBeenCalledWith('session/create')
expect(useWorkspaceAccess).not.toHaveBeenCalled()
expect(screen.queryByRole('button', { name: /新建会话/ })).toBeNull()
})
it('session row drags and renames from its title only while its access snapshot allows each', () => {
const node: SessionNode = {
id: sid('s1'), title: 'One', blank: false, running: false,
runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: false,
}
const drag: RowDragProps = {
active: false, marker: null, start: vi.fn(), end: vi.fn(), hover: vi.fn(), drop: vi.fn(),
}
const onRenameRequest = vi.fn()
const useSessionAccess = vi.fn((_sessionId: SessionId, _endpoint: string) => false)
const view = render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()} drag={drag}
onRenameRequest={onRenameRequest} useSessionAccess={useSessionAccess} t={t} />)
expect(useSessionAccess).toHaveBeenCalledWith(sid('s1'), 'session/rename')
expect(useSessionAccess).toHaveBeenCalledWith(sid('s1'), 'workspace/insertSessionBefore')
expect(screen.getByRole('treeitem').getAttribute('draggable')).toBe('false')
fireEvent.doubleClick(screen.getByText('One'))
expect(onRenameRequest).not.toHaveBeenCalled()
view.rerender(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()} drag={drag}
onRenameRequest={onRenameRequest} useSessionAccess={() => true} t={t} />)
expect(screen.getByRole('treeitem').getAttribute('draggable')).toBe('true')
// A browser rename arms on the first press and fires on the double-click; the helper states both.
realDoubleClick(screen.getByText('One'))
expect(onRenameRequest).toHaveBeenCalledWith(sid('s1'), 'One')
})
it('workspace hover card shows its details and copies the full directory path', async () => {
vi.useFakeTimers()
const writeText = vi.fn(async () => {})