Files
dsh-context/tests/client/components/fileCard.spec.ts
T
bowenliang123 97ae8ff2ee feat(client): preview File Activity files in the right Sidebar
Clicking a file name in the File Activity card now opens the file's preview
tab in the right Sidebar — the shipped document-preview viewer, reached the
same way the built-in Files sidebar reaches it — on harnesses that serve that
column (dsh 0.1.5-rc.1+). The address is built with the harness's own
browser-safe `fileAddressFor`, inlined by the client bundle exactly as the
Sidebar's file types inline it, so a path under the workspace collapses to the
session-relative spelling and every route to one file settles on one tab.

The capability is OPTIONAL by contract, like the tab registration itself: the
opener reads `ctx.sidebarRight.openResource` through the runtime boundary and
re-proves it per call, and reports whether the column took the address. An
absent column, a face that refuses the address, or an unencodable log path
falls through to the pre-existing system-open behavior, so an older line is
fully functional and nothing becomes an inert click. Pattern rows and
directory targets are never previewable.

Adds the `@deepseek-ai/dsh-util-workspace-path` dev dependency (itself
browser-safe), allows it in the client bundle purity gate, pins the
resource-navigation seam in the compat matrix, and covers the address builder,
the card's preview-then-fallback click, and the optional opener per baseline.
2026-09-11 18:45:28 +08:00

