diff --git a/docs/features/assistant-workbar/prd.md b/docs/features/assistant-workbar/prd.md index 3030f32b..adb36c19 100644 --- a/docs/features/assistant-workbar/prd.md +++ b/docs/features/assistant-workbar/prd.md @@ -388,8 +388,11 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发 当前项目目录的文件浏览与 Git 交互遵循以下规则: -- FR-W1:顶部保留当前浏览目录及可返回父目录的路径导航,根目录使用图标入口,完整根路径通过可访问名称和提示提供,不重复显示项目名称或独立路径标题;工具栏依次提供刷新、新建文件、新建目录。 - 路径导航与操作工具栏分行排列;操作使用带名称和提示的紧凑图标按钮,在 300px 工作栏内保持单行。 +- FR-W1:顶部同一行放置“文件 / Git 工作区”视图切换和右侧刷新;非 Git 目录仍保留刷新。 + 下一行只提供当前视图的工具:文件视图提供新建文件、新建目录,Git 视图提供分支、Fetch 和更改显示方式, + 不在 Git 视图重复目录导航或新建文件操作。操作使用带名称和提示的紧凑图标按钮。 + 文件视图在根目录不显示孤立的文件夹图标或空路径行;进入子目录后提供“项目目录名 / 子目录” + 文字路径导航和返回父目录按钮。根目录文字可点击返回,提示显示完整根路径;返回根目录后焦点移至顶部刷新。 长目录名单行省略,完整路径通过提示查看;多级路径在导航内部横向滚动,不撑宽工作栏。 选中目录时在该目录创建,选中文件时在其父目录创建,未选择时在当前浏览目录创建。 无可用目录时隐藏无关操作。 @@ -413,6 +416,8 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发 不自动 force、reset、discard 或 stash,不因浏览分支而自动 Fetch。 分支触发器复用共享模型选择按钮,带分支图标和展开箭头;Fetch 使用共享次按钮,二者在 紧凑工作栏内保持同一行,长分支名省略并提供完整名称提示。 + 展开的分支选择面板不超过 360px 或工作栏可用宽度;搜索使用共享表单标签、输入框与主题样式, + 分支列表独立限高滚动,创建按钮以紧凑次操作放在列表下方。Escape 收起面板并将焦点返回分支触发器。 - FR-W5:未提交更改支持列表和目录树切换;两种视图均可打开单文件 Diff。 - FR-W6:Git 工作区底部提供默认收起的提交历史区,独立滚动,可调整高度。按当前分支 分页读取提交,展示摘要、作者、时间、短 OID 和 refs。选中提交显示该次修改文件, diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index 8b932f94..65bc22d9 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -1,5 +1,29 @@ # 工作栏实现与验证进度 +## 2026-09-10 工作区布局与分支输入样式 + +- 按 [FR-W1、FR-W4](./prd.md#76-工作区) 将视图切换和刷新合并到顶部行, + 文件与 Git 工具按视图显示;移除根目录的孤立图标及空导航行,子目录保留文字路径导航。 + 分支搜索复用共享 `field`,面板限宽、分支列表限高滚动,创建按钮使用紧凑次操作。 +- 新增布局、子目录返回、焦点恢复、分支搜索/切换/创建回归;工作区、设置界面、设置存储、 + IPC 和 Sidebar 五个测试文件共 354 项通过。另确认安全与数据的默认 `toolApproval` 为 + `always`,对应“Execute 自动授权已启用的工具”;已有明确保存的禁止策略仍保留。 + 本轮没有修改授权策略或默认值。 +- 当前构建的 Windows Electron 使用独立 profile 和临时 Git 项目,实际 App → Preload → + Main 路径通过分支搜索、切换、创建和子目录新建文件;磁盘与 Git 分支结果均确认。 + 文件/Git 切换保留浏览目录,返回根目录后导航行移除、焦点回到刷新;Escape 收起分支面板并恢复焦点。 + 真实安全与数据页面的默认选中项也确认为自动授权。 +- 浅色与深色下,将实际工作栏布局宽度设为 300、420、650px 后均无工作区横向溢出。 + 输入框为共享 13px 正文、标签 11px,分支面板最大 360px;15 个分支时列表可在 240px 内滚动。 + 这些是布局检查,不作为分栏拖拽或原生浏览器鼠标交互的验证。 +- `npm run build:bundle`、`npm run typecheck`、`npm run lint` 通过。 + 整库 `npm test` 为 3843 项通过、1 项失败、66 项跳过;失败项是未修改的 + `opencode-runtime-lifecycle.test.ts` 事件流关闭时序断言,单独复跑通过。 + 未再次运行整库,不将此结果记为整库全绿。 +- 本轮产品改动仅为共享 Renderer 工作区布局,未改 Main/Agent 的工作区命令、 + desktop-to-Agent 协议或 Runtime 执行逻辑;远程项目继续使用同一组件和既有回调, + 本轮未做新的 Host 实测。无外部模型调用,所属测试应用、浏览器会话和调试监听已关闭。 + ## 2026-09-10 提交审查修复 - [FR-W3、FR-W6](./prd.md#76-工作区):同一目录项可仅修改大小写,仍拒绝覆盖其他文件、 diff --git a/src/main/runtime-settings-store.test.ts b/src/main/runtime-settings-store.test.ts index b8716809..7d287d87 100644 --- a/src/main/runtime-settings-store.test.ts +++ b/src/main/runtime-settings-store.test.ts @@ -80,6 +80,16 @@ afterEach(async () => { }) describe('RuntimeSettingsStore', () => { + it('defaults to automatic Execute tool authorization and preserves an explicitly saved deny policy', async () => { + const { store, filePath } = await createStore() + await expect(store.getPublicSettings()).resolves.toMatchObject({ toolApproval: 'always' }) + await expect(store.getPolicySettings()).resolves.toMatchObject({ toolApproval: 'always' }) + await store.update(settings({ toolApproval: 'policy' })) + const reloaded = new RuntimeSettingsStore(filePath, cipher, {}) + await expect(reloaded.getPublicSettings()).resolves.toMatchObject({ toolApproval: 'policy' }) + await expect(reloaded.getPolicySettings()).resolves.toMatchObject({ toolApproval: 'policy' }) + }) + it('persists default references separately from fixed profiles and resolves each settings generation', async () => { const { store, filePath } = await createStore() const firstId = '00000000-0000-4000-8000-000000000041' diff --git a/src/renderer/src/SettingsPanel.test.tsx b/src/renderer/src/SettingsPanel.test.tsx index 4e93f941..ddde3c79 100644 --- a/src/renderer/src/SettingsPanel.test.tsx +++ b/src/renderer/src/SettingsPanel.test.tsx @@ -2789,6 +2789,7 @@ describe('SettingsPanel runtime files', () => { const policy = await screen.findByLabelText( '直连模型工具安全策略' ) + expect(policy).toHaveValue('always') expect( within(policy).getByRole('option', { name: 'Execute 自动授权已启用的工具' diff --git a/src/renderer/src/WorkspaceFilesPanel.test.tsx b/src/renderer/src/WorkspaceFilesPanel.test.tsx index 97d73faa..cd4314ea 100644 --- a/src/renderer/src/WorkspaceFilesPanel.test.tsx +++ b/src/renderer/src/WorkspaceFilesPanel.test.tsx @@ -15,6 +15,104 @@ afterEach(async () => { }) describe('WorkspaceFilesPanel', () => { + it('groups view switching and refresh, and only shows actions for the active view', async () => { + const onRefresh = vi.fn(async () => undefined) + const { container } = render( ({ path: '', entries: [], truncated: false }))} + onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />) + const createFile = await screen.findByRole('button', { name: '新建文件' }) + const header = container.querySelector('.workspace-files__header')! + expect(within(header).getByRole('button', { name: '文件' })).toBeInTheDocument() + expect(within(header).getByRole('button', { name: 'Git 工作区' })).toBeInTheDocument() + expect(within(header).getByRole('button', { name: '刷新工作区文件' })).toBeInTheDocument() + expect(header).not.toContainElement(createFile) + expect(screen.queryByRole('navigation', { name: '路径' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'demo' })).not.toBeInTheDocument() + fireEvent.click(within(header).getByRole('button', { name: 'Git 工作区' })) + expect(await screen.findByRole('button', { name: 'main' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '新建文件' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: '新建目录' })).not.toBeInTheDocument() + fireEvent.click(within(header).getByRole('button', { name: '刷新工作区文件' })) + await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce()) + fireEvent.click(within(header).getByRole('button', { name: '文件' })) + expect(screen.getByRole('button', { name: '新建文件' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Fetch' })).not.toBeInTheDocument() + }) + + it.each(['D:\\workspace\\demo', '/workspace/demo'])('keeps text breadcrumbs only in subdirectories of %s', async (rootPath) => { + const onListDirectory = vi.fn(async (path: string) => ({ + path, truncated: false, + entries: path ? [{ name: 'guide.md', path: 'docs/guide.md', type: 'file' as const }] + : [{ name: 'docs', path: 'docs', type: 'directory' as const }] + })) + render() + fireEvent.click(await screen.findByRole('button', { name: 'docs 的更多操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '浏览此目录' })) + await screen.findByRole('button', { name: 'guide.md' }) + const nav = screen.getByRole('navigation', { name: '路径' }) + expect(within(nav).getByRole('button', { name: 'demo' })).toHaveAttribute('title', rootPath) + expect(within(nav).getByRole('button', { name: 'docs' })).toHaveAttribute('aria-current', 'location') + expect(screen.getByRole('button', { name: '新建文件' })).toHaveAttribute('title', '新建文件: docs') + fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' })) + expect(screen.queryByRole('navigation', { name: '路径' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '文件' })) + expect(screen.getByRole('button', { name: 'docs' })).toHaveAttribute('aria-current', 'location') + fireEvent.click(screen.getByRole('button', { name: 'demo' })) + expect(screen.queryByRole('navigation', { name: '路径' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '刷新工作区文件' })).toHaveFocus() + expect(screen.getByRole('button', { name: '新建文件' })).toHaveAttribute('title', '新建文件: /') + fireEvent.click(await screen.findByRole('button', { name: 'docs 的更多操作' })) + fireEvent.click(screen.getByRole('menuitem', { name: '浏览此目录' })) + fireEvent.click(screen.getByRole('button', { name: '返回父目录' })) + expect(screen.queryByRole('navigation', { name: '路径' })).not.toBeInTheDocument() + }) + + it('styles branch search as a shared field and preserves filtering, switching and creation', async () => { + let current = 'main' + const manage = vi.fn(async (_project: string, action: WorkspaceManagementAction): Promise => { + if (action.kind === 'switchBranch' || action.kind === 'createBranch') { + current = action.branch + return { kind: 'done' } + } + return { kind: 'branches', current, branches: [ + { name: 'main', remote: false }, + { name: 'origin/topic', remote: true } + ] } + }) + vi.stubGlobal('goodbuddy', { workspace: { manage } }) + const onRefresh = vi.fn(async () => undefined) + render( ({ path: '', entries: [], truncated: false }))} + onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />) + fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' })) + const trigger = await screen.findByRole('button', { name: 'main' }) + fireEvent.click(trigger) + const search = screen.getByRole('textbox', { name: '搜索本地或远程分支 / 新分支名称' }) + expect(search.closest('label')).toHaveClass('field') + expect(search.previousElementSibling?.tagName).toBe('SPAN') + expect(search).toHaveFocus() + expect(screen.getByRole('button', { name: '创建并切换分支' })).toBeDisabled() + fireEvent.change(search, { target: { value: 'topic' } }) + expect(screen.queryByRole('button', { name: /main\s*本地/ })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /origin\/topic\s*远程/ })) + await waitFor(() => expect(manage).toHaveBeenCalledWith('project', { kind: 'switchBranch', branch: 'origin/topic', remote: true })) + await waitFor(() => expect(onRefresh).toHaveBeenCalledOnce()) + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + expect(trigger).toHaveFocus() + fireEvent.click(trigger) + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new-topic' } }) + fireEvent.click(screen.getByRole('button', { name: '创建并切换分支' })) + await waitFor(() => expect(manage).toHaveBeenCalledWith('project', { kind: 'createBranch', branch: 'new-topic' })) + await waitFor(() => expect(onRefresh).toHaveBeenCalledTimes(2)) + fireEvent.click(trigger) + fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Escape' }) + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + expect(trigger).toHaveFocus() + expect(manage.mock.calls.some(([, action]) => action.kind === 'fetch')).toBe(false) + }) + it.each([false, true])('keeps Git operations usable when a task refresh occurs during history pagination (failure: %s)', async (fail) => { const head = 'a'.repeat(40) const initial = { oid: head, subject: 'Latest commit', author: 'Author', time: '2026-09-09T10:00:00Z', refs: 'HEAD -> main' } @@ -105,6 +203,10 @@ describe('WorkspaceFilesPanel', () => { expect(css).toMatch(/\.workspace-files__view-switch\s*\{[^}]*align-self: flex-start/) expect(css).toMatch(/\.workspace-git__toolbar > \.segmented-control\s*\{[^}]*width: max-content;[^}]*border: 0/) expect(css).toMatch(/\.workspace-git__branch-trigger\s*\{[^}]*width: auto;[^}]*height: 32px;[^}]*flex: 0 1 auto;[^}]*text-align: left/) + expect(css).toMatch(/\.workspace-files__header\s*\{[^}]*justify-content: space-between/) + expect(css).toMatch(/\.workspace-git__branches\s*\{[^}]*width: min\(100%, 360px\);[^}]*border-radius: var\(--radius-control\)/) + expect(css).toMatch(/\.workspace-git__branch-list\s*\{[^}]*max-height: 240px;[^}]*overflow: auto/) + expect(css).toMatch(/\.workspace-git__create-branch\s*\{[^}]*justify-self: start/) }) it.each([ @@ -294,7 +396,7 @@ describe('WorkspaceFilesPanel', () => { /> ) - expect(screen.getByRole('button', { name: '当前工作区' })).toHaveAttribute('aria-current', 'location') + expect(screen.queryByRole('navigation', { name: '路径' })).not.toBeInTheDocument() fireEvent.click(await screen.findByRole('button', { name: 'docs' })) fireEvent.click( await screen.findByRole('button', { name: 'guide.md' }) @@ -384,7 +486,7 @@ describe('WorkspaceFilesPanel', () => { await changeUiLocale('en-US') expect( - await screen.findByRole('button', { name: 'Current workspace' }) + await screen.findByRole('button', { name: 'Files' }) ).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Git Workspace' })) expect(screen.getByText('M')).toBeInTheDocument() diff --git a/src/renderer/src/WorkspaceFilesPanel.tsx b/src/renderer/src/WorkspaceFilesPanel.tsx index 3eaad1ef..af7eecb6 100644 --- a/src/renderer/src/WorkspaceFilesPanel.tsx +++ b/src/renderer/src/WorkspaceFilesPanel.tsx @@ -14,7 +14,6 @@ import { Trash2, FileText, Folder, - FolderRoot, FolderOpen } from 'lucide-react' import { @@ -151,6 +150,7 @@ export function WorkspaceFilesPanel({ const activeView = showGit ? view : 'files' const [selected, setSelected] = useState<{ projectId?: string; path: string }>() const panelRef = useRef(null) + const refreshRef = useRef(null) const backRef = useRef(null) const returnContext = useRef<{ scrollTop: number; scrollLeft: number; trigger: HTMLButtonElement } | undefined>(undefined) const requestGeneration = useRef(0) @@ -352,6 +352,7 @@ export function WorkspaceFilesPanel({ const browse = (path: string): void => { setBrowsedPath(path); setCreateTarget(path) void loadDirectory(path, requestGeneration.current) + if (!path) refreshRef.current?.focus({ preventScroll: true }) } const entryMenu = (entry: WorkspaceDirectoryEntry): React.JSX.Element => ( } +
+ {showGit &&
} + +
+ {activeView === 'files' && root &&
+ + +
} + {activeView === 'files' && browsedPath && - {root &&
- - - -
} - {showGit &&
} + } {branchOpen &&
{ if (event.key === 'Escape') { setBranchOpen(false); branchTrigger.current?.focus() } }}> - - -
{branches?.branches.filter((branch) => branch.name.toLowerCase().includes(query.toLowerCase())).map((branch) => )}
+ +
{branches?.branches.filter((branch) => branch.name.toLowerCase().includes(query.toLowerCase())).map((branch) => )}
+
} {children} diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 3f9e8586..586feef3 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -4399,6 +4399,7 @@ button > svg { min-width: 0; align-items: center; gap: var(--space-1); + font-size: var(--font-body); } .workspace-files__breadcrumb-parts { @@ -4438,6 +4439,7 @@ button > svg { gap: var(--space-1); } +.workspace-files__header .icon-button, .workspace-files__actions .icon-button, .workspace-files__breadcrumbs > .icon-button { width: 32px; @@ -17375,6 +17377,14 @@ details.settings-section > :not(summary) + :not(summary) { .workspace-files__directory-picker .remote-directory-picker__content { max-height: 240px; } .workspace-git__working { display: flex; flex-direction: column; min-height: 0; gap: var(--space-2); } .workspace-git__working[hidden] { display: none; } +.workspace-files__header { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: var(--space-2); +} +.workspace-files__header > .icon-button { margin-left: auto; } .workspace-files__view-switch { align-self: flex-start; max-width: 100%; } .workspace-files__view-switch > .segmented-control, .workspace-git__toolbar > .segmented-control { @@ -17401,6 +17411,7 @@ details.settings-section > :not(summary) + :not(summary) { min-width: 0; align-items: center; flex-wrap: wrap; + justify-content: space-between; gap: var(--space-2); } .workspace-git__repository-actions { @@ -17425,16 +17436,38 @@ details.settings-section > :not(summary) + :not(summary) { flex: 0 0 32px; border-color: var(--border-default); } -.workspace-git__branches { display: grid; min-width: 0; gap: var(--space-2); padding-bottom: var(--space-2); } -.workspace-git__branches input { min-width: 0; width: 100%; } +.workspace-git__branches { + display: grid; + width: min(100%, 360px); + min-width: 0; + gap: var(--space-2); + padding: var(--space-3); + border: 1px solid var(--border-default); + border-radius: var(--radius-control); + background: var(--surface-subtle); + color: var(--text-primary); + font-size: var(--font-body); +} +.workspace-git__branches .field { + min-width: 0; + gap: var(--space-2); +} +.workspace-git__branches .field > span { color: var(--text-secondary); } +.workspace-git__branches input { min-width: 0; } +.workspace-git__branch-list { + display: grid; + min-width: 0; + max-height: 240px; + overflow: auto; +} +.workspace-git__branch-list:empty { display: none; } +.workspace-git__create-branch { justify-self: start; max-width: 100%; } .workspace-git__history-toggle { margin-top: var(--space-2); font-weight: 650; } -.workspace-git__branches > div { display: grid; max-height: 240px; overflow: auto; } -.workspace-git__branches label, .workspace-git__commit { display: grid; text-align: left; } .workspace-git__history { display: flex; flex-direction: column; flex: 0 0 auto; min-height: 120px; max-height: 600px; border-top: 1px solid var(--border-default); } .workspace-git__history[hidden] { display: none; } .workspace-git__history > [role="separator"] { height: var(--space-2); flex: 0 0 auto; cursor: ns-resize; touch-action: none; } .workspace-git__history-scroll { overflow: auto; min-height: 0; } -.workspace-git__commit { width: 100%; padding: var(--space-2); grid-template-columns: minmax(0, 1fr); gap: var(--space-1); border-bottom: 1px solid var(--border-subtle); } +.workspace-git__commit { display: grid; width: 100%; padding: var(--space-2); grid-template-columns: minmax(0, 1fr); gap: var(--space-1); border-bottom: 1px solid var(--border-subtle); text-align: left; } .workspace-git__commit strong, .workspace-git__commit small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .workspace-git__commit span, .workspace-git__commit small { color: var(--text-secondary); font-size: var(--font-caption); } @media (hover: none), (pointer: coarse) { .workspace-files__more { opacity: 1; } }