fix: simplify workspace controls and branch search

Group the workspace view switch and refresh action in one header, show only
file or Git actions for the active view, and remove the isolated root icon.
Subdirectory breadcrumbs retain root navigation and restore keyboard focus.
Use the shared form style for branch search in a bounded, scrollable panel.

Confirm that fresh settings already default to automatic Execute tool
authorization, and add regression coverage without changing saved policies.
Update the workbar design and record isolated production Electron validation.

Validation: 354 focused tests, typecheck, lint and development bundle passed.
The full suite passed 3843 tests and skipped 66; an unchanged OpenCode stream
lifecycle timing test failed once and passed its isolated rerun.

Release note: 整理工作区文件与 Git 工具栏,移除孤立的根目录图标并保留子目录导航;分支搜索框、文字和按钮统一样式,宽窄工作栏下均保持紧凑布局。
This commit is contained in:
mesalogo
2026-09-10 13:54:34 +08:00
parent c037d7fb20
commit 0eb5205b1c
8 changed files with 205 additions and 26 deletions
+10
View File
@@ -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'
+1
View File
@@ -2789,6 +2789,7 @@ describe('SettingsPanel runtime files', () => {
const policy = await screen.findByLabelText(
'直连模型工具安全策略'
)
expect(policy).toHaveValue('always')
expect(
within(policy).getByRole('option', {
name: 'Execute 自动授权已启用的工具'
+104 -2
View File
@@ -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(<WorkspaceFilesPanel projectId="project" isRepository rootPath="D:\\workspace\\demo"
changedFiles={[]} onRefresh={onRefresh}
onListDirectory={vi.fn(async () => ({ path: '', entries: [], truncated: false }))}
onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />)
const createFile = await screen.findByRole('button', { name: '新建文件' })
const header = container.querySelector<HTMLElement>('.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(<WorkspaceFilesPanel projectId="project" isRepository rootPath={rootPath} changedFiles={[]}
onListDirectory={onListDirectory} onLoadDiff={vi.fn()} onOpenFile={vi.fn()} />)
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<WorkspaceManagementResult> => {
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(<WorkspaceFilesPanel projectId="project" isRepository changedFiles={[]} onRefresh={onRefresh}
onListDirectory={vi.fn(async () => ({ 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()
+18 -14
View File
@@ -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<HTMLDivElement>(null)
const refreshRef = useRef<HTMLButtonElement>(null)
const backRef = useRef<HTMLButtonElement>(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 => (
<button className="icon-button workspace-files__more" type="button"
@@ -450,6 +451,7 @@ export function WorkspaceFilesPanel({
}
const root = listings[browsedPath]
const rootName = rootPath?.replaceAll('\\', '/').split('/').filter(Boolean).at(-1) || t('files.currentWorkspace')
const renderPatch = (patch: string): React.JSX.Element => (
<pre className="assistant-sidebar__diff workspace-files__diff">
{patch.split('\n').map((line, index) => (
@@ -487,22 +489,24 @@ export function WorkspaceFilesPanel({
</>}
</section>}
<div className="workspace-files__list" hidden={showingDiff}>
<nav className="workspace-files__breadcrumbs" aria-label={t('management.path')}>
{browsedPath && <button className="icon-button" type="button" aria-label={t('management.parent')} title={t('management.parent')} onClick={() => browse(browsedPath.split('/').slice(0, -1).join('/'))}><ChevronLeft size={14} /></button>}
<div className="workspace-files__header">
{showGit && <div className="workspace-files__view-switch"><SegmentedControl ariaLabel={t('files.view')} value={activeView} onChange={setView} options={[
{ value: 'files', label: t('files.filesView') },
{ value: 'changes', label: t('management.gitWorkspace') }
]} /></div>}
<button ref={refreshRef} className="icon-button" type="button" disabled={refreshing} aria-label={t('sidebar.workspace.refreshAriaLabel')} title={t('sidebar.workspace.refresh')} onClick={() => void refresh()}><RefreshCw size={14} aria-hidden="true" /></button>
</div>
{activeView === 'files' && root && <div className="workspace-files__toolbar workspace-files__actions">
<button className="icon-button" type="button" aria-label={t('management.createFile')} title={`${t('management.createFile')}: ${createTarget || '/'}`} onClick={() => setDialog({ kind: 'createFile', path: createTarget })}><FilePlus size={14} aria-hidden="true" /></button>
<button className="icon-button" type="button" aria-label={t('management.createDirectory')} title={`${t('management.createDirectory')}: ${createTarget || '/'}`} onClick={() => setDialog({ kind: 'createDirectory', path: createTarget })}><FolderPlus size={14} aria-hidden="true" /></button>
</div>}
{activeView === 'files' && browsedPath && <nav className="workspace-files__breadcrumbs" aria-label={t('management.path')}>
<button className="icon-button" type="button" aria-label={t('management.parent')} title={t('management.parent')} onClick={() => browse(browsedPath.split('/').slice(0, -1).join('/'))}><ChevronLeft size={14} aria-hidden="true" /></button>
<div className="workspace-files__breadcrumb-parts">
<button className="icon-button" type="button" onClick={() => browse('')} aria-label={rootPath || t('files.currentWorkspace')} title={rootPath || t('files.currentWorkspace')} aria-current={!browsedPath ? 'location' : undefined}><FolderRoot size={14} aria-hidden="true" /></button>
<button type="button" onClick={() => browse('')} title={rootPath || t('files.currentWorkspace')}>{rootName}</button>
{browsedPath.split('/').filter(Boolean).map((part, index, parts) => <button type="button" key={index} title={parts.slice(0, index + 1).join('/')} aria-current={index === parts.length - 1 ? 'location' : undefined} onClick={() => browse(parts.slice(0, index + 1).join('/'))}>{part}</button>)}
</div>
</nav>
{root && <div className="workspace-files__toolbar workspace-files__actions">
<button className="icon-button" type="button" disabled={refreshing} aria-label={t('sidebar.workspace.refreshAriaLabel')} title={t('sidebar.workspace.refresh')} onClick={() => void refresh()}><RefreshCw size={14} /></button>
<button className="icon-button" type="button" aria-label={t('management.createFile')} title={`${t('management.createFile')}: ${createTarget || '/'}`} onClick={() => setDialog({ kind: 'createFile', path: createTarget })}><FilePlus size={14} /></button>
<button className="icon-button" type="button" aria-label={t('management.createDirectory')} title={`${t('management.createDirectory')}: ${createTarget || '/'}`} onClick={() => setDialog({ kind: 'createDirectory', path: createTarget })}><FolderPlus size={14} /></button>
</div>}
{showGit && <div className="workspace-files__view-switch"><SegmentedControl ariaLabel={t('files.view')} value={activeView} onChange={setView} options={[
{ value: 'files', label: t('files.filesView') },
{ value: 'changes', label: t('management.gitWorkspace') }
]} /></div>}
</nav>}
<div hidden={activeView !== 'changes'}>
{showGit && <WorkspaceGitTools key={projectId} projectId={projectId} refreshToken={refreshToken} onRefresh={refresh} viewControl={
<SegmentedControl ariaLabel={t('management.changedView')} value={changeView} onChange={setChangeView} options={[{ value: 'list', label: t('management.list') }, { value: 'tree', label: t('management.tree') }]} />
+3 -3
View File
@@ -88,9 +88,9 @@ export function WorkspaceGitTools({ projectId, refreshToken, onRefresh, viewCont
{viewControl}
</div>
{branchOpen && <div className="workspace-git__branches" onKeyDown={(event) => { if (event.key === 'Escape') { setBranchOpen(false); branchTrigger.current?.focus() } }}>
<label>{t('management.searchBranch')}<input autoFocus value={query} onChange={(event) => setQuery(event.target.value)} /></label>
<button className="secondary-button" type="button" disabled={busy || !query.trim()} onClick={() => void run({ kind: 'createBranch', branch: query })}>{t('management.createBranch')}</button>
<div>{branches?.branches.filter((branch) => branch.name.toLowerCase().includes(query.toLowerCase())).map((branch) => <button className="workspace-files__changed-row" type="button" key={`${branch.remote}:${branch.name}`} disabled={busy} aria-current={!branch.remote && branches.current === branch.name ? 'true' : undefined} onClick={() => void run({ kind: 'switchBranch', branch: branch.name, remote: branch.remote })}><GitBranch size={14} aria-hidden="true" /><span title={branch.name}>{branch.name}</span><small>{t(branch.remote ? 'management.remote' : 'management.local')}</small></button>)}</div>
<label className="field"><span>{t('management.searchBranch')}</span><input autoFocus value={query} onChange={(event) => setQuery(event.target.value)} /></label>
<div className="workspace-git__branch-list">{branches?.branches.filter((branch) => branch.name.toLowerCase().includes(query.toLowerCase())).map((branch) => <button className="workspace-files__changed-row" type="button" key={`${branch.remote}:${branch.name}`} disabled={busy} aria-current={!branch.remote && branches.current === branch.name ? 'true' : undefined} onClick={() => void run({ kind: 'switchBranch', branch: branch.name, remote: branch.remote })}><GitBranch size={14} aria-hidden="true" /><span title={branch.name}>{branch.name}</span><small>{t(branch.remote ? 'management.remote' : 'management.local')}</small></button>)}</div>
<button className="secondary-button workspace-git__create-branch" type="button" disabled={busy || !query.trim()} onClick={() => void run({ kind: 'createBranch', branch: query })}>{t('management.createBranch')}</button>
</div>}
{children}
<button className="workspace-files__changed-row workspace-git__history-toggle" type="button" aria-expanded={historyOpen} onClick={() => { setHistoryOpen(!historyOpen); if (!historyOpen && !history) void loadHistory() }}>{historyOpen ? <ChevronDown size={14} aria-hidden="true" /> : <ChevronRight size={14} aria-hidden="true" />}<span>{t('management.history')}</span></button>
+38 -5
View File
@@ -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; } }