fix: keep workspace file controls visible while scrolling

Scroll the file list independently so view selection, refresh, creation actions and directory navigation remain visible. Keep the selected Files entry available in projects without Git.

Add component coverage and 16 Electron layout scenarios for themes, window sizes and scroll retention. Focused tests, typecheck and lint passed; the full test run timed out after 200 seconds.

Release note: Workspace file controls remain visible while scrolling, and projects without Git retain the Files entry.
This commit is contained in:
mesalogo
2026-09-30 17:33:16 +08:00
parent 80edd15438
commit ab85e72f01
7 changed files with 274 additions and 7 deletions
+2 -1
View File
@@ -393,7 +393,7 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
当前项目目录的文件浏览与 Git 交互遵循以下规则:
- FR-W1:顶部同一行放置“文件 / Git 工作区”视图切换和右侧刷新;非 Git 目录仍保留刷新。
- FR-W1:顶部同一行放置“文件 / Git 工作区”视图切换和右侧刷新;非 Git 目录仍显示选中的“文件”入口和刷新,仅隐藏“Git 工作区”。
下一行只提供当前视图的工具:文件视图提供新建文件、新建目录,Git 视图提供分支、Fetch 和更改显示方式,
不在 Git 视图重复目录导航或新建文件操作。操作使用带名称和提示的紧凑图标按钮。
文件视图在根目录不显示孤立的文件夹图标或空路径行;进入子目录后提供“项目目录名 / 子目录”
@@ -401,6 +401,7 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
长目录名单行省略,完整路径通过提示查看;多级路径在导航内部横向滚动,不撑宽工作栏。
选中目录时在该目录创建,选中文件时在其父目录创建,未选择时在当前浏览目录创建。
无可用目录时隐藏无关操作。
文件列表独立纵向滚动;顶部视图切换、刷新、新建操作和子目录路径导航保持可见,不随列表滚走。
- 使用共享 `SegmentedControl` 切换“文件”和“Git 工作区”,默认显示文件;
选择只在当前面板挂载期间保存在内存中,不持久化。未提交更改包含当前项目的全部
Git 更改,不按 AI、用户或其他工具的修改来源筛选。
@@ -211,6 +211,9 @@ describe('WorkspaceFilesPanel', () => {
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-files__files-view\s*\{[^}]*flex: 1;[^}]*min-height: 0;[^}]*overflow: auto/)
expect(css).toContain('.workspace-files__navigation:has(.workspace-files__list:not([hidden]) .workspace-files__files-view:not([hidden]))')
expect(css).toMatch(/\.workspace-files__header,\s*\.workspace-files__actions,\s*\.workspace-files__breadcrumbs,[^{]*\{\s*flex-shrink: 0/)
expect(css).toMatch(/\.workspace-git__branches\s*\{[^}]*width: min\(100%, 360px\);[^}]*border-radius: var\(--radius-control\)/)
expect(css).toMatch(/\.workspace-git__branches\s*\{[^}]*position: fixed;[^}]*max-height: calc\(100vh - 32px\);[^}]*overflow: auto/)
expect(css).toMatch(/\.workspace-git__branch-list\s*\{[^}]*max-height: 240px;[^}]*overflow: auto/)
@@ -535,6 +538,7 @@ describe('WorkspaceFilesPanel', () => {
)
await screen.findByText('工作区为空。')
expect(screen.getByRole('button', { name: '文件' })).toHaveAttribute('aria-pressed', 'true')
expect(onListDirectory).toHaveBeenCalledOnce()
await changeUiLocale('en-US')
@@ -705,6 +709,7 @@ describe('WorkspaceFilesPanel', () => {
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Git 工作区' })).not.toBeInTheDocument()
rerender(<WorkspaceFilesPanel {...props} isRepository={false} />)
expect(screen.getByRole('button', { name: '文件' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.queryByRole('button', { name: 'Git 工作区' })).not.toBeInTheDocument()
rerender(<WorkspaceFilesPanel {...props} isRepository />)
fireEvent.click(screen.getByRole('button', { name: 'Git 工作区' }))
+4 -4
View File
@@ -492,10 +492,10 @@ export function WorkspaceFilesPanel({
</section>}
<div className="workspace-files__list" hidden={showingDiff}>
<div className="workspace-files__header">
{showGit && <div className="workspace-files__view-switch"><SegmentedControl ariaLabel={t('files.view')} value={activeView} onChange={setView} options={[
<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>}
...(showGit ? [{ value: 'changes' as const, 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">
@@ -555,7 +555,7 @@ export function WorkspaceFilesPanel({
</div>
</WorkspaceGitTools>}
</div>
<div hidden={activeView !== 'files'}>
<div className="workspace-files__files-view" hidden={activeView !== 'files'}>
{loadingPaths.has(browsedPath) && !root ? (
<p className="assistant-sidebar__empty">
{t('files.readingWorkspace')}
+10 -2
View File
@@ -18639,13 +18639,14 @@ textarea.field-control {
.workspace-git__history-dock > [role="separator"] { height: var(--space-2); flex: 0 0 auto; cursor: ns-resize; touch-action: none; }
.workspace-git__history-scroll { flex: 1; overflow: auto; min-height: 0; }
/* Keep the history header docked even when its content is collapsed. */
.workspace-files__navigation:has(.workspace-files__list:not([hidden]) .workspace-files__files-view:not([hidden])),
.workspace-files__navigation:has(.workspace-files__list:not([hidden]) .workspace-files__git-view:not([hidden]) .workspace-git__working:not([hidden])) {
height: 100%;
min-height: 0;
padding-bottom: 0;
}
.workspace-files__navigation:has(.workspace-git) .workspace-files,
.workspace-files__navigation:has(.workspace-git) .workspace-files__list:not([hidden]),
.workspace-files__navigation .workspace-files,
.workspace-files__navigation .workspace-files__list:not([hidden]),
.workspace-files__git-view:not([hidden]),
.workspace-git,
.workspace-git__current {
@@ -18659,8 +18660,15 @@ textarea.field-control {
.workspace-git__current { gap: var(--space-2); overflow: auto; }
.workspace-git > [role="status"] { margin: 0; flex-shrink: 0; }
.workspace-files__header,
.workspace-files__actions,
.workspace-files__breadcrumbs,
.workspace-git__toolbar,
.workspace-git__history-toggle { flex-shrink: 0; }
.workspace-files__files-view {
flex: 1;
min-height: 0;
overflow: auto;
}
.workspace-git__changes {
flex: 1;
min-height: 0;
@@ -0,0 +1,151 @@
import { app, BrowserWindow } from 'electron'
import assert from 'node:assert/strict'
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import process from 'node:process'
import { setTimeout } from 'node:timers/promises'
import { setTimeout as startTimeout } from 'node:timers'
const directory = process.env.GB_LAYOUT_DIRECTORY
const artifacts = process.env.GB_LAYOUT_ARTIFACTS
app.setPath('userData', join(directory, 'profile'))
app.setPath('sessionData', join(directory, 'session'))
app.setAppLogsPath(join(directory, 'logs'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
app.commandLine.appendSwitch('disable-features', 'CalculateNativeWinOcclusion')
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: true, width: 1280, height: 800, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
const observations = [], errors = []
let pending = 'startup'
const environment = { platform: process.platform, arch: process.arch, electron: process.versions.electron, locale: 'en-US' }
const report = async result => {
const text = JSON.stringify({ ...result, environment, pending, observations, errors, modelCalls: 0 }, null, 2)
await writeFile(join(directory, 'result.json'), text)
if (artifacts) await writeFile(join(artifacts, 'result.json'), text)
}
startTimeout(async () => { await report({ passed: false, error: 'Driver timeout' }); app.exit(1) }, 80000)
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
win.webContents.on('render-process-gone', (_event, details) => errors.push(JSON.stringify(details)))
const js = code => { pending = code; return win.webContents.executeJavaScript(code) }
const wait = async code => {
for (let i = 0; i < 240; i++) { if (await js(code)) return; await setTimeout(25) }
throw new Error(`Timeout: ${code}\n${errors.join('\n')}`)
}
const settle = () => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
const point = selector => js(`(() => {
const e=document.querySelector(${JSON.stringify(selector)}),r=e.getBoundingClientRect();
return {x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2),hit:e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2))};
})()`)
const click = async selector => {
win.focus(); win.webContents.focus()
await wait('document.hasFocus()')
const p = await point(selector)
win.webContents.sendInputEvent({ type: 'mouseMove', x: p.x, y: p.y })
await settle()
assert((await point(selector)).hit, `Control occluded: ${selector}`)
win.webContents.sendInputEvent({ type: 'mouseDown', x: p.x, y: p.y, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x: p.x, y: p.y, button: 'left', clickCount: 1 })
await settle()
}
const screenshot = async name => {
if (artifacts) await writeFile(join(artifacts, `${name}.png`), (await win.webContents.capturePage()).toPNG())
}
const viewport = "document.querySelector('.workspace-files__files-view')"
const geometry = () => js(`(() => {
const rect=e=>{const r=e.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height,bottom:r.bottom,right:r.right}};
const v=${viewport},body=document.querySelector('.assistant-sidebar__body'),nav=document.querySelector('.workspace-files__navigation');
const selectors=['.workspace-files__header','.workspace-files__actions','.workspace-files__breadcrumbs'];
const controls=[...document.querySelectorAll('.workspace-files__header button,.workspace-files__actions button,.workspace-files__breadcrumbs button')];
return {viewport:rect(v),scrollTop:v.scrollTop,scrollHeight:v.scrollHeight,clientHeight:v.clientHeight,overflow:getComputedStyle(v).overflowY,
fixed:selectors.map(s=>{const e=document.querySelector(s);return e?rect(e):null}),
controls:controls.map(e=>{const r=e.getBoundingClientRect();return {label:e.getAttribute('aria-label')||e.textContent,hit:e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2)),outside:!v.contains(e),...rect(e)}}),
bodyScroll:body.scrollTop,bodyOverflow:body.scrollHeight-body.clientHeight,nav:rect(nav),
horizontalOverflow:[document.documentElement,body,nav,v].map(e=>e.scrollWidth-e.clientWidth),
innerWidth,innerHeight,scale:devicePixelRatio};
})()`)
const docked = (before, after) => {
assert.deepEqual(after.fixed, before.fixed, 'Header/actions/breadcrumbs moved with files')
assert(after.controls.every(c => c.hit && c.outside && c.height > 0), 'Docked controls must remain visible and hit-testable')
assert.equal(after.bodyScroll, 0, 'Sidebar body must not scroll with files')
assert(after.bodyOverflow <= 1, 'Files must not overflow the sidebar body')
assert(after.horizontalOverflow.every(value => value <= 1), 'Unexpected horizontal overflow')
assert(after.viewport.bottom <= after.nav.bottom + 1, 'Files viewport exceeds navigation bounds')
assert(after.viewport.y >= Math.max(...after.fixed.filter(Boolean).map(r => r.bottom)) - 1, 'Controls overlap files viewport')
}
try {
if (artifacts) await mkdir(artifacts, { recursive: true })
for (const git of [false, true]) for (const theme of ['light', 'dark']) {
for (const [width, height, sidebar] of [[1280, 800, 560], [960, 720, 420], [720, 640, 320], [640, 420, 300]]) {
const name = `${git ? 'git' : 'non-git'}-${theme}-${width}x${height}`
win.setContentSize(width, height)
await win.loadURL(`${process.env.GB_LAYOUT_URL}?git=${git}&theme=${theme}&sidebar=${sidebar}`)
await wait(`document.querySelectorAll('.workspace-files__row').length===121 && !!${viewport}`)
await js('document.fonts.ready')
await settle()
assert.equal(await js('document.querySelectorAll(".workspace-files__view-switch button").length'), git ? 2 : 1)
assert.equal(await js('document.querySelector(".workspace-files__view-switch button").textContent'), 'Files')
assert.equal(await js('document.querySelector(".workspace-files__view-switch button").getAttribute("aria-pressed")'), 'true')
const root = await geometry()
assert.equal(root.innerWidth, width); assert.equal(root.innerHeight, height)
assert(root.scrollHeight > root.clientHeight * 3 && root.clientHeight > 50, 'Long files list needs a bounded viewport')
assert.equal(root.overflow, 'auto')
docked(root, root)
const p = await point('.workspace-files__files-view')
win.webContents.sendInputEvent({ type: 'mouseWheel', x: p.x, y: p.y, deltaY: -240, deltaX: 0 })
await wait(`${viewport}.scrollTop > 0`)
// Native wheel scrolling finishes asynchronously in Chromium.
await setTimeout(350)
docked(root, await geometry())
await js(`${viewport}.scrollTop=0`)
await settle()
await click('.workspace-files__entry .workspace-files__more')
await wait('!!document.querySelector("[role=menuitem]")')
await click('[role=menuitem]:first-child')
await wait('document.querySelectorAll(".workspace-files__row").length===120 && !!document.querySelector(".workspace-files__breadcrumbs")')
const before = await geometry()
assert.equal(await js('document.querySelector(".workspace-files__breadcrumbs [aria-current=location]").textContent'), 'documents')
await js(`${viewport}.scrollTop=${viewport}.scrollHeight`)
await settle()
const after = await geometry()
assert(after.scrollTop > 0)
docked(before, after)
const last = '.workspace-files__entry:last-child .workspace-files__row'
assert(await js(`(() => {const r=document.querySelector(${JSON.stringify(last)}).getBoundingClientRect(),v=${viewport}.getBoundingClientRect();return r.top>=v.top && r.bottom<=v.bottom+1})()`), 'Last file must be fully visible')
assert.equal(await js(`document.querySelector(${JSON.stringify(last)}).title`), 'documents/file-120-workspace-notes.md')
await screenshot(name)
await click(last)
await wait('!!document.querySelector("#return-to-files")')
assert.equal(await js('getComputedStyle(document.querySelector(".workspace-files__navigation")).display'), 'none')
await click('#return-to-files')
await wait('!document.querySelector("#return-to-files")')
assert.equal(await js(`${viewport}.scrollTop`), after.scrollTop, 'Preview return must retain files scroll')
assert.equal(await js('document.querySelector(".workspace-files__row[aria-current=true]").title'), 'documents/file-120-workspace-notes.md')
docked(before, await geometry())
if (git) {
await click('.workspace-files__view-switch button:last-child')
assert.equal(await js(`getComputedStyle(${viewport}).display`), 'none')
await click('.workspace-files__view-switch button:first-child')
assert.equal(await js(`${viewport}.scrollTop`), after.scrollTop, 'Git view return must retain files scroll')
docked(before, await geometry())
}
for (const index of [1, 2]) {
await click(`.workspace-files__actions button:nth-child(${index})`)
await wait('!!document.querySelector("[role=dialog]")')
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' })
await wait('!document.querySelector("[role=dialog]")')
}
observations.push({ name, git, theme, sidebar, root, before, after, previewRetention: true, gitRetention: git, createDialogs: 2 })
}
}
assert.deepEqual(errors, [])
await report({ passed: true })
app.exit(0)
} catch (error) {
await screenshot('failure')
await report({ passed: false, error: String(error.stack) })
app.exit(1)
}
})
+52
View File
@@ -0,0 +1,52 @@
import { useState } from 'react'
import { createRoot } from 'react-dom/client'
import { WorkspaceFilesPanel } from '../../src/renderer/src/WorkspaceFilesPanel'
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
import { changeUiLocale } from '../../src/renderer/src/i18n'
import { applyAppearanceTheme } from '../../src/renderer/src/theme'
import { installBundledUiFonts } from '../../src/renderer/src/fonts'
import type { DesktopApi } from '../../src/shared/contracts'
import type { WorkspaceDirectoryListing } from '../../src/shared/assistant-contracts'
import '../../src/renderer/src/styles.css'
const query = new URLSearchParams(location.search)
const listDirectory = async (path: string): Promise<WorkspaceDirectoryListing> => ({
path, truncated: false, entries: [
...(!path ? [{ name: 'documents', path: 'documents', type: 'directory' as const }] : []),
...Array.from({ length: 120 }, (_, index) => {
const name = `file-${String(index + 1).padStart(3, '0')}-workspace-notes.md`
return { name, path: path ? `${path}/${name}` : name, type: 'file' as const }
})
]
})
Object.defineProperty(window, 'goodbuddy', { value: {
workspace: { manage: async (_projectId: string, action: { kind: string }) => {
if (action.kind === 'branches') return { kind: 'branches', current: 'main', branches: [] }
throw new Error(`Unexpected fixture action: ${action.kind}`)
} }
} as unknown as DesktopApi })
function Fixture(): React.JSX.Element {
const [preview, setPreview] = useState<string>()
return <UiLocaleProvider initialPreference="en-US">
<aside className="assistant-sidebar assistant-sidebar--open"
style={{ width: Number(query.get('sidebar')), flexBasis: Number(query.get('sidebar')), height: '100vh' }}>
<div className="assistant-sidebar__body">
<section className="assistant-sidebar__section workspace-files__navigation" hidden={Boolean(preview)}>
<WorkspaceFilesPanel projectId="files-layout" rootPath="/fixture/workspace"
isRepository={query.get('git') === 'true'} changedFiles={[]}
onListDirectory={listDirectory} onOpenFile={setPreview}
onLoadDiff={async () => { throw new Error('Unused diff fixture') }} />
</section>
{preview && <section className="assistant-sidebar__section">
<button id="return-to-files" onClick={() => setPreview(undefined)}>Return to Files</button>
<p>{preview}</p>
</section>}
</div>
</aside>
</UiLocaleProvider>
}
installBundledUiFonts()
applyAppearanceTheme(query.get('theme') === 'dark' ? 'dark' : 'light')
void changeUiLocale('en-US').then(() => createRoot(document.getElementById('root')!).render(<Fixture />))
@@ -0,0 +1,50 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { copyFile, mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
it('docks workspace Files controls while long Git and non-Git directories scroll', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-files-layout-'))
const server = await createServer({
configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'workspace-files-layout', configureServer(server) {
server.middlewares.use('/files.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/files.html',
'<html><body><div id="root"></div><script type="module" src="/tests/support/workspace-files-layout.tsx"></script></body></html>'))
})
} }],
optimizeDeps: { entries: ['tests/support/workspace-files-layout.tsx'] },
server: { host: '127.0.0.1', port: 0, watch: null }
})
try {
await server.listen()
const driver = join(directory, 'driver.mjs')
await copyFile(resolve('tests/support/workspace-files-layout-driver.mjs'), driver)
const env: NodeJS.ProcessEnv = { ...process.env, GB_LAYOUT_DIRECTORY: directory,
GB_LAYOUT_URL: server.resolvedUrls!.local[0] + 'files.html' }
delete env.ELECTRON_RUN_AS_NODE
const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
const timeout = setTimeout(() => child.kill(), 90000)
try {
const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) })
const result = await readFile(join(directory, 'result.json'), 'utf8').catch(() => output)
expect(code, result).toBe(0)
const parsed = JSON.parse(result)
expect(parsed.passed).toBe(true)
expect(parsed.observations).toHaveLength(16)
process.stdout.write('Workspace Files: 16 Electron geometry scenarios; Git/non-Git, light/dark, four sizes, native wheel/click, hide/show retention; model calls: 0\n')
} finally { clearTimeout(timeout) }
} finally {
await server.close()
await rm(directory, { recursive: true, force: true })
}
}, 120000)