Files
apes-sidebar/tests/file-tree-reveal-scroll.spec.tsx
T
Menghuan1918 a9353ce627 feat: workspace fence toggle (workspaceFence pref + one-click off in error surfaces)
- New persisted built-in pref workspaceFence (default true) under the
  files card's secondary settings modal; host reads it per request via
  the settings seam (fenceEnabledOf — absent/malformed stays ON).
- ensureWorkspacePath/ensureWorkspaceWritePath gain a fence param:
  disarmed skips the containment assert but keeps realpath resolution;
  wired through fs.tree/fs.read/fs.write, the media + html routes, and
  the upload path (writeWorkspaceUpload fence input).
- Editor load failures and file-tree level errors show the localized
  reason plus a one-click global off (FenceErrorNotice) that flips the
  pref, adopts it into the store, and auto-retries the failed operation
  (reloadSeq / retryDir) — the raw wire text is never shown.
- GitView's two client-side pre-checks (hidden open action + no-op
  defense) respect the pref; store threaded through TreePanel/FileTree.
- i18n: 4 new keys across all 21 dictionaries; tests: fence-off route
  coverage, prefs parsing, schema default, editor toggles list;
  docs: guide §8/§8.1 + plans/2026-08-29 design record.
2026-08-29 15:49:19 +08:00

122 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* "Show in folder" reveal scrolling: the tree body is the ONLY thing that
* may scroll. The reveal used native scrollIntoView, which scrolls every
* scrollable ancestor — and the clipping fixed panel host
* ([data-dsh-panel-host], overflow hidden at the time) is still
* programmatically scrollable, so a deep reveal shifted the whole panel,
* tab bar included, out of the viewport (reported on DSH 0.1.2-alpha.1).
* The regression guard: body.scrollTo receives the centered, clamped
* target; row.scrollIntoView is never called.
*/
// @vitest-environment jsdom
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { FileTree } from '../src/client/FileTree.tsx'
import { createSidebarStore } from '../src/client/state.ts'
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
// jsdom defines no scrollIntoView; counting calls needs a real function.
const scrollIntoView = vi.fn()
beforeAll(() => {
;(Element.prototype as unknown as { scrollIntoView: () => void }).scrollIntoView = scrollIntoView
})
afterEach(() => { scrollIntoView.mockClear() })
vi.mock('../src/client/api.ts', () => ({
api: {
fsTree: async () => ({
entries: [
{ name: 'src', path: '/tmp/src', isDir: true },
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
],
}),
},
downloadUrl: () => '/sidebar/file',
}))
interface TreeHarness {
/** Re-render with new props; the refresh tick reloads the level cache. */
rerender: (revealed: string[], refreshTick: number) => Promise<void>
body: HTMLElement
/** Pin one row's geometry (jsdom has no layout). */
layoutRow: (top: number, height: number) => void
scrollTo: ReturnType<typeof vi.fn>
unmount: () => void
}
async function mountTree(bodyScroll: { top: number; client: number; height: number }): Promise<TreeHarness> {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
const render = (revealed: string[], refreshTick: number): void => {
root.render(createElement(FileTree, {
sessionId: 's1',
cwd: '/tmp',
store: createSidebarStore(),
expanded: [],
revealed,
onToggle: () => {},
onOpenFile: () => {},
onReferenceFile: () => {},
refreshTick,
onUploadRequest: () => {},
busy: false,
}))
}
await act(async () => { render([], 0) })
const body = container.firstElementChild as HTMLElement
const scrollTo = vi.fn()
Object.defineProperty(body, 'scrollTo', { value: scrollTo })
Object.defineProperty(body, 'getBoundingClientRect', { value: () => ({ top: 100, height: bodyScroll.client }) })
Object.defineProperty(body, 'clientHeight', { value: bodyScroll.client })
Object.defineProperty(body, 'scrollHeight', { value: bodyScroll.height })
Object.defineProperty(body, 'scrollTop', { value: bodyScroll.top, writable: true })
return {
rerender: async (revealed, refreshTick) => { await act(async () => { render(revealed, refreshTick) }) },
body,
layoutRow: (top, height) => {
const row = body.querySelector('[data-dsh-revealed]')
expect(row).not.toBeNull()
Object.defineProperty(row as Element, 'getBoundingClientRect', { value: () => ({ top, height }) })
},
scrollTo,
unmount: () => { act(() => { root.unmount() }); container.remove() },
}
}
afterEach(() => { document.body.innerHTML = '' })
describe('FileTree reveal scrolling (show in folder)', () => {
it('centers the revealed row in the tree body only', async () => {
// Body: top 100, 300 tall, scrolled 40; row lands at 400, 20 tall →
// target = 40 + (400 + 10) − (100 + 150) = 200.
const tree = await mountTree({ top: 40, client: 300, height: 1000 })
await tree.rerender(['/tmp/a.ts'], 0)
tree.layoutRow(400, 20)
await tree.rerender(['/tmp/a.ts'], 1)
expect(scrollIntoView).not.toHaveBeenCalled()
expect(tree.scrollTo.mock.calls.map(call => call[0])).toContainEqual({ top: 200, behavior: 'smooth' })
tree.unmount()
})
it('clamps the target to the scrollable range', async () => {
// 5000px-tall scroller with 300 visible: max scrollTop 4700.
const tree = await mountTree({ top: 0, client: 300, height: 5000 })
await tree.rerender(['/tmp/a.ts'], 0)
tree.layoutRow(9000, 20)
await tree.rerender(['/tmp/a.ts'], 1)
expect(tree.scrollTo.mock.calls.map(call => call[0]?.top)).toContainEqual(4700)
tree.unmount()
})
it('scrolls nothing without a reveal', async () => {
const tree = await mountTree({ top: 0, client: 300, height: 1000 })
await tree.rerender([], 1)
expect(tree.scrollTo).not.toHaveBeenCalled()
tree.unmount()
})
})