From 55deb7a6c68886d04d1ae006dd4f5acdaf121c43 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Thu, 22 Oct 2026 12:04:00 +0800 Subject: [PATCH] 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. --- packages/client/ui-workspace/README.i18n.yaml | 20 +-- .../ui-workspace/src/client/rows/Rows.tsx | 122 +++++++++++++----- .../ui-workspace/tests/rows.client.spec.tsx | 103 ++++++++++++++- 3 files changed, 194 insertions(+), 51 deletions(-) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 7be334dadb..3d826e05b2 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 291c3f7ff4..2e4f41a602 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -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 + +/** 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 + ? + : +} + +/** 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 +} + +/** 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 +} + +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: }, - { id: 'delete', label: t('delete.workspace'), icon: , danger: true }, + ...access.rename ? [{ id: 'rename', label: t('rename'), icon: }] : [], + ...access.delete ? [{ id: 'delete', label: t('delete.workspace'), icon: , danger: true }] : [], ] const ownRow = (
{label} - {actions !== undefined && ( + {actions !== undefined && workspaceMenuItems.length > 0 && ( { setMenuOpen(false) }} @@ -297,17 +340,19 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog )} /> )} - - - + {access.create && ( + + + + )}
) @@ -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 & 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() diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index a77c230456..b2c177d800 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -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['renderSlot'] type SessionNodeItemProps = ComponentProps +type ProjectRowItemProps = ComponentProps 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>) { - return +function SessionNodeItem({ + renderSlot = renderNoRowEntries, onRenameRequest = () => {}, useSessionAccess = allowAllAccess.useSessionAccess, ...props +}: Omit< + SessionNodeItemProps, 'renderSlot' | 'onRenameRequest' | 'useSessionAccess' +> & Partial>) { + return ( + + ) +} + +// Direct header specs hold every access answer unless the case refuses one. +function ProjectRowItem({ + useRemoteAccess = allowAllAccess.useRemoteAccess, useWorkspaceAccess = allowAllAccess.useWorkspaceAccess, ...props +}: Omit + & Partial>) { + return } /** 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(['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() + 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() + 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() + 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() + 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() + 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( 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 () => {})