621 lines
29 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.
// FileCard (src/client/components/fileCard.tsx) rendered with real React:
// purpose/form filter chips, path search, per-file rows with count badges
// and line deltas, expandable operation logs, the op-level locate hook,
// workspace-relative path display, and the system-open affordance.
import { act, createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { makeFileCard } from '../../../src/client/components/fileCard'
import type { FileActivity, FileEntry, FileOp } from '../../../src/client/fileActivity'
import { createContextSettings } from '../../../src/client/settings'
import { makeKit, mount, query, queryAll, text, click } from '../helpers/kit'
const kit = makeKit()
const settings = createContextSettings()
const FileCard = makeFileCard(kit, settings)
const T0 = 1700000000000
function fileOp(seq: number, kind: FileOp['kind'], tool: string, over: Partial<FileOp> = {}): FileOp {
return { seq, kind, tool, time: T0 + seq, err: false, added: 0, removed: 0, path: '', ...over }
}
function entry(path: string, over: Partial<FileEntry>): FileEntry {
return { path, form: 'text', reads: 0, writes: 0, searches: 0, added: 0, removed: 0, errs: 0, ops: [], ...over }
}
/** Five files exercising every row anatomy: split paths, all badges, deltas both ways, failures, a directory, an image. */
function richActivity(over: Partial<FileActivity> = {}): FileActivity {
return {
entries: [
entry('/src/a.ts', {
reads: 2,
writes: 2,
added: 2,
removed: 3,
errs: 1,
ops: [
fileOp(7, 'read', 'read'),
fileOp(5, 'write', 'edit', { added: 2, removed: 1 }),
fileOp(4, 'write', 'edit', { removed: 2, err: true }),
fileOp(3, 'read', 'read', { time: undefined }),
],
}),
entry('/shots/ui.png', { form: 'image', reads: 1, ops: [fileOp(9, 'read', 'read_image')] }),
entry('/src/', { form: 'dir', searches: 1, ops: [fileOp(6, 'search', 'grep', { detail: 'needle' })] }),
entry('solo.md', { writes: 1, added: 5, ops: [fileOp(8, 'write', 'write', { added: 5, time: undefined })] }),
entry('/var/many.log', {
reads: 9,
ops: Array.from({ length: 9 }, (_, i) => fileOp(28 - i, 'read', 'read')),
}),
],
totals: {
read: { files: 3, ops: 12 },
write: { files: 2, ops: 3 },
search: { files: 1, ops: 1 },
image: { files: 1, ops: 1 },
added: 7,
removed: 3,
},
...over,
}
}
function chipByLabel(container: ParentNode, label: string): HTMLElement {
const hit = queryAll(container, '.lc-fa-ctl .lc-gran-btn').find(b => text(b).startsWith(label))
if (hit === undefined) throw new Error(`chip not found: ${label}`)
return hit
}
function rowByTitle(container: ParentNode, title: string): HTMLElement {
const hit = queryAll(container, '.lc-fa-row').find(r => r.title === title)
if (hit === undefined) throw new Error(`row not found: ${title}`)
return hit
}
async function typeSearch(container: ParentNode, value: string): Promise<void> {
const input = query<HTMLInputElement>(container, 'input.lc-fa-search')
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
setter?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
}
describe('FileCard — empty state', () => {
test('no file ops renders the empty state without controls', async () => {
const m = await mount(h(FileCard, {
activity: richActivity({ entries: [] }),
scope: 'Turn 1 · Step 1',
}))
assert.ok(text(m.container).includes('No file reads, writes, or searches'))
assert.ok(text(m.container).includes('Turn 1 · Step 1'))
assert.equal(queryAll(m.container, '.lc-fa-ctl').length, 0)
await m.unmount()
})
})
describe('FileCard — header, rows, and filters', () => {
test('renders chips with op counts, the meta strip, and fully-dressed rows', async () => {
const m = await mount(h(FileCard, { activity: richActivity(), scope: 'Up to latest' }))
// Chip op counts: all = 12 + 3 + 1.
assert.ok(text(chipByLabel(m.container, 'All')).includes('16'))
assert.ok(text(chipByLabel(m.container, 'Read')).includes('12'))
assert.ok(text(chipByLabel(m.container, 'Images')).includes('1'))
// Meta strip: file count + aggregate delta (+7 −3).
const meta = query(m.container, '.lc-fa-meta')
assert.ok(text(meta).includes('5 files'))
assert.ok(text(meta).includes('+7'))
assert.ok(text(meta).includes('−3'))
assert.ok(query(meta, '.lc-fa-meta-tip').textContent?.includes('estimated') === true)
const rows = queryAll(m.container, '.lc-fa-row')
assert.equal(rows.length, 5)
// Default order is most-active first: many.log (9 ops) → a.ts (4) → the one-op files by recency.
assert.deepEqual(rows.map(r => r.title), ['/var/many.log', '/src/a.ts', '/shots/ui.png', 'solo.md', '/src/'])
// Path split: muted dir + bold base; every badge and the row delta.
const first = rows[0]
assert.ok(first.querySelector('.lc-fa-path em')?.textContent === '/var/')
assert.ok(first.querySelector('.lc-fa-path b')?.textContent === 'many.log')
assert.ok(text(first).includes('9')) // read ×9 badge
assert.equal(queryAll(first, '.lc-fa-badge').length, 1) // read only
assert.ok(first.querySelector('.lc-fa-time') !== null)
const aRow = rowByTitle(m.container, '/src/a.ts')
assert.ok(aRow.querySelector('.lc-fa-path em')?.textContent === '/src/')
assert.ok(aRow.querySelector('.lc-fa-path b')?.textContent === 'a.ts')
assert.equal(queryAll(aRow, '.lc-fa-badge').length, 2) // read + write
assert.ok(text(aRow).includes('+2') && text(aRow).includes('−3'))
assert.ok(aRow.querySelector('.lc-br-err-dot') !== null)
assert.ok(aRow.title === '/src/a.ts')
// The directory row trims its trailing slash for display.
const dirRow = rows.find(r => r.title === '/src/')
assert.ok(dirRow !== undefined)
assert.ok(dirRow.querySelector('.lc-fa-path b')?.textContent === 'src')
// The extension-less row relativizes with no workspace known: a './' dir span, and no badges beyond its own kind.
const solo = rows.find(r => r.title === 'solo.md')
assert.ok(solo !== undefined)
assert.ok(solo.querySelector('.lc-fa-path em')?.textContent === './')
assert.ok(solo.querySelector('.lc-fa-path b')?.textContent === 'solo.md')
assert.ok(text(solo).includes('+5') && !text(solo).includes('−'))
// solo.md's only op is timeless: the row drops its time cell.
assert.ok(solo.querySelector('.lc-fa-time') === null)
await m.unmount()
})
test('purpose and form chips isolate rows; re-clicking restores all', async () => {
const m = await mount(h(FileCard, { activity: richActivity(), scope: 'live' }))
const paths = () => queryAll(m.container, '.lc-fa-row').map(r => r.title)
// Kind chips always carry their badge-color class; the neutral chips don't.
assert.ok(chipByLabel(m.container, 'Read').className.includes('lc-fa-chip-read'))
assert.ok(chipByLabel(m.container, 'Written').className.includes('lc-fa-chip-write'))
assert.ok(chipByLabel(m.container, 'Searched').className.includes('lc-fa-chip-search'))
assert.ok(!chipByLabel(m.container, 'All').className.includes('lc-fa-chip-'))
assert.ok(!chipByLabel(m.container, 'Images').className.includes('lc-fa-chip-'))
await click(chipByLabel(m.container, 'Written'))
assert.ok(chipByLabel(m.container, 'Written').className.includes('lc-gran-on'))
assert.deepEqual(paths(), ['/src/a.ts', 'solo.md'])
await click(chipByLabel(m.container, 'Searched'))
assert.deepEqual(paths(), ['/src/'])
await click(chipByLabel(m.container, 'Images'))
assert.deepEqual(paths(), ['/shots/ui.png'])
await click(chipByLabel(m.container, 'Read'))
assert.deepEqual(paths(), ['/var/many.log', '/src/a.ts', '/shots/ui.png'])
await click(chipByLabel(m.container, 'Read'))
assert.equal(paths().length, 5)
await m.unmount()
})
test('the count sort keys on the selected kind once a kind chip is active', async () => {
// x.log is op-heavy overall (6 ops) but read-light (1); y.ts is read-heavy (2 of 2 ops);
// idx.md and pat.txt split the search counts 3 vs 1.
const activity: FileActivity = {
entries: [
entry('/logs/x.log', {
reads: 1,
writes: 5,
ops: [
fileOp(6, 'write', 'edit'),
fileOp(5, 'write', 'edit'),
fileOp(4, 'write', 'edit'),
fileOp(3, 'write', 'edit'),
fileOp(2, 'write', 'edit'),
fileOp(1, 'read', 'read'),
],
}),
entry('/code/y.ts', { reads: 2, ops: [fileOp(8, 'read', 'read'), fileOp(7, 'read', 'read')] }),
entry('/src/idx.md', {
searches: 3,
ops: [fileOp(11, 'search', 'glob'), fileOp(10, 'search', 'grep'), fileOp(9, 'search', 'grep')],
}),
entry('/src/pat.txt', { searches: 1, ops: [fileOp(12, 'search', 'grep')] }),
],
totals: {
read: { files: 2, ops: 3 },
write: { files: 1, ops: 5 },
search: { files: 2, ops: 4 },
image: { files: 0, ops: 0 },
added: 0,
removed: 0,
},
}
const m = await mount(h(FileCard, { activity, scope: 'live' }))
const titles = () => queryAll(m.container, '.lc-fa-row').map(r => r.title)
// 'count' over everything: total ops lead.
assert.deepEqual(titles(), ['/logs/x.log', '/src/idx.md', '/code/y.ts', '/src/pat.txt'])
// The read chip narrows AND the count sort re-keys on reads: 2 reads beat 1.
await click(chipByLabel(m.container, 'Read'))
assert.deepEqual(titles(), ['/code/y.ts', '/logs/x.log'])
// The write chip leaves the one write-heavy file, still write-keyed.
await click(chipByLabel(m.container, 'Written'))
assert.deepEqual(titles(), ['/logs/x.log'])
// The search chip re-keys on searches: 3 beat 1.
await click(chipByLabel(m.container, 'Searched'))
assert.deepEqual(titles(), ['/src/idx.md', '/src/pat.txt'])
await m.unmount()
})
test('path search narrows rows and reports no matches', async () => {
const m = await mount(h(FileCard, { activity: richActivity(), scope: 'live' }))
await typeSearch(m.container, 'A.TS')
assert.deepEqual(queryAll(m.container, '.lc-fa-row').map(r => r.title), ['/src/a.ts'])
await typeSearch(m.container, 'zzz')
assert.equal(queryAll(m.container, '.lc-fa-row').length, 0)
assert.ok(text(m.container).includes('No files match the current filters'))
await typeSearch(m.container, '')
assert.equal(queryAll(m.container, '.lc-fa-row').length, 5)
await m.unmount()
})
test('the sort toggle defaults to most-active and switches to latest and path', async () => {
const m = await mount(h(FileCard, { activity: richActivity(), scope: 'live' }))
const titles = () => queryAll(m.container, '.lc-fa-row').map(r => r.title)
const sortBtns = queryAll(m.container, '.lc-fa-sort .lc-gran-btn')
assert.equal(sortBtns.length, 3)
// Count order by default: 9 ops → 4 ops → one-op files by recency.
assert.ok(sortBtns[0].className.includes('lc-gran-on'))
assert.deepEqual(titles(), ['/var/many.log', '/src/a.ts', '/shots/ui.png', 'solo.md', '/src/'])
// Latest order: many.log (seq 28) → ui.png (9) → solo.md (8) → a.ts (7) → /src/ (6).
await click(sortBtns[1])
assert.ok(sortBtns[1].className.includes('lc-gran-on'))
assert.deepEqual(titles(), ['/var/many.log', '/shots/ui.png', 'solo.md', '/src/a.ts', '/src/'])
// Path order: ascending over full paths (the dir row precedes its children).
await click(sortBtns[2])
assert.ok(sortBtns[2].className.includes('lc-gran-on'))
assert.deepEqual(titles(), ['/shots/ui.png', '/src/', '/src/a.ts', '/var/many.log', 'solo.md'])
await click(sortBtns[0])
assert.ok(sortBtns[0].className.includes('lc-gran-on'))
assert.deepEqual(titles(), ['/var/many.log', '/src/a.ts', '/shots/ui.png', 'solo.md', '/src/'])
await m.unmount()
})
test('the mount-time default sort comes from the plugin settings', async () => {
const titles = (container: ParentNode) => queryAll(container, '.lc-fa-row').map(r => r.title)
const sortBtns = (container: ParentNode) => queryAll(container, '.lc-fa-sort .lc-gran-btn')
settings.set('defaultFileSort', 'latest')
let m = await mount(h(FileCard, { activity: richActivity(), scope: 'live' }))
assert.ok(sortBtns(m.container)[1].className.includes('lc-gran-on'))
assert.deepEqual(titles(m.container), ['/var/many.log', '/shots/ui.png', 'solo.md', '/src/a.ts', '/src/'])
await m.unmount()
settings.set('defaultFileSort', 'path')
m = await mount(h(FileCard, { activity: richActivity(), scope: 'live' }))
assert.ok(sortBtns(m.container)[2].className.includes('lc-gran-on'))
assert.deepEqual(titles(m.container), ['/shots/ui.png', '/src/', '/src/a.ts', '/var/many.log', 'solo.md'])
await m.unmount()
// The stored preference resets the default; in-card toggling never writes back.
settings.set('defaultFileSort', 'count')
m = await mount(h(FileCard, { activity: richActivity(), scope: 'live' }))
assert.ok(sortBtns(m.container)[0].className.includes('lc-gran-on'))
assert.deepEqual(titles(m.container), ['/var/many.log', '/src/a.ts', '/shots/ui.png', 'solo.md', '/src/'])
await click(sortBtns(m.container)[1])
assert.deepEqual(settings.store.getSnapshot().fileSort, 'count')
await m.unmount()
})
})
describe('FileCard — expansion and locate', () => {
test('a row click expands its op log; an op click locates it; re-click collapses', async () => {
const located: number[] = []
const m = await mount(h(FileCard, {
activity: richActivity(),
scope: 'live',
onLocate: op => { located.push(op.seq) },
}))
const row = rowByTitle(m.container, '/src/a.ts')
await click(row)
const ops = queryAll(m.container, '.lc-fa-op')
assert.equal(ops.length, 4)
// Op anatomy: time, tool chip, per-op delta, failure dot, and the timeless op's dash.
assert.ok(text(ops[0]).includes('read'))
assert.ok(ops[0].className.includes('lc-fa-op-link'))
assert.ok(text(ops[1]).includes('+2') && text(ops[1]).includes('−1'))
assert.ok(text(ops[2]).includes('−2') && !text(ops[2]).includes('+'))
assert.ok(ops[2].querySelector('.lc-br-err-dot') !== null)
assert.ok(text(ops[3]).includes('—'))
// The op click jumps — and does not collapse the row (no bubbling).
await click(ops[1])
assert.deepEqual(located, [5])
assert.ok(query(m.container, '.lc-fa-ops') !== null)
await click(rowByTitle(m.container, '/src/a.ts'))
assert.equal(queryAll(m.container, '.lc-fa-ops').length, 0)
await m.unmount()
})
test('a search op shows its pattern detail; the expanded log lists every op', async () => {
const m = await mount(h(FileCard, { activity: richActivity(), scope: 'live' }))
// The directory row: one grep op with its searched pattern.
await click(rowByTitle(m.container, '/src/'))
assert.ok(text(query(m.container, '.lc-fa-ops')).includes('needle'))
// The nine-read log renders all nine rows — no cap, no overflow line
// (one expanded file at a time: this click collapsed the directory row).
await click(rowByTitle(m.container, '/var/many.log'))
const many = query(m.container, '.lc-fa-ops')
assert.equal(queryAll(many, '.lc-fa-op').length, 9)
await m.unmount()
})
test('a nested PTC op tells why (its program); a meta search op shows its hit count', async () => {
const m = await mount(h(FileCard, {
activity: richActivity({
entries: [
entry('/src/a.ts', {
reads: 2,
ops: [
fileOp(9, 'read', 'read', { parent: 30, program: 'Read failing test and its fixture' }),
fileOp(7, 'read', 'read'),
],
}),
entry('/src/b.ts', { searches: 1, ops: [fileOp(10, 'search', 'grep', { detail: 'needle', hits: 3 })] }),
],
totals: { read: { files: 1, ops: 2 }, write: { files: 0, ops: 0 }, search: { files: 1, ops: 1 }, image: { files: 0, ops: 0 }, added: 0, removed: 0 },
}),
scope: 'live',
}))
await click(rowByTitle(m.container, '/src/a.ts'))
assert.ok(text(query(m.container, '.lc-fa-ops')).includes('Read failing test and its fixture'))
await click(rowByTitle(m.container, '/src/a.ts')) // collapse
await click(rowByTitle(m.container, '/src/b.ts'))
const ops = text(query(m.container, '.lc-fa-ops'))
assert.ok(ops.includes('needle'))
assert.ok(ops.includes('3 hits'))
await m.unmount()
})
test('a code file renders its language badge; an emoji file keeps its glyph', async () => {
const m = await mount(h(FileCard, {
activity: richActivity({
entries: [
entry('/src/main.ts', { reads: 1, ops: [fileOp(9, 'read', 'read')] }),
entry('/notes.md', { reads: 1, ops: [fileOp(8, 'read', 'read')] }),
],
totals: { read: { files: 2, ops: 2 }, write: { files: 0, ops: 0 }, search: { files: 0, ops: 0 }, image: { files: 0, ops: 0 }, added: 0, removed: 0 },
}),
scope: 'live',
}))
// The badge: TS label on the language fill, readable text color inline.
const tsRow = rowByTitle(m.container, '/src/main.ts')
const badge = query(tsRow, '.lc-fa-lang')
assert.ok(badge !== undefined)
assert.equal(badge.textContent, 'TS')
assert.equal(query(tsRow, '.lc-fa-form').getAttribute('title'), 'TypeScript')
const style = badge.getAttribute('style') ?? ''
assert.ok(style.includes('background: rgb(49, 120, 198)'), style)
assert.ok(style.includes('color: rgb(255, 255, 255)'), style)
// The markdown row keeps its emoji glyph with the bucket name as the title.
const mdRow = rowByTitle(m.container, '/notes.md')
assert.equal(queryAll(mdRow, '.lc-fa-lang').length, 0)
assert.ok(text(mdRow).includes('📝'))
assert.equal(query(mdRow, '.lc-fa-form').getAttribute('title'), 'Markup document')
await m.unmount()
})
test('a read op shows its line footprint — exact window or estimate; the time rides the right edge', async () => {
const m = await mount(h(FileCard, {
activity: richActivity({
entries: [entry('/src/a.ts', {
reads: 2,
ops: [fileOp(7, 'read', 'read', { read: { start: 41, count: 30 } }), fileOp(6, 'read', 'read', { read: { count: 80, est: true } })],
})],
totals: { read: { files: 1, ops: 2 }, write: { files: 0, ops: 0 }, search: { files: 0, ops: 0 }, image: { files: 0, ops: 0 }, added: 0, removed: 0 },
}),
scope: 'live',
}))
await click(rowByTitle(m.container, '/src/a.ts'))
const ops = queryAll(m.container, '.lc-fa-op')
// The exact read: `>> count` with the range as the title.
const exact = queryAll(ops[0], '.lc-fa-read')
assert.equal(exact.length, 1)
assert.equal(exact[0].textContent, '>>30')
assert.equal(exact[0].getAttribute('title'), 'Read lines 41–70')
// The estimate keeps the ≈ form and its own title.
const est = queryAll(ops[1], '.lc-fa-read')
assert.equal(est.length, 1)
assert.equal(est[0].textContent, '≈80')
assert.equal(est[0].getAttribute('title'), 'Lines read, estimated from the limit argument')
// The time is the row's last cell, pushed to the right edge (formatted, not the timeless dash).
const time = query(ops[1], '.lc-fa-op-time')
assert.notEqual(time.textContent, '—')
assert.ok((time.textContent ?? '').includes(':'))
await m.unmount()
})
test('without a locate hook the op lines render inert', async () => {
const m = await mount(h(FileCard, { activity: richActivity(), scope: 'live' }))
await click(queryAll(m.container, '.lc-fa-row')[0])
assert.equal(queryAll(m.container, 'button.lc-fa-op').length, 0)
assert.ok(queryAll(m.container, 'div.lc-fa-op').length > 0)
await m.unmount()
})
test('no line activity anywhere drops the aggregate delta pill', async () => {
const m = await mount(h(FileCard, {
activity: richActivity({
entries: [entry('/a.ts', { reads: 1, ops: [fileOp(1, 'read', 'read')] })],
totals: { read: { files: 1, ops: 1 }, write: { files: 0, ops: 0 }, search: { files: 0, ops: 0 }, image: { files: 0, ops: 0 }, added: 0, removed: 0 },
}),
scope: 'live',
}))
assert.equal(queryAll(m.container, '.lc-fa-meta-delta').length, 0)
await m.unmount()
})
})
describe('FileCard — workspace display and system open', () => {
/** Entries exercising every display form against one workspace root. */
function workspaceActivity(): FileActivity {
return {
entries: [
entry('/repo/src/a.ts', { reads: 1, ops: [fileOp(5, 'read', 'read')] }),
entry('/etc/hosts', { reads: 1, ops: [fileOp(4, 'read', 'read')] }),
entry('rel/b.md', { writes: 1, ops: [fileOp(3, 'write', 'write')] }),
entry('needle', { searches: 1, pattern: true, ops: [fileOp(2, 'search', 'grep', { detail: 'needle' })] }),
entry('./dot/c.ts', { reads: 1, ops: [fileOp(1, 'read', 'read')] }),
],
totals: {
read: { files: 3, ops: 3 },
write: { files: 1, ops: 1 },
search: { files: 1, ops: 1 },
image: { files: 0, ops: 0 },
added: 0,
removed: 0,
},
}
}
function rowOf(container: ParentNode, title: string): HTMLElement {
const hit = queryAll(container, '.lc-fa-row').find(r => r.title === title)
if (hit === undefined) throw new Error(`row not found: ${title}`)
return hit
}
const baseOf = (row: HTMLElement): HTMLElement => query(row, '.lc-fa-path b')
test('workspace paths display ./-relative; pattern rows and outside paths stay verbatim', async () => {
const m = await mount(h(FileCard, { activity: workspaceActivity(), scope: 'live', workspace: '/repo' }))
// Inside the workspace: './'-relative dir + base.
const a = rowOf(m.container, '/repo/src/a.ts')
assert.ok(a.querySelector('.lc-fa-path em')?.textContent === './src/')
assert.ok(baseOf(a).textContent === 'a.ts')
// An absolute path outside the workspace keeps its verbatim form.
const hosts = rowOf(m.container, '/etc/hosts')
assert.ok(hosts.querySelector('.lc-fa-path em')?.textContent === '/etc/')
// A bare-relative path displays './'-prefixed.
const rel = rowOf(m.container, 'rel/b.md')
assert.ok(rel.querySelector('.lc-fa-path em')?.textContent === './rel/')
// A pattern-as-target search row shows the pattern, never relativized.
const pat = rowOf(m.container, 'needle')
assert.equal(pat.querySelector('.lc-fa-path em'), null)
assert.ok(baseOf(pat).textContent === 'needle')
// A './'-prefixed path keeps its form.
const dot = rowOf(m.container, './dot/c.ts')
assert.ok(dot.querySelector('.lc-fa-path em')?.textContent === './dot/')
await m.unmount()
})
test('the file name opens on the system; pattern and unresolvable rows render inert', async () => {
const opened: string[] = []
const m = await mount(h(FileCard, {
activity: workspaceActivity(),
scope: 'live',
workspace: '/repo',
onOpen: p => { opened.push(p) },
}))
// A workspace file resolves to its absolute path and opens without expanding the row.
const a = baseOf(rowOf(m.container, '/repo/src/a.ts'))
assert.ok(a.className.includes('lc-fa-file'))
assert.equal(a.getAttribute('title'), 'Open on your system')
await click(a)
assert.deepEqual(opened, ['/repo/src/a.ts'])
assert.equal(queryAll(m.container, '.lc-fa-ops').length, 0)
// An outside absolute path hands through verbatim.
await click(baseOf(rowOf(m.container, '/etc/hosts')))
assert.deepEqual(opened, ['/repo/src/a.ts', '/etc/hosts'])
// Pattern rows are not files: inert bold text.
const pat = baseOf(rowOf(m.container, 'needle'))
assert.equal(pat.className.includes('lc-fa-file'), false)
// A './'-prefixed path has no root to resolve against: inert.
const dot = baseOf(rowOf(m.container, './dot/c.ts'))
assert.equal(dot.className.includes('lc-fa-file'), false)
await m.unmount()
})
test('without an opener (or without the workspace) the file names render inert', async () => {
// No onOpen prop: nothing clickable even with the workspace known.
const m1 = await mount(h(FileCard, { activity: workspaceActivity(), scope: 'live', workspace: '/repo' }))
assert.equal(queryAll(m1.container, '.lc-fa-file').length, 0)
await m1.unmount()
// No workspace: only the absolute rows resolve; relative ones stay inert.
const opened: string[] = []
const m2 = await mount(h(FileCard, {
activity: workspaceActivity(),
scope: 'live',
onOpen: p => { opened.push(p) },
}))
assert.equal(queryAll(m2.container, '.lc-fa-file').length, 2)
await click(baseOf(rowOf(m2.container, 'rel/b.md')))
assert.deepEqual(opened, [])
await m2.unmount()
})
test('with the Sidebar preview available the name previews, and a refusal falls through to the system opener', async () => {
const previewed: FileEntry[] = []
const opened: string[] = []
const m = await mount(h(FileCard, {
activity: workspaceActivity(),
scope: 'live',
workspace: '/repo',
onPreview: e => { previewed.push(e); return true },
onOpen: p => { opened.push(p) },
}))
// The preview affordance leads: the title says so, and the click never
// reaches the system opener.
const a = baseOf(rowOf(m.container, '/repo/src/a.ts'))
assert.equal(a.getAttribute('title'), 'Preview in the sidebar')
await click(a)
assert.deepEqual(previewed.map(e => e.path), ['/repo/src/a.ts'])
assert.equal(opened.length, 0)
// A refusal (no preview type claims it, no surface mounted) still opens on
// the system rather than becoming an inert click.
await m.unmount()
const m2 = await mount(h(FileCard, {
activity: workspaceActivity(),
scope: 'live',
workspace: '/repo',
onPreview: () => false,
onOpen: p => { opened.push(p) },
}))
await click(baseOf(rowOf(m2.container, '/repo/src/a.ts')))
assert.deepEqual(opened, ['/repo/src/a.ts'])
await m2.unmount()
})
test('the preview opener never claims pattern or directory rows', async () => {
const dirActivity = workspaceActivity()
dirActivity.entries.push(entry('/repo/src/', { form: 'dir', searches: 1, ops: [fileOp(0, 'search', 'grep')] }))
const previewed: FileEntry[] = []
const m = await mount(h(FileCard, {
activity: dirActivity,
scope: 'live',
workspace: '/repo',
onPreview: e => { previewed.push(e); return true },
}))
// Neither is a file a preview type can claim: both render inert.
assert.equal(baseOf(rowOf(m.container, 'needle')).className.includes('lc-fa-file'), false)
assert.equal(baseOf(rowOf(m.container, '/repo/src/')).className.includes('lc-fa-file'), false)
// The real file beside them still previews.
await click(baseOf(rowOf(m.container, '/repo/src/a.ts')))
assert.deepEqual(previewed.map(e => e.path), ['/repo/src/a.ts'])
await m.unmount()
})
test('a refused preview with no resolvable absolute path is a silent no-op', async () => {
// Relative row with no workspace and no system opener: the preview is the
// only affordance, so a refusal leaves nothing to fall through to.
const m = await mount(h(FileCard, {
activity: workspaceActivity(),
scope: 'live',
onPreview: () => false,
}))
const name = baseOf(rowOf(m.container, 'rel/b.md'))
assert.equal(name.getAttribute('title'), 'Preview in the sidebar')
await click(name)
await m.unmount()
})
})
describe('FileCard — the split generation detail states', () => {
test('a pending detail read shows the loading note instead of the empty claim', async () => {
const m = await mount(h(FileCard, {
activity: richActivity({ entries: [] }),
scope: 'live',
state: 'loading',
onRetry: () => {},
}))
assert.ok(text(m.container).includes('Loading history'))
assert.ok(!text(m.container).includes('No file reads'))
await m.unmount()
})
test('a failed detail read arms the retry button; without one the empty claim stays', async () => {
let retries = 0
const m = await mount(h(FileCard, {
activity: richActivity({ entries: [] }),
scope: 'live',
state: 'failed',
onRetry: () => { retries++ },
}))
await click(query(m.container, '.lc-br-retry'))
assert.equal(retries, 1)
const inert = await mount(h(FileCard, { activity: richActivity({ entries: [] }), scope: 'live', state: 'failed' }))
assert.ok(text(inert.container).includes('No file reads'), 'no retry wired — the plain empty state')
await inert.unmount()
await m.unmount()
})
})