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:
1 parent
3ee45f6d81
commit
0cd9762bad
12 files changed
+67
-16
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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) }}
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '该范围内没有文件读取、写入或搜索操作',
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
|
||||
@@ -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' }))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user