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
@@ -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)