feat(client): add a by-name sort to File Activity

The sort toggle ordered rows by counts, recency, and path; the row's own
last segment — the bold name a reader scans for — had no order of its
own, so same-named files in different directories never grouped. Name
leads on the displayed last segment (either separator style, via the
shared leafNameOf) and the full path breaks ties between rows that share
a name.

The Preferences picker, the host Config union, the shared
DefaultFileSort vocabulary, and both dictionaries carry the fourth
option; glyphOf now reuses the same leaf helper instead of its own split.
This commit is contained in:
bowenliang123 committed 2026-10-17 12:47:42 +08:00
1 parent 3ee45f6d81
commit 0cd9762bad
12 files changed
+67 -16

No files matched your search

+11 -3
View File
@@ -1,5 +1,5 @@
import { memo, useEffect, useState, type ChangeEvent, type ComponentType, type MouseEvent, type ReactElement } from 'react'
import { absPathOf, displayPathOf, glyphOf } from '../fileActivity'
import { absPathOf, baseNameOf, displayPathOf, glyphOf } from '../fileActivity'
import type { FileActivity, FileEntry, FileOp, FileOpKind } from '../fileActivity'
import type { ContextSettings, DefaultFileSort } from '../settings'
import type { DetailState } from '../timelineSource'
@@ -74,13 +74,21 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): Component
// Count sort magnitude: the selected kind's own ops under a kind chip, else the file's overall op count.
const countOf = (e: FileEntry): number =>
filter === 'read' ? e.reads : filter === 'write' ? e.writes : filter === 'search' ? e.searches : e.ops.length
// Name sort: the displayed last segment leads, the full path breaks ties between same-named files.
const byName = (a: FileEntry, b: FileEntry): number => {
const an = baseNameOf(displayOf(a))
const bn = baseNameOf(displayOf(b))
return an !== bn ? (an < bn ? -1 : 1) : (a.path < b.path ? -1 : 1)
}
const shown = activity.entries
.filter(e => matches(e, filter) && (q === '' || displayOf(e).toLowerCase().includes(q)))
.sort((a, b) => sort === 'count'
? (countOf(b) - countOf(a)) || (b.ops[0].seq - a.ops[0].seq)
: sort === 'latest'
? b.ops[0].seq - a.ops[0].seq
: (a.path < b.path ? -1 : 1))
: sort === 'name'
? byName(a, b)
: (a.path < b.path ? -1 : 1))
const chips: { key: FileFilter; files: number; ops: number }[] = [
{
@@ -165,7 +173,7 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): Component
</span>
) : null}
<span className="lc-gran lc-fa-sort" role="group" title={t('files.sortTip')}>
{(['count', 'latest', 'path'] as const).map(k => (
{(['count', 'latest', 'path', 'name'] as const).map(k => (
<button
key={k}
type="button"
+1
View File
@@ -199,6 +199,7 @@ function PreferenceRows(props: { t: Translate; state: SettingsState; set?: Setti
{ id: 'count', label: t('files.sort.count') },
{ id: 'latest', label: t('files.sort.latest') },
{ id: 'path', label: t('files.sort.path') },
{ id: 'name', label: t('files.sort.name') },
]}
onPick={(id) => { set?.('defaultFileSort', id) }}
/>
+7 -2
View File
@@ -10,6 +10,7 @@
import type { FileOpRecord, RequestRecord, SurfaceNode } from '../shared/types'
import { opsOfCall, parseCallArgs } from '../shared/fileOps'
import { leafNameOf } from '../shared/attachment'
// The harness's browser-safe file-address layer, inlined by the client bundle:
// the produced address is the `dsh-resource://file/…` spelling the preview claims.
import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path'
@@ -152,10 +153,14 @@ function fileGlyph(base: string): FileGlyph {
return { glyph: '📄', tip: 'files.form.text' }
}
/** The last segment of a path, either separator style, a trailing slash trimmed first. */
export function baseNameOf(path: string): string {
return leafNameOf(path.endsWith('/') ? path.slice(0, -1) : path)
}
export function glyphOf(path: string, form: FileForm): FileGlyph {
if (form === 'image') return { glyph: '🖼', tip: 'files.form.image' }
const trimmed = path.endsWith('/') ? path.slice(0, -1) : path
const base = trimmed.slice(trimmed.lastIndexOf('/') + 1)
const base = baseNameOf(path)
return form === 'dir' ? dirGlyph(base) : fileGlyph(base)
}
+2 -1
View File
@@ -162,7 +162,8 @@ export const DICT_EN = {
'files.sort.count': 'Most active',
'files.sort.latest': 'Latest',
'files.sort.path': 'By path',
'files.sortTip': 'Sort files by operation counts (of the selected kind when a kind chip is active), most recent activity, or path',
'files.sort.name': 'By name',
'files.sortTip': 'Sort files by operation counts (of the selected kind when a kind chip is active), most recent activity, path, or file name',
'files.files': '{n} files',
'files.deltaTip': 'Cumulative line change estimated from edit/write call arguments: + added / − removed',
'files.empty': 'No file reads, writes, or searches in this range',
+2 -1
View File
@@ -160,7 +160,8 @@ export const DICT_ZH = {
'files.sort.count': '按次数',
'files.sort.latest': '按最新',
'files.sort.path': '按路径',
'files.sortTip': '文件排序:按操作次数(选中分类时按该分类的次数)、最近操作时间或路径',
'files.sort.name': '按文件名',
'files.sortTip': '文件排序:按操作次数(选中分类时按该分类的次数)、最近操作时间、路径或文件名',
'files.files': '{n} 个文件',
'files.deltaTip': '按 edit/write 调用参数估算的累计行数变化:+ 新增 / − 删除',
'files.empty': '该范围内没有文件读取、写入或搜索操作',
+1 -1
View File
@@ -76,7 +76,7 @@ function prefsOf(value: unknown): Prefs {
...(v.defaultTrendMode === 'total' || v.defaultTrendMode === 'delta' ? { mode: v.defaultTrendMode } : {}),
...(v.defaultDeltaBase === 'step' || v.defaultDeltaBase === 'turn' ? { deltaBase: v.defaultDeltaBase } : {}),
...(v.defaultToolSort === 'size' || v.defaultToolSort === 'count' || v.defaultToolSort === 'name' ? { toolSort: v.defaultToolSort } : {}),
...(v.defaultFileSort === 'count' || v.defaultFileSort === 'latest' || v.defaultFileSort === 'path' ? { fileSort: v.defaultFileSort } : {}),
...(v.defaultFileSort === 'count' || v.defaultFileSort === 'latest' || v.defaultFileSort === 'path' || v.defaultFileSort === 'name' ? { fileSort: v.defaultFileSort } : {}),
...(v.insightsEntry === 'show' || v.insightsEntry === 'hide' ? { insightsEntry: v.insightsEntry } : {}),
...(v.defaultDurationCurve === 'show' || v.defaultDurationCurve === 'hide' ? { durationCurve: v.defaultDurationCurve } : {}),
...(v.fleetTab === 'show' || v.fleetTab === 'hide' ? { fleetTab: v.fleetTab } : {}),
+1 -1
View File
@@ -52,7 +52,7 @@ export const Config = z.object({
defaultTrendMode: volatileField(z.union(['total', 'delta']).default('total').loose()),
defaultDeltaBase: volatileField(z.union(['step', 'turn']).default('step').loose()),
defaultToolSort: volatileField(z.union(['size', 'count', 'name']).default('count').loose()),
defaultFileSort: volatileField(z.union(['count', 'latest', 'path']).default('count').loose()),
defaultFileSort: volatileField(z.union(['count', 'latest', 'path', 'name']).default('count').loose()),
insightsEntry: volatileField(z.union(['show', 'hide']).default('show').loose()),
defaultDurationCurve: volatileField(z.union(['show', 'hide']).default('show').loose()),
fleetTab: volatileField(z.union(['show', 'hide']).default('show').loose()),
+1 -1
View File
@@ -152,7 +152,7 @@ export type DefaultTrendMode = 'total' | 'delta'
/** The browser delta baseline: against the immediately preceding record, or the previous turn's last step. */
export type DefaultDeltaBase = 'step' | 'turn'
export type DefaultFileSort = 'count' | 'latest' | 'path'
export type DefaultFileSort = 'count' | 'latest' | 'path' | 'name'
export type DefaultToolSort = 'size' | 'count' | 'name'
+32 -2
View File
@@ -226,11 +226,11 @@ describe('FileCard — header, rows, and filters', () => {
await m.unmount()
})
test('the sort toggle defaults to most-active and switches to latest and path', async () => {
test('the sort toggle defaults to most-active and switches to latest, path, and name', 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)
assert.equal(sortBtns.length, 4)
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).
@@ -241,12 +241,36 @@ describe('FileCard — header, rows, and filters', () => {
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'])
// Name order: ascending over the last segment (a.ts → many.log → solo.md → src → ui.png).
await click(sortBtns[3])
assert.ok(sortBtns[3].className.includes('lc-gran-on'))
assert.deepEqual(titles(), ['/src/a.ts', '/var/many.log', 'solo.md', '/src/', '/shots/ui.png'])
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 name sort leads with the last segment and breaks ties on the full path', async () => {
const readOne = (seq: number, path: string): FileEntry => entry(path, { reads: 1, ops: [fileOp(seq, 'read', 'read')] })
const activity: FileActivity = {
entries: [
readOne(4, '/b/x.ts'),
readOne(3, '/d/x.ts'),
readOne(2, '/a/x.ts'),
readOne(1, '/a/z.ts'),
readOne(5, 'C:\\repo\\x.ts'),
],
totals: { read: { files: 5, ops: 5 }, write: { files: 0, ops: 0 }, search: { files: 0, ops: 0 }, image: { files: 0, ops: 0 }, added: 0, removed: 0 },
}
const m = await mount(h(FileCard, { activity, scope: 'live' }))
const sortBtns = queryAll(m.container, '.lc-fa-sort .lc-gran-btn')
await click(sortBtns[3])
// x.ts groups ahead of z.ts by leaf (backslash leaves count); within one leaf, the full path ascends.
assert.deepEqual(queryAll(m.container, '.lc-fa-row').map(r => r.title), ['/a/x.ts', '/b/x.ts', '/d/x.ts', 'C:\\repo\\x.ts', '/a/z.ts'])
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')
@@ -263,6 +287,12 @@ describe('FileCard — header, rows, and filters', () => {
assert.deepEqual(titles(m.container), ['/shots/ui.png', '/src/', '/src/a.ts', '/var/many.log', 'solo.md'])
await m.unmount()
settings.set('defaultFileSort', 'name')
m = await mount(h(FileCard, { activity: richActivity(), scope: 'live' }))
assert.ok(sortBtns(m.container)[3].className.includes('lc-gran-on'))
assert.deepEqual(titles(m.container), ['/src/a.ts', '/var/many.log', 'solo.md', '/src/', '/shots/ui.png'])
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' }))
+3 -2
View File
@@ -185,8 +185,9 @@ describe('PluginConfigCard (the Plugins-page seat)', () => {
DICT_EN['files.sort.count'],
DICT_EN['files.sort.latest'],
DICT_EN['files.sort.path'],
DICT_EN['files.sort.name'],
])
await click(sortItems[2]) // 'By path'
await click(sortItems[3]) // 'By name'
assert.deepEqual(calls, [
['defaultPlacement', 'sidebar'],
['insightsEntry', 'hide'],
@@ -196,7 +197,7 @@ describe('PluginConfigCard (the Plugins-page seat)', () => {
['defaultDurationCurve', 'hide'],
['defaultDeltaBase', 'turn'],
['defaultToolSort', 'name'],
['defaultFileSort', 'path'],
['defaultFileSort', 'name'],
])
assert.equal(document.body.querySelector('[role="menu"]'), null)
+4
View File
@@ -606,6 +606,10 @@ describe('glyphOf', () => {
for (const [path, glyph, tip] of cases) assert.deepEqual(glyphOf(path, 'dir'), { glyph, tip }, path)
})
test('a Windows-style path keys on its backslash leaf too', () => {
assert.deepEqual(glyphOf('C:\\repo\\.gitignore', 'text'), { glyph: '🚫', tip: 'files.glyph.ignore' })
})
test('files resolve special names before extensions, and buckets before the fallback', () => {
const cases: [string, string, string][] = [
['pnpm-lock.yaml', '🔒', 'files.glyph.lock'],
+2 -2
View File
@@ -393,9 +393,9 @@ describe('attach', () => {
scope.emit({ status: 'ready', value: { defaultGranularity: 'turn', defaultTrendMode: 'delta' }, writable: true })
assert.equal(calls, 2)
assert.equal(s.defaultTrendMode(), 'delta')
scope.emit({ status: 'ready', value: { defaultFileSort: 'path' }, writable: true })
scope.emit({ status: 'ready', value: { defaultFileSort: 'name' }, writable: true })
assert.equal(calls, 3)
assert.equal(s.defaultFileSort(), 'path')
assert.equal(s.defaultFileSort(), 'name')
scope.emit({ status: 'ready', value: { defaultPlacement: 'sidebar' }, writable: true })
assert.equal(calls, 4)
assert.equal(s.defaultPlacement(), 'sidebar')