dsh-file-activity 0.4.7 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  本文件记录 dsh-file-activity 的所有版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.5.1] - 2026-08-27
6
+
7
+ ### 变更
8
+
9
+ - feat(file-activity): 侧边栏页签选中态改用品牌蓝,三态一眼可分(issue #25)
10
+ - feat(file-activity): 文件活动列表按文件类型显示专属彩色图标(issue #24)
11
+
12
+ ## [0.5.0] - 2026-08-26
13
+
14
+ ### 变更
15
+
16
+ - feat(file-activity): bash 命令文件操作识别(rm/touch/mv/cp/tee/重定向)+ 0 值计数徽标过滤
17
+ - docs+test: 全面审查修复——文档同步补全 + mermaid 测试增强
18
+
5
19
  ## [0.4.7] - 2026-08-25
6
20
 
7
21
  ### 变更
package/README.md CHANGED
@@ -36,6 +36,9 @@
36
36
 
37
37
  ## 安装
38
38
 
39
+ > 💡 **npm 安装(普通用户推荐)**:`dsh plugin --profile web add dsh-file-activity`——无需克隆本仓库;以下 link 方式供本仓库开发者使用。
40
+
41
+
39
42
  前置:已安装 `dsh-better-sidebar`(v0.12+,推荐 v0.14)。
40
43
 
41
44
  ```sh
@@ -0,0 +1,99 @@
1
+ import { homedir } from 'node:os'
2
+ import { isAbsolute, join, normalize, resolve } from 'node:path'
3
+
4
+ /**
5
+ * bash-ops.js — command → file-operation mapping for the bash intent parser
6
+ * (see bash-parse.js for the segment/token layer).
7
+ *
8
+ * Conservative by design: only unambiguous commands map to ops; paths the
9
+ * parser cannot resolve statically (option-argument pairs, `-t` relocations,
10
+ * sed without -i, unknown commands) contribute nothing.
11
+ */
12
+ /** Options of a command that consume the following argument (never a path). */
13
+ function optionArgsOf(cmd) {
14
+ if (cmd === 'touch') return new Set(['-d', '-t', '-r', '--date', '--time', '--reference'])
15
+ return new Set()
16
+ }
17
+
18
+ /**
19
+ * Path arguments with options stripped: `-rf` / `--force` / `--` are skipped;
20
+ * options listed in `optionArgs` consume their following argument too.
21
+ */
22
+ export function positionalArgs(args, optionArgs) {
23
+ const paths = []
24
+ let afterDashDash = false
25
+ for (let i = 0; i < args.length; i += 1) {
26
+ const token = args[i]
27
+ if (!afterDashDash && token.text === '--') {
28
+ afterDashDash = true
29
+ continue
30
+ }
31
+ if (!afterDashDash && token.text.startsWith('-') && token.text !== '-') {
32
+ if (optionArgs.has(token.text)) i += 1
33
+ continue
34
+ }
35
+ paths.push(token)
36
+ }
37
+ return paths
38
+ }
39
+
40
+ /**
41
+ * -t / --target-directory variants (cp/mv/install) relocate sources into a
42
+ * directory; statically resolving the real destination is unreliable, so the
43
+ * whole segment is skipped (conservative: never record a wrong path).
44
+ */
45
+ function hasTargetDirOption(cmd, args) {
46
+ if (cmd !== 'mv' && cmd !== 'cp' && cmd !== 'install') return false
47
+ return args.some((token) => token.text === '-t' || token.text === '--target-directory')
48
+ }
49
+
50
+ /** Dispatch one segment's command to the matching file-op mapping. */
51
+ export function pushCommandOps(ops, cmd, args, cwd) {
52
+ if (hasTargetDirOption(cmd, args)) return
53
+ const optionArgs = optionArgsOf(cmd)
54
+ const paths = positionalArgs(args, optionArgs)
55
+ if (cmd === 'rm') return pushAll(ops, 'delete', paths, cwd)
56
+ if (cmd === 'touch' || cmd === 'tee') return pushAll(ops, 'write', paths, cwd)
57
+ if (cmd === 'mv') return pushMv(ops, paths, cwd)
58
+ if (cmd === 'cp' || cmd === 'install') return pushLast(ops, paths, cwd)
59
+ if (cmd === 'sed' && hasInPlace(args)) pushLast(ops, positionalArgs(args, optionArgs), cwd)
60
+ }
61
+
62
+ /** All positional paths get the same op (rm / touch / tee). */
63
+ function pushAll(ops, op, paths, cwd) {
64
+ for (const token of paths) pushOp(ops, op, token, cwd)
65
+ }
66
+
67
+ /** Only the last path is the write destination (cp / install). */
68
+ function pushLast(ops, paths, cwd) {
69
+ if (paths.length === 0) return
70
+ pushOp(ops, 'write', paths[paths.length - 1], cwd)
71
+ }
72
+
73
+ /** mv: all but the last are sources (delete), the last is the destination. */
74
+ function pushMv(ops, paths, cwd) {
75
+ if (paths.length === 0) return
76
+ for (let i = 0; i < paths.length - 1; i += 1) pushOp(ops, 'delete', paths[i], cwd)
77
+ pushOp(ops, 'write', paths[paths.length - 1], cwd)
78
+ }
79
+
80
+ /** sed writes files only with -i / --in-place (also -i.bak style). */
81
+ function hasInPlace(args) {
82
+ return args.some((token) => /^-i($|[A-Za-z0-9_.])/.test(token.text) || token.text === '--in-place')
83
+ }
84
+
85
+ export function pushOp(ops, op, token, cwd) {
86
+ const path = resolveSafe(token, cwd)
87
+ if (path !== '') ops.push({ op, path })
88
+ }
89
+
90
+ /** Resolve a token to an absolute path; '' when unsafe or unusable. */
91
+ export function resolveSafe(token, cwd) {
92
+ if (!token.safe || token.text === '') return ''
93
+ let path = token.text
94
+ if (path === '~') return homedir()
95
+ if (path.startsWith('~/')) path = join(homedir(), path.slice(2))
96
+ if (path.includes('\0')) return ''
97
+ const abs = isAbsolute(path) ? normalize(path) : resolve(cwd, path)
98
+ return normalize(abs)
99
+ }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Lightweight static parser for bash command text → file-touch intents.
3
+ *
4
+ * Feeds the `tools/pre-execute` observer (index.js): a bash tool call carries
5
+ * the exact command string, and we resolve the file operations it is about to
6
+ * perform. Conservative by design — 宁可漏报也不误报:
7
+ *
8
+ * - only well-known commands with unambiguous file effects are recognized
9
+ * (rm → delete; touch / cp / install / tee / sed -i / `> file` → write;
10
+ * mv → source delete + destination write) — see bash-ops.js;
11
+ * - paths containing variables ($…), command substitution ($(…), `…`),
12
+ * globs (* ? [ {) or fd redirections (2>&1) are skipped;
13
+ * - unknown commands contribute nothing (only their `>` redirects count);
14
+ * - relative paths resolve against the base dir (workdir / session cwd),
15
+ * and `cd <dir>` segments update the base for the following segments.
16
+ *
17
+ * The returned operations are applied through the regular record pipeline
18
+ * ('write' is classified create/modify by the known-file registry, 'delete'
19
+ * removes the file from stats), so the view stays consistent with the disk.
20
+ */
21
+ import { pushCommandOps, positionalArgs, resolveSafe, pushOp } from './bash-ops.js'
22
+
23
+ /** Prefix wrappers that do not touch files themselves. */
24
+ const PREFIX_CMDS = new Set(['sudo', 'nohup', 'command', 'time', 'env'])
25
+
26
+ /** Parse one bash command into [{ op: 'write'|'delete', path }] (deduped). */
27
+ export function parseBashFileOps(command, baseDir) {
28
+ const ops = []
29
+ let cwd = baseDir
30
+ for (const segment of splitSegments(command)) {
31
+ const tokens = tokenize(segment)
32
+ const start = commandStartOf(tokens)
33
+ if (start < 0) continue
34
+ const cmd = basenameOf(tokens[start].text)
35
+ const args = tokens.slice(start + 1)
36
+ if (cmd === 'cd') {
37
+ const target = cdTargetOf(args)
38
+ if (target !== '') cwd = resolveSafe({ text: target, safe: true }, cwd)
39
+ continue
40
+ }
41
+ pushCommandOps(ops, cmd, args, cwd)
42
+ pushRedirectOps(ops, tokens, cwd)
43
+ }
44
+ return dedupeOps(ops)
45
+ }
46
+
47
+ // ── quoting pre-pass ───────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Per-char quote state: 0 = unquoted, 1 = single-quoted, 2 = double-quoted,
51
+ * -1 = the quote character itself (kept in segment text, stripped by
52
+ * tokenize). Escape handling: outside quotes and inside double quotes a
53
+ * backslash makes the next char literal; inside single quotes backslash is
54
+ * literal (bash). The output array has one entry per input character.
55
+ */
56
+ function quoteMarks(text) {
57
+ const marks = []
58
+ let quote = 0
59
+ let i = 0
60
+ while (i < text.length) {
61
+ const step = quoteStep(text, i, quote)
62
+ marks.push(...step.marks)
63
+ quote = step.quote
64
+ i += step.advance
65
+ }
66
+ return marks
67
+ }
68
+
69
+ /** One character's quote transition: { marks, quote, advance }. */
70
+ function quoteStep(text, i, quote) {
71
+ const ch = text[i]
72
+ if (ch === '\\' && quote !== 1 && i + 1 < text.length) return { marks: [quote, quote], quote, advance: 2 }
73
+ if (quote !== 0 && ch === quoteCharOf(quote)) return { marks: [-1], quote: 0, advance: 1 }
74
+ if (quote === 0 && isQuoteChar(ch)) return { marks: [-1], quote: quoteOf(ch), advance: 1 }
75
+ return { marks: [quote], quote, advance: 1 }
76
+ }
77
+
78
+ function quoteCharOf(quote) {
79
+ return quote === 1 ? "'" : '"'
80
+ }
81
+
82
+ function quoteOf(ch) {
83
+ return ch === "'" ? 1 : 2
84
+ }
85
+
86
+ function isQuoteChar(ch) {
87
+ return ch === "'" || ch === '"'
88
+ }
89
+
90
+ // ── segment splitting ──────────────────────────────────────────────────────
91
+
92
+ /**
93
+ * Split a command on the top-level separators && || ; | & and newlines.
94
+ * Quoted separators never split; a bare `&` (background marker) splits only
95
+ * when followed by whitespace/end — `2>&1` stays one word.
96
+ */
97
+ export function splitSegments(command) {
98
+ const marks = quoteMarks(command)
99
+ const segments = []
100
+ let current = ''
101
+ for (let i = 0; i < command.length; i += 1) {
102
+ if (marks[i] !== 0 || !isSeparator(command, i)) {
103
+ current += command[i]
104
+ continue
105
+ }
106
+ flushSegment(segments, current)
107
+ current = ''
108
+ i += separatorLength(command, i) - 1
109
+ }
110
+ flushSegment(segments, current)
111
+ return segments
112
+ }
113
+
114
+ function isSeparator(command, i) {
115
+ const ch = command[i]
116
+ if (ch === '&') return command[i + 1] === '&' || i + 1 >= command.length || /\s/.test(command[i + 1])
117
+ return ch === '|' || ch === ';' || ch === '\n' || ch === '\r'
118
+ }
119
+
120
+ function separatorLength(command, i) {
121
+ return command[i] === '&' && command[i + 1] === '&' ? 2 : 1
122
+ }
123
+
124
+ function flushSegment(segments, current) {
125
+ const trimmed = current.trim()
126
+ if (trimmed !== '') segments.push(trimmed)
127
+ }
128
+
129
+ // ── tokenizing ─────────────────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Split one segment into words. Tokens keep their quoted content (quotes
133
+ * stripped) and a `safe` flag: false when the word contains variables /
134
+ * command substitution / globs — such paths must never be recorded.
135
+ */
136
+ export function tokenize(segment) {
137
+ const marks = quoteMarks(segment)
138
+ const tokens = []
139
+ let current = ''
140
+ let safe = true
141
+ const push = () => {
142
+ if (current !== '') {
143
+ tokens.push({ text: current, safe })
144
+ current = ''
145
+ safe = true
146
+ }
147
+ }
148
+ for (let i = 0; i < segment.length; i += 1) {
149
+ const ch = segment[i]
150
+ if (marks[i] === -1) continue // quote characters are stripped
151
+ if (marks[i] === 0 && /\s/.test(ch)) {
152
+ push()
153
+ continue
154
+ }
155
+ if (isEscape(segment, i, marks[i])) {
156
+ current += segment[i + 1]
157
+ i += 1
158
+ continue
159
+ }
160
+ if (isUnsafeChar(ch, marks[i])) {
161
+ safe = false
162
+ current += ch
163
+ continue
164
+ }
165
+ current += ch
166
+ }
167
+ push()
168
+ return tokens
169
+ }
170
+
171
+ /** Backslash escapes the next char outside quotes and inside double quotes. */
172
+ function isEscape(segment, i, quote) {
173
+ if (segment[i] !== '\\' || quote === 1) return false
174
+ return i + 1 < segment.length
175
+ }
176
+
177
+ /** `$` expands everywhere except single quotes; globs only outside quotes. */
178
+ function isUnsafeChar(ch, quote) {
179
+ if (ch === '$') return quote !== 1
180
+ if (quote !== 0) return false
181
+ return ch === '`' || ch === '*' || ch === '?' || ch === '[' || ch === '{'
182
+ }
183
+
184
+ // ── command head ───────────────────────────────────────────────────────────
185
+
186
+ /** Index of the first real command token (skipping VAR=x and wrappers). */
187
+ function findStartOf(tokens, index) {
188
+ while (index < tokens.length) {
189
+ const token = tokens[index]
190
+ if (isAssignment(token.text) || PREFIX_CMDS.has(basenameOf(token.text))) {
191
+ index += 1
192
+ continue
193
+ }
194
+ return index
195
+ }
196
+ return -1
197
+ }
198
+
199
+ function commandStartOf(tokens) {
200
+ const start = findStartOf(tokens, 0)
201
+ if (start < 0) return -1
202
+ if (basenameOf(tokens[start].text) === 'env') {
203
+ // env VAR=value cmd: assignments after env are skipped too.
204
+ return findStartOf(tokens, start + 1)
205
+ }
206
+ return start
207
+ }
208
+
209
+ function isAssignment(text) {
210
+ const eq = text.indexOf('=')
211
+ if (eq <= 0) return false
212
+ return /^[A-Za-z_][A-Za-z0-9_]*=/.test(text)
213
+ }
214
+
215
+ function basenameOf(text) {
216
+ const slash = text.lastIndexOf('/')
217
+ return slash === -1 ? text : text.slice(slash + 1)
218
+ }
219
+
220
+ // ── paths & redirects ──────────────────────────────────────────────────────
221
+
222
+ /** `> file` / `>> file` / `2> file` redirects write the target file. */
223
+ function pushRedirectOps(ops, tokens, cwd) {
224
+ for (let i = 0; i < tokens.length; i += 1) {
225
+ const match = /^(\d*)>{1,2}(.*)$/.exec(tokens[i].text)
226
+ if (match === null) continue
227
+ const target = redirectTarget(tokens, i, match[2])
228
+ if (target === null) continue
229
+ pushOp(ops, 'write', { text: target.text, safe: target.safe }, cwd)
230
+ }
231
+ }
232
+
233
+ /** The file a redirect writes; null when it is an fd / /dev/null / missing. */
234
+ function redirectTarget(tokens, i, inline) {
235
+ if (inline !== '') return usable(inline, tokens[i].safe) ? { text: inline, safe: tokens[i].safe } : null
236
+ const next = tokens[i + 1]
237
+ if (next === undefined || !next.safe) return null
238
+ return usable(next.text, true) ? { text: next.text, safe: true } : null
239
+ }
240
+
241
+ function usable(text, safe) {
242
+ if (!safe || text === '') return false
243
+ return !text.startsWith('&') && !text.startsWith('/dev/')
244
+ }
245
+
246
+ /** `cd` target: the first positional argument ('' when missing/unsafe). */
247
+ function cdTargetOf(args) {
248
+ const paths = positionalArgs(args)
249
+ if (paths.length === 0) return ''
250
+ return paths[0].safe ? paths[0].text : ''
251
+ }
252
+
253
+ /** Dedupe by op+path, keeping the first occurrence. */
254
+ function dedupeOps(ops) {
255
+ const seen = new Set()
256
+ return ops.filter((op) => {
257
+ const key = `${op.op}\u0000${op.path}`
258
+ if (seen.has(key)) return false
259
+ seen.add(key)
260
+ return true
261
+ })
262
+ }
package/lib/client.js CHANGED
@@ -70,6 +70,7 @@ window.__ModuleLoader__.load({
70
70
  read: () => (isZh() ? '读取' : 'read'),
71
71
  create: () => (isZh() ? '新增' : 'create'),
72
72
  modify: () => (isZh() ? '修改' : 'modify'),
73
+ delete: () => (isZh() ? '删除' : 'delete'),
73
74
  readShort: () => (isZh() ? '读' : 'R'),
74
75
  createShort: () => (isZh() ? '增' : 'C'),
75
76
  modifyShort: () => (isZh() ? '改' : 'M'),
@@ -433,6 +434,102 @@ window.__ModuleLoader__.load({
433
434
  ], size),
434
435
  }
435
436
 
437
+ // Common-language / file-type badges (issue #24): brand fill + contrast
438
+ // ink, reading on both light and dark themes. Unmapped extensions keep the
439
+ // neutral currentColor file icon above. [bg, fg ink, short mark]
440
+ const FILE_BADGES = {
441
+ // JavaScript / TypeScript
442
+ js: ['#F7DF1E', '#323330', 'JS'], mjs: ['#F7DF1E', '#323330', 'JS'], cjs: ['#F7DF1E', '#323330', 'JS'],
443
+ ts: ['#3178C6', '#ffffff', 'TS'], mts: ['#3178C6', '#ffffff', 'TS'], cts: ['#3178C6', '#ffffff', 'TS'],
444
+ tsx: ['#3178C6', '#ffffff', 'TSX'], jsx: ['#3178C6', '#ffffff', 'JSX'],
445
+ // 后端语言
446
+ java: ['#007396', '#ffffff', 'JAVA'],
447
+ c: ['#A8B9CC', '#111111', 'C'],
448
+ cpp: ['#00599C', '#ffffff', 'C++'], cxx: ['#00599C', '#ffffff', 'C++'], cc: ['#00599C', '#ffffff', 'C++'], hpp: ['#00599C', '#ffffff', 'C++'],
449
+ h: ['#A8B9CC', '#111111', 'H'], hh: ['#A8B9CC', '#111111', 'H'],
450
+ cs: ['#68217A', '#ffffff', 'C#'], csharp: ['#68217A', '#ffffff', 'C#'],
451
+ go: ['#00ADD8', '#ffffff', 'GO'],
452
+ rs: ['#CE422B', '#ffffff', 'RS'],
453
+ rb: ['#B51624', '#ffffff', 'RB'],
454
+ php: ['#777BB4', '#ffffff', 'PHP'],
455
+ py: ['#3776AB', '#ffffff', 'PY'],
456
+ swift: ['#F05138', '#ffffff', 'SWIFT'],
457
+ kt: ['#7F52FF', '#ffffff', 'KT'], kotlin: ['#7F52FF', '#ffffff', 'KT'],
458
+ dart: ['#0175C2', '#ffffff', 'DART'],
459
+ scala: ['#DC322F', '#ffffff', 'SCALA'],
460
+ lua: ['#2C2C7C', '#ffffff', 'LUA'],
461
+ pl: ['#0298C3', '#ffffff', 'PERL'],
462
+ r: ['#336DC3', '#ffffff', 'R'],
463
+ m: ['#C1272D', '#ffffff', 'MAT'], mm: ['#C1272D', '#ffffff', 'MAT'],
464
+ // Web / 前端
465
+ html: ['#E34F26', '#ffffff', '</>'], htm: ['#E34F26', '#ffffff', '</>'],
466
+ css: ['#663399', '#ffffff', 'CSS'],
467
+ scss: ['#CD6799', '#ffffff', 'SCSS'], sass: ['#CD6799', '#ffffff', 'SCSS'],
468
+ vue: ['#42B883', '#ffffff', 'VUE'],
469
+ svelte: ['#FF3E00', '#ffffff', 'SVELTE'],
470
+ // 数据 / 结构化
471
+ json: ['#F7DF1E', '#323330', '{}'],
472
+ sql: ['#00758F', '#ffffff', 'SQL'],
473
+ csv: ['#2E7D32', '#ffffff', 'CSV'],
474
+ db: ['#0F62FE', '#ffffff', 'DB'], sqlite: ['#0F62FE', '#ffffff', 'DB'], sqlite3: ['#0F62FE', '#ffffff', 'DB'],
475
+ xml: ['#FF6F00', '#ffffff', 'XML'],
476
+ svg: ['#FF6F00', '#ffffff', 'SVG'],
477
+ // 文档
478
+ md: ['#42A5F5', '#ffffff', 'M↓'], markdown: ['#42A5F5', '#ffffff', 'M↓'],
479
+ txt: ['#90A4AE', '#ffffff', 'TXT'], text: ['#90A4AE', '#ffffff', 'TXT'], log: ['#90A4AE', '#ffffff', 'TXT'],
480
+ pdf: ['#E5202B', '#ffffff', 'PDF'],
481
+ doc: ['#2B579A', '#ffffff', 'DOC'], docx: ['#2B579A', '#ffffff', 'DOC'],
482
+ xls: ['#217346', '#ffffff', 'XLS'], xlsx: ['#217346', '#ffffff', 'XLS'],
483
+ ppt: ['#D24726', '#ffffff', 'PPT'], pptx: ['#D24726', '#ffffff', 'PPT'],
484
+ // 配置 / 构建
485
+ yml: ['#CB171E', '#ffffff', 'YML'], yaml: ['#CB171E', '#ffffff', 'YML'],
486
+ toml: ['#8D6E63', '#ffffff', 'TOML'],
487
+ ini: ['#546E7A', '#ffffff', 'CFG'], cfg: ['#546E7A', '#ffffff', 'CFG'], config: ['#546E7A', '#ffffff', 'CFG'],
488
+ env: ['#F9A825', '#323330', 'ENV'],
489
+ properties: ['#7B1FA2', '#ffffff', 'PROP'],
490
+ lock: ['#37474F', '#ffffff', 'LOCK'],
491
+ dockerfile: ['#2496ED', '#ffffff', 'DOCK'], docker: ['#2496ED', '#ffffff', 'DOCK'],
492
+ makefile: ['#607D8B', '#ffffff', 'MAKE'],
493
+ gradle: ['#02303A', '#ffffff', 'GRADLE'],
494
+ cmake: ['#265774', '#ffffff', 'CMAKE'],
495
+ ipynb: ['#F37726', '#ffffff', 'JNB'],
496
+ // 脚本 / Shell
497
+ sh: ['#89E051', '#111111', '>_'], bash: ['#89E051', '#111111', '>_'], zsh: ['#89E051', '#111111', '>_'],
498
+ ps1: ['#012456', '#ffffff', 'PS1'],
499
+ bat: ['#546E7A', '#ffffff', 'CMD'], cmd: ['#546E7A', '#ffffff', 'CMD'],
500
+ // 打包 / 二进制
501
+ zip: ['#FFA726', '#323330', 'ZIP'], tar: ['#FFA726', '#323330', 'ZIP'], gz: ['#FFA726', '#323330', 'ZIP'],
502
+ '7z': ['#FFA726', '#323330', 'ZIP'], rar: ['#FFA726', '#323330', 'ZIP'],
503
+ exe: ['#0078D4', '#ffffff', 'EXE'], msi: ['#0078D4', '#ffffff', 'EXE'],
504
+ wasm: ['#654FF0', '#ffffff', 'WASM'],
505
+ // 图片 / 媒体
506
+ png: ['#8E44AD', '#ffffff', 'IMG'], jpg: ['#8E44AD', '#ffffff', 'IMG'], jpeg: ['#8E44AD', '#ffffff', 'IMG'],
507
+ gif: ['#8E44AD', '#ffffff', 'IMG'], webp: ['#8E44AD', '#ffffff', 'IMG'], ico: ['#8E44AD', '#ffffff', 'IMG'], bmp: ['#8E44AD', '#ffffff', 'IMG'],
508
+ // 版本控制
509
+ gitignore: ['#F05032', '#ffffff', 'GIT'], gitattributes: ['#F05032', '#ffffff', 'GIT'],
510
+ }
511
+
512
+ /** One self-colored badge svg: rounded brand rect + short contrast mark.
513
+ * Mark font scales by length so 5-6 char marks (JAVA/SCALA/SWIFT) stay
514
+ * inside the 24×24 viewBox. */
515
+ const badgeIcon = ([bg, fg, mark], size) =>
516
+ createElement('svg', {
517
+ width: size, height: size, viewBox: '0 0 24 24', 'aria-hidden': 'true',
518
+ },
519
+ createElement('rect', { x: 1, y: 1, width: 22, height: 22, rx: 5, fill: bg }),
520
+ createElement('text', {
521
+ x: 12, y: 16, textAnchor: 'middle', fontSize: mark.length <= 2 ? 9 : mark.length <= 4 ? 7 : 5.5,
522
+ fontWeight: 700, fill: fg,
523
+ }, mark))
524
+
525
+ /** File-type icon dispatcher: branded badge for known extensions, the
526
+ * neutral file icon for everything else (case-insensitive, tolerates a
527
+ * leading dot like ".md"). */
528
+ const fileIconByExt = (ext, size = 14) => {
529
+ const spec = FILE_BADGES[String(ext ?? '').toLowerCase().replace(/^\./, '')]
530
+ return spec === undefined ? icon.file(size) : badgeIcon(spec, size)
531
+ }
532
+
436
533
  // ── themed stylesheet (injected once per activation) ──────────────────
437
534
  // Mirrors the better-sidebar explorer surface: tight 2px 6px 8px body,
438
535
  // 30px rows, box-sizing border-box indentation, folder rows use the
@@ -479,6 +576,7 @@ window.__ModuleLoader__.load({
479
576
  .dfa-op-create { color:var(--dsw-alias-state-success-primary); background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent); }
480
577
  .dfa-op-modify { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
481
578
  .dfa-op-read { color:var(--dsw-alias-accent); background:color-mix(in srgb, var(--dsw-alias-accent) 12%, transparent); }
579
+ .dfa-op-delete { color:var(--dsw-alias-state-danger-primary); background:color-mix(in srgb, var(--dsw-alias-state-danger-primary) 14%, transparent); }
482
580
  .dfa-counts { flex:none; display:flex; align-items:center; gap:3px; }
483
581
  .dfa-count { flex:none; display:inline-flex; align-items:center; justify-content:center; height:15px; padding:0 4px; border-radius:4px;
484
582
  font:var(--dsw-font-xxxs-strong-11); }
@@ -508,11 +606,27 @@ window.__ModuleLoader__.load({
508
606
  .dfa-pdf-download:hover { text-decoration:underline; }
509
607
  .dfa-pdf-frame { flex:1; min-height:0; width:100%; border:none; border-radius:6px; background:transparent; }
510
608
  @keyframes dfa-row-in { from { opacity:0; transform:translateY(1px); } to { opacity:1; transform:none; } }
609
+ /* ── sidebar tab selected-state overrides (issue #25) ─────────────────────
610
+ The host (dsh-better-sidebar) renders tabs with CSS-modules hashed class
611
+ names (e.g. tabActive_xxxxx) and no data attributes, so the selected tab
612
+ is targeted via the [class*="tabActive"] substring selector — the fallback
613
+ contract that keeps these rules working if the host ever renames its
614
+ classes. The double attribute selector [class*="tab"][class*="tabActive"]
615
+ (0,2,0) beats the host .tabActive (0,1,0) by specificity alone: no
616
+ !important, no reliance on injection order. Selected = theme-aware brand
617
+ fill + contrast ink (light #4176e6/#fff, dark #679efe/#0f1115); the :hover
618
+ variant (0,3,0) beats the host .tab:hover (0,2,0) so the selected state
619
+ never collapses into the host's grey hover. Unselected tabs stay untouched
620
+ (transparent + secondary label ink, host grey hover). */
621
+ [class*="tab"][class*="tabActive"] { background:var(--dsw-alias-state-business-primary); color:var(--dsw-alias-label-primary-foreground); }
622
+ [class*="tab"][class*="tabActive"]:hover { background:var(--dsw-alias-state-business-primary); color:var(--dsw-alias-label-primary-foreground); }
623
+ [class*="tab"][class*="tabActive"] [class*="tabClose"] { color:var(--dsw-alias-label-primary-foreground); }
624
+ [class*="tab"][class*="tabActive"] [class*="tabClose"]:hover { background:color-mix(in srgb, var(--dsw-alias-label-primary-foreground) 18%, transparent); color:var(--dsw-alias-label-primary-foreground); }
511
625
  `
512
626
 
513
627
  // ── row rendering helpers (recent list & stats tree) ──────────────────
514
- const opClass = (op) => (op === 'create' ? 'dfa-op-create' : op === 'modify' ? 'dfa-op-modify' : 'dfa-op-read')
515
- const opLabel = (op) => (op === 'create' ? strings.create() : op === 'modify' ? strings.modify() : strings.read())
628
+ const opClass = (op) => (op === 'create' ? 'dfa-op-create' : op === 'modify' ? 'dfa-op-modify' : op === 'delete' ? 'dfa-op-delete' : 'dfa-op-read')
629
+ const opLabel = (op) => (op === 'create' ? strings.create() : op === 'modify' ? strings.modify() : op === 'delete' ? strings.delete() : strings.read())
516
630
 
517
631
  /** Tooltip for a stats file row: absolute path + created / last-seen times. */
518
632
  const fileTitle = (abs, firstSeen, lastSeen) => {
@@ -522,13 +636,40 @@ window.__ModuleLoader__.load({
522
636
  return times.length > 0 ? `${abs}\n${times.join(' · ')}` : abs
523
637
  }
524
638
 
525
- /** Three colored count pills for a file/dir node (read/create/modify). */
526
- const countPills = (node) =>
527
- createElement('span', { className: 'dfa-counts', style: { paddingLeft: '6px' } },
528
- createElement('span', { className: 'dfa-count dfa-count-read' }, `${strings.readShort()} ${node.read}`),
529
- createElement('span', { className: 'dfa-count dfa-count-create' }, `${strings.createShort()} ${node.create}`),
530
- createElement('span', { className: 'dfa-count dfa-count-modify' }, `${strings.modifyShort()} ${node.modify}`),
531
- )
639
+ /** Count pills for a file/dir node only actions that actually happened are
640
+ * shown (a zero count renders no pill; all-zero nodes render no pill group,
641
+ * keeping untouched files visually quiet). */
642
+ const countPills = (node) => {
643
+ const pills = []
644
+ if (node.read > 0) pills.push(createElement('span', { className: 'dfa-count dfa-count-read' }, `${strings.readShort()} ${node.read}`))
645
+ if (node.create > 0) pills.push(createElement('span', { className: 'dfa-count dfa-count-create' }, `${strings.createShort()} ${node.create}`))
646
+ if (node.modify > 0) pills.push(createElement('span', { className: 'dfa-count dfa-count-modify' }, `${strings.modifyShort()} ${node.modify}`))
647
+ if (pills.length === 0) return null
648
+ return createElement('span', { className: 'dfa-counts', style: { paddingLeft: '6px' } }, ...pills)
649
+ }
650
+
651
+ /** Extension of a file name (lowercase, no leading dot); '' when none.
652
+ * Dotfiles map to their whole name ('.gitignore' → 'gitignore') so the
653
+ * badge table can cover them; 'notes.' still yields ''. */
654
+ const extOf = (name) => {
655
+ const dot = name.lastIndexOf('.')
656
+ if (dot > 0) return name.slice(dot + 1).toLowerCase()
657
+ if (dot === 0) return name.slice(1).toLowerCase()
658
+ return ''
659
+ }
660
+
661
+ /** Extension-less but common build files → their badge key. */
662
+ const NAME_BADGES = {
663
+ makefile: 'makefile', dockerfile: 'dockerfile', 'cmakelists.txt': 'cmake',
664
+ }
665
+
666
+ /** Badge key for a file name: basename match first, then extension. */
667
+ const badgeKeyOf = (name) => {
668
+ const base = name.toLowerCase()
669
+ const named = NAME_BADGES[base]
670
+ if (named !== undefined) return named
671
+ return extOf(name)
672
+ }
532
673
 
533
674
  /** A stats-tree file row: icon + name + count pills + relative time. */
534
675
  const fileRow = (file, depth, onOpen) =>
@@ -541,7 +682,7 @@ window.__ModuleLoader__.load({
541
682
  style: { paddingLeft: 8 + depth * 20 },
542
683
  title: fileTitle(file.abs, file.firstSeen, file.lastSeen),
543
684
  },
544
- createElement('span', { className: 'dfa-row-icon dfa-icon-file' }, icon.file(14)),
685
+ createElement('span', { className: 'dfa-row-icon dfa-icon-file' }, fileIconByExt(badgeKeyOf(file.name))),
545
686
  createElement('span', { className: 'dfa-row-name dfa-name-file' }, file.name),
546
687
  countPills(file),
547
688
  file.lastSeen
package/lib/index.js CHANGED
@@ -5,6 +5,8 @@
5
5
  * - agent tool file operations arrive as `fs/observed` events (read / write /
6
6
  * edit / str_replace_editor / read_image ...), with the tool execution as
7
7
  * the actor (name + parsed arguments + owning agent).
8
+ * - bash tool calls carry file-touching commands (rm/touch/mv/…): the
9
+ * `tools/pre-execute` observer parses them and records the intents.
8
10
  * - sidebar operations (files opened / saved through the better-sidebar
9
11
  * explorer & editor) are reported by our client half through the
10
12
  * `/file-activity/api/record` route.
@@ -17,16 +19,52 @@ import { createApiHandler } from './api-route.js'
17
19
  import { createMediaHandler } from './media-route.js'
18
20
  import { createFsObserver } from './observer.js'
19
21
  import { createStore } from './store.js'
22
+ import { parseBashFileOps } from './bash-parse.js'
23
+ import { sessionCwdOf } from './http.js'
20
24
 
21
25
  export const name = 'dsh-file-activity'
22
26
 
23
27
  export const inject = ['webServer', 'sessions', 'webRuntime']
24
28
 
29
+ /** 观察型监听:只读取 bash 命令隐含的文件操作并上报,不拦截(返回 next())。 */
30
+ function observeBashIntents(exec, ctx, store) {
31
+ const sessionId = bashSessionOf(exec)
32
+ const command = bashCommandOf(exec)
33
+ if (sessionId === '' || command === '') return
34
+ const baseDir = bashBaseDirOf(exec, ctx, sessionId)
35
+ for (const touched of parseBashFileOps(command, baseDir)) {
36
+ store.record(sessionId, touched.path, touched.op, Date.now())
37
+ }
38
+ }
39
+
40
+ /** 会话 id:仅 bash 工具、agent id 非空时返回。 */
41
+ function bashSessionOf(exec) {
42
+ if (exec?.name !== 'bash') return ''
43
+ const id = exec.agent?.id
44
+ return typeof id === 'string' && id !== '' ? id : ''
45
+ }
46
+
47
+ /** 命令文本:仅非空字符串时返回。 */
48
+ function bashCommandOf(exec) {
49
+ const command = exec.arguments?.command
50
+ return typeof command === 'string' && command !== '' ? command : ''
51
+ }
52
+
53
+ /** 相对路径基准:workdir 参数优先,否则会话 cwd。 */
54
+ function bashBaseDirOf(exec, ctx, sessionId) {
55
+ const workdir = exec.arguments?.workdir
56
+ return typeof workdir === 'string' && workdir !== '' ? workdir : sessionCwdOf(ctx, sessionId)
57
+ }
58
+
25
59
  export function apply(ctx) {
26
60
  const store = createStore(ctx)
27
61
 
28
62
  // ── agent-side file operations ──────────────────────────────────────────
29
63
  ctx.on('fs/observed', createFsObserver(store.record))
64
+ ctx.on('tools/pre-execute', (exec, next) => {
65
+ observeBashIntents(exec, ctx, store)
66
+ return next()
67
+ })
30
68
 
31
69
  // ── routes ──────────────────────────────────────────────────────────────
32
70
  const fence = (request) => isTrustedApiRequest(request, ctx.webRuntime.trustedHosts)
@@ -22,6 +22,7 @@
22
22
  read: () => (isZh() ? '读取' : 'read'),
23
23
  create: () => (isZh() ? '新增' : 'create'),
24
24
  modify: () => (isZh() ? '修改' : 'modify'),
25
+ delete: () => (isZh() ? '删除' : 'delete'),
25
26
  readShort: () => (isZh() ? '读' : 'R'),
26
27
  createShort: () => (isZh() ? '增' : 'C'),
27
28
  modifyShort: () => (isZh() ? '改' : 'M'),
@@ -46,3 +46,99 @@
46
46
  createElement('line', { x1: 6, y1: 6, x2: 18, y2: 18 }),
47
47
  ], size),
48
48
  }
49
+
50
+ // Common-language / file-type badges (issue #24): brand fill + contrast
51
+ // ink, reading on both light and dark themes. Unmapped extensions keep the
52
+ // neutral currentColor file icon above. [bg, fg ink, short mark]
53
+ const FILE_BADGES = {
54
+ // JavaScript / TypeScript
55
+ js: ['#F7DF1E', '#323330', 'JS'], mjs: ['#F7DF1E', '#323330', 'JS'], cjs: ['#F7DF1E', '#323330', 'JS'],
56
+ ts: ['#3178C6', '#ffffff', 'TS'], mts: ['#3178C6', '#ffffff', 'TS'], cts: ['#3178C6', '#ffffff', 'TS'],
57
+ tsx: ['#3178C6', '#ffffff', 'TSX'], jsx: ['#3178C6', '#ffffff', 'JSX'],
58
+ // 后端语言
59
+ java: ['#007396', '#ffffff', 'JAVA'],
60
+ c: ['#A8B9CC', '#111111', 'C'],
61
+ cpp: ['#00599C', '#ffffff', 'C++'], cxx: ['#00599C', '#ffffff', 'C++'], cc: ['#00599C', '#ffffff', 'C++'], hpp: ['#00599C', '#ffffff', 'C++'],
62
+ h: ['#A8B9CC', '#111111', 'H'], hh: ['#A8B9CC', '#111111', 'H'],
63
+ cs: ['#68217A', '#ffffff', 'C#'], csharp: ['#68217A', '#ffffff', 'C#'],
64
+ go: ['#00ADD8', '#ffffff', 'GO'],
65
+ rs: ['#CE422B', '#ffffff', 'RS'],
66
+ rb: ['#B51624', '#ffffff', 'RB'],
67
+ php: ['#777BB4', '#ffffff', 'PHP'],
68
+ py: ['#3776AB', '#ffffff', 'PY'],
69
+ swift: ['#F05138', '#ffffff', 'SWIFT'],
70
+ kt: ['#7F52FF', '#ffffff', 'KT'], kotlin: ['#7F52FF', '#ffffff', 'KT'],
71
+ dart: ['#0175C2', '#ffffff', 'DART'],
72
+ scala: ['#DC322F', '#ffffff', 'SCALA'],
73
+ lua: ['#2C2C7C', '#ffffff', 'LUA'],
74
+ pl: ['#0298C3', '#ffffff', 'PERL'],
75
+ r: ['#336DC3', '#ffffff', 'R'],
76
+ m: ['#C1272D', '#ffffff', 'MAT'], mm: ['#C1272D', '#ffffff', 'MAT'],
77
+ // Web / 前端
78
+ html: ['#E34F26', '#ffffff', '</>'], htm: ['#E34F26', '#ffffff', '</>'],
79
+ css: ['#663399', '#ffffff', 'CSS'],
80
+ scss: ['#CD6799', '#ffffff', 'SCSS'], sass: ['#CD6799', '#ffffff', 'SCSS'],
81
+ vue: ['#42B883', '#ffffff', 'VUE'],
82
+ svelte: ['#FF3E00', '#ffffff', 'SVELTE'],
83
+ // 数据 / 结构化
84
+ json: ['#F7DF1E', '#323330', '{}'],
85
+ sql: ['#00758F', '#ffffff', 'SQL'],
86
+ csv: ['#2E7D32', '#ffffff', 'CSV'],
87
+ db: ['#0F62FE', '#ffffff', 'DB'], sqlite: ['#0F62FE', '#ffffff', 'DB'], sqlite3: ['#0F62FE', '#ffffff', 'DB'],
88
+ xml: ['#FF6F00', '#ffffff', 'XML'],
89
+ svg: ['#FF6F00', '#ffffff', 'SVG'],
90
+ // 文档
91
+ md: ['#42A5F5', '#ffffff', 'M↓'], markdown: ['#42A5F5', '#ffffff', 'M↓'],
92
+ txt: ['#90A4AE', '#ffffff', 'TXT'], text: ['#90A4AE', '#ffffff', 'TXT'], log: ['#90A4AE', '#ffffff', 'TXT'],
93
+ pdf: ['#E5202B', '#ffffff', 'PDF'],
94
+ doc: ['#2B579A', '#ffffff', 'DOC'], docx: ['#2B579A', '#ffffff', 'DOC'],
95
+ xls: ['#217346', '#ffffff', 'XLS'], xlsx: ['#217346', '#ffffff', 'XLS'],
96
+ ppt: ['#D24726', '#ffffff', 'PPT'], pptx: ['#D24726', '#ffffff', 'PPT'],
97
+ // 配置 / 构建
98
+ yml: ['#CB171E', '#ffffff', 'YML'], yaml: ['#CB171E', '#ffffff', 'YML'],
99
+ toml: ['#8D6E63', '#ffffff', 'TOML'],
100
+ ini: ['#546E7A', '#ffffff', 'CFG'], cfg: ['#546E7A', '#ffffff', 'CFG'], config: ['#546E7A', '#ffffff', 'CFG'],
101
+ env: ['#F9A825', '#323330', 'ENV'],
102
+ properties: ['#7B1FA2', '#ffffff', 'PROP'],
103
+ lock: ['#37474F', '#ffffff', 'LOCK'],
104
+ dockerfile: ['#2496ED', '#ffffff', 'DOCK'], docker: ['#2496ED', '#ffffff', 'DOCK'],
105
+ makefile: ['#607D8B', '#ffffff', 'MAKE'],
106
+ gradle: ['#02303A', '#ffffff', 'GRADLE'],
107
+ cmake: ['#265774', '#ffffff', 'CMAKE'],
108
+ ipynb: ['#F37726', '#ffffff', 'JNB'],
109
+ // 脚本 / Shell
110
+ sh: ['#89E051', '#111111', '>_'], bash: ['#89E051', '#111111', '>_'], zsh: ['#89E051', '#111111', '>_'],
111
+ ps1: ['#012456', '#ffffff', 'PS1'],
112
+ bat: ['#546E7A', '#ffffff', 'CMD'], cmd: ['#546E7A', '#ffffff', 'CMD'],
113
+ // 打包 / 二进制
114
+ zip: ['#FFA726', '#323330', 'ZIP'], tar: ['#FFA726', '#323330', 'ZIP'], gz: ['#FFA726', '#323330', 'ZIP'],
115
+ '7z': ['#FFA726', '#323330', 'ZIP'], rar: ['#FFA726', '#323330', 'ZIP'],
116
+ exe: ['#0078D4', '#ffffff', 'EXE'], msi: ['#0078D4', '#ffffff', 'EXE'],
117
+ wasm: ['#654FF0', '#ffffff', 'WASM'],
118
+ // 图片 / 媒体
119
+ png: ['#8E44AD', '#ffffff', 'IMG'], jpg: ['#8E44AD', '#ffffff', 'IMG'], jpeg: ['#8E44AD', '#ffffff', 'IMG'],
120
+ gif: ['#8E44AD', '#ffffff', 'IMG'], webp: ['#8E44AD', '#ffffff', 'IMG'], ico: ['#8E44AD', '#ffffff', 'IMG'], bmp: ['#8E44AD', '#ffffff', 'IMG'],
121
+ // 版本控制
122
+ gitignore: ['#F05032', '#ffffff', 'GIT'], gitattributes: ['#F05032', '#ffffff', 'GIT'],
123
+ }
124
+
125
+ /** One self-colored badge svg: rounded brand rect + short contrast mark.
126
+ * Mark font scales by length so 5-6 char marks (JAVA/SCALA/SWIFT) stay
127
+ * inside the 24×24 viewBox. */
128
+ const badgeIcon = ([bg, fg, mark], size) =>
129
+ createElement('svg', {
130
+ width: size, height: size, viewBox: '0 0 24 24', 'aria-hidden': 'true',
131
+ },
132
+ createElement('rect', { x: 1, y: 1, width: 22, height: 22, rx: 5, fill: bg }),
133
+ createElement('text', {
134
+ x: 12, y: 16, textAnchor: 'middle', fontSize: mark.length <= 2 ? 9 : mark.length <= 4 ? 7 : 5.5,
135
+ fontWeight: 700, fill: fg,
136
+ }, mark))
137
+
138
+ /** File-type icon dispatcher: branded badge for known extensions, the
139
+ * neutral file icon for everything else (case-insensitive, tolerates a
140
+ * leading dot like ".md"). */
141
+ const fileIconByExt = (ext, size = 14) => {
142
+ const spec = FILE_BADGES[String(ext ?? '').toLowerCase().replace(/^\./, '')]
143
+ return spec === undefined ? icon.file(size) : badgeIcon(spec, size)
144
+ }
@@ -1,6 +1,6 @@
1
1
  // ── row rendering helpers (recent list & stats tree) ──────────────────
2
- const opClass = (op) => (op === 'create' ? 'dfa-op-create' : op === 'modify' ? 'dfa-op-modify' : 'dfa-op-read')
3
- const opLabel = (op) => (op === 'create' ? strings.create() : op === 'modify' ? strings.modify() : strings.read())
2
+ const opClass = (op) => (op === 'create' ? 'dfa-op-create' : op === 'modify' ? 'dfa-op-modify' : op === 'delete' ? 'dfa-op-delete' : 'dfa-op-read')
3
+ const opLabel = (op) => (op === 'create' ? strings.create() : op === 'modify' ? strings.modify() : op === 'delete' ? strings.delete() : strings.read())
4
4
 
5
5
  /** Tooltip for a stats file row: absolute path + created / last-seen times. */
6
6
  const fileTitle = (abs, firstSeen, lastSeen) => {
@@ -10,13 +10,40 @@
10
10
  return times.length > 0 ? `${abs}\n${times.join(' · ')}` : abs
11
11
  }
12
12
 
13
- /** Three colored count pills for a file/dir node (read/create/modify). */
14
- const countPills = (node) =>
15
- createElement('span', { className: 'dfa-counts', style: { paddingLeft: '6px' } },
16
- createElement('span', { className: 'dfa-count dfa-count-read' }, `${strings.readShort()} ${node.read}`),
17
- createElement('span', { className: 'dfa-count dfa-count-create' }, `${strings.createShort()} ${node.create}`),
18
- createElement('span', { className: 'dfa-count dfa-count-modify' }, `${strings.modifyShort()} ${node.modify}`),
19
- )
13
+ /** Count pills for a file/dir node only actions that actually happened are
14
+ * shown (a zero count renders no pill; all-zero nodes render no pill group,
15
+ * keeping untouched files visually quiet). */
16
+ const countPills = (node) => {
17
+ const pills = []
18
+ if (node.read > 0) pills.push(createElement('span', { className: 'dfa-count dfa-count-read' }, `${strings.readShort()} ${node.read}`))
19
+ if (node.create > 0) pills.push(createElement('span', { className: 'dfa-count dfa-count-create' }, `${strings.createShort()} ${node.create}`))
20
+ if (node.modify > 0) pills.push(createElement('span', { className: 'dfa-count dfa-count-modify' }, `${strings.modifyShort()} ${node.modify}`))
21
+ if (pills.length === 0) return null
22
+ return createElement('span', { className: 'dfa-counts', style: { paddingLeft: '6px' } }, ...pills)
23
+ }
24
+
25
+ /** Extension of a file name (lowercase, no leading dot); '' when none.
26
+ * Dotfiles map to their whole name ('.gitignore' → 'gitignore') so the
27
+ * badge table can cover them; 'notes.' still yields ''. */
28
+ const extOf = (name) => {
29
+ const dot = name.lastIndexOf('.')
30
+ if (dot > 0) return name.slice(dot + 1).toLowerCase()
31
+ if (dot === 0) return name.slice(1).toLowerCase()
32
+ return ''
33
+ }
34
+
35
+ /** Extension-less but common build files → their badge key. */
36
+ const NAME_BADGES = {
37
+ makefile: 'makefile', dockerfile: 'dockerfile', 'cmakelists.txt': 'cmake',
38
+ }
39
+
40
+ /** Badge key for a file name: basename match first, then extension. */
41
+ const badgeKeyOf = (name) => {
42
+ const base = name.toLowerCase()
43
+ const named = NAME_BADGES[base]
44
+ if (named !== undefined) return named
45
+ return extOf(name)
46
+ }
20
47
 
21
48
  /** A stats-tree file row: icon + name + count pills + relative time. */
22
49
  const fileRow = (file, depth, onOpen) =>
@@ -29,7 +56,7 @@
29
56
  style: { paddingLeft: 8 + depth * 20 },
30
57
  title: fileTitle(file.abs, file.firstSeen, file.lastSeen),
31
58
  },
32
- createElement('span', { className: 'dfa-row-icon dfa-icon-file' }, icon.file(14)),
59
+ createElement('span', { className: 'dfa-row-icon dfa-icon-file' }, fileIconByExt(badgeKeyOf(file.name))),
33
60
  createElement('span', { className: 'dfa-row-name dfa-name-file' }, file.name),
34
61
  countPills(file),
35
62
  file.lastSeen
@@ -44,6 +44,7 @@
44
44
  .dfa-op-create { color:var(--dsw-alias-state-success-primary); background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 14%, transparent); }
45
45
  .dfa-op-modify { color:var(--dsw-alias-state-warn-primary); background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 16%, transparent); }
46
46
  .dfa-op-read { color:var(--dsw-alias-accent); background:color-mix(in srgb, var(--dsw-alias-accent) 12%, transparent); }
47
+ .dfa-op-delete { color:var(--dsw-alias-state-danger-primary); background:color-mix(in srgb, var(--dsw-alias-state-danger-primary) 14%, transparent); }
47
48
  .dfa-counts { flex:none; display:flex; align-items:center; gap:3px; }
48
49
  .dfa-count { flex:none; display:inline-flex; align-items:center; justify-content:center; height:15px; padding:0 4px; border-radius:4px;
49
50
  font:var(--dsw-font-xxxs-strong-11); }
@@ -73,4 +74,20 @@
73
74
  .dfa-pdf-download:hover { text-decoration:underline; }
74
75
  .dfa-pdf-frame { flex:1; min-height:0; width:100%; border:none; border-radius:6px; background:transparent; }
75
76
  @keyframes dfa-row-in { from { opacity:0; transform:translateY(1px); } to { opacity:1; transform:none; } }
77
+ /* ── sidebar tab selected-state overrides (issue #25) ─────────────────────
78
+ The host (dsh-better-sidebar) renders tabs with CSS-modules hashed class
79
+ names (e.g. tabActive_xxxxx) and no data attributes, so the selected tab
80
+ is targeted via the [class*="tabActive"] substring selector — the fallback
81
+ contract that keeps these rules working if the host ever renames its
82
+ classes. The double attribute selector [class*="tab"][class*="tabActive"]
83
+ (0,2,0) beats the host .tabActive (0,1,0) by specificity alone: no
84
+ !important, no reliance on injection order. Selected = theme-aware brand
85
+ fill + contrast ink (light #4176e6/#fff, dark #679efe/#0f1115); the :hover
86
+ variant (0,3,0) beats the host .tab:hover (0,2,0) so the selected state
87
+ never collapses into the host's grey hover. Unselected tabs stay untouched
88
+ (transparent + secondary label ink, host grey hover). */
89
+ [class*="tab"][class*="tabActive"] { background:var(--dsw-alias-state-business-primary); color:var(--dsw-alias-label-primary-foreground); }
90
+ [class*="tab"][class*="tabActive"]:hover { background:var(--dsw-alias-state-business-primary); color:var(--dsw-alias-label-primary-foreground); }
91
+ [class*="tab"][class*="tabActive"] [class*="tabClose"] { color:var(--dsw-alias-label-primary-foreground); }
92
+ [class*="tab"][class*="tabActive"] [class*="tabClose"]:hover { background:color-mix(in srgb, var(--dsw-alias-label-primary-foreground) 18%, transparent); color:var(--dsw-alias-label-primary-foreground); }
76
93
  `
package/lib/state.js CHANGED
@@ -35,12 +35,13 @@ export async function loadState(file) {
35
35
  return createState()
36
36
  }
37
37
 
38
- /** Map a raw operation kind (tool name or client op) to 'read' | 'write' | 'edit'. */
38
+ /** Map a raw operation kind (tool name or client op) to 'read' | 'write' | 'edit' | 'delete'. */
39
39
  export function mapOp(op) {
40
40
  switch (op) {
41
41
  case 'write': return 'write'
42
42
  case 'edit':
43
43
  case 'str_replace_editor': return 'edit'
44
+ case 'delete': return 'delete'
44
45
  case 'read':
45
46
  case 'read_image':
46
47
  default: return 'read'
@@ -51,28 +52,43 @@ export function mapOp(op) {
51
52
  * Fold one observed operation into the state.
52
53
  * 'write' is classified create vs modify through the per-session known-file
53
54
  * registry (first contact = create, later writes = modify); edits are always
54
- * modifies. Each file's counters also track firstSeen (first contact time,
55
- * i.e. creation time) and lastSeen (most recent activity time).
55
+ * modifies. 'delete' removes the file from stats entirely (disk state is the
56
+ * truth: the file no longer exists) and records a single delete history entry.
57
+ * Each file's counters also track firstSeen (first contact time, i.e.
58
+ * creation time) and lastSeen (most recent activity time).
56
59
  * Returns true when a record was produced.
57
60
  */
58
61
  export function applyRecord(state, sessionId, path, op, time) {
59
62
  if (!isValidRecordTarget(sessionId, path)) return false
60
63
  const session = state.sessions[sessionId] ?? (state.sessions[sessionId] = { known: {}, counts: {}, recent: [] })
61
64
  const timestamp = typeof time === 'number' ? time : Date.now()
65
+ if (op === 'delete') return applyDelete(session, path, timestamp)
62
66
  const firstSeen = typeof session.known[path] === 'number' ? session.known[path] : timestamp
63
67
  const finalOp = classifyOp(op, session.known[path])
64
68
  session.known[path] = firstSeen
65
69
  const counts = session.counts[path] ?? (session.counts[path] = { read: 0, create: 0, modify: 0 })
66
70
  bumpCount(counts, finalOp, firstSeen, timestamp)
67
- // Newest-first LRU history: revisiting a path moves it to the front
68
- // instead of appending a duplicate; cap at RECENT_LIMIT entries.
69
- const existing = session.recent.findIndex((entry) => entry.path === path)
70
- if (existing !== -1) session.recent.splice(existing, 1)
71
- session.recent.unshift({ path, op: finalOp, time: timestamp })
72
- if (session.recent.length > RECENT_LIMIT) session.recent.length = RECENT_LIMIT
71
+ pushRecent(session.recent, path, finalOp, timestamp)
73
72
  return true
74
73
  }
75
74
 
75
+ /** 'delete': the file no longer exists on disk — drop it from stats and the
76
+ * known-file registry, and record a single delete history entry. */
77
+ function applyDelete(session, path, timestamp) {
78
+ delete session.counts[path]
79
+ delete session.known[path]
80
+ pushRecent(session.recent, path, 'delete', timestamp)
81
+ return true
82
+ }
83
+
84
+ /** Newest-first LRU history: one entry per path, cap at RECENT_LIMIT. */
85
+ function pushRecent(recent, path, op, time) {
86
+ const existing = recent.findIndex((entry) => entry.path === path)
87
+ if (existing !== -1) recent.splice(existing, 1)
88
+ recent.unshift({ path, op, time })
89
+ if (recent.length > RECENT_LIMIT) recent.length = RECENT_LIMIT
90
+ }
91
+
76
92
  /** A record target is valid when both ids are non-empty strings (no NUL). */
77
93
  function isValidRecordTarget(sessionId, path) {
78
94
  return typeof sessionId === 'string' && sessionId !== ''
@@ -135,6 +151,8 @@ export function isRecordedPath(state, sessionId, path) {
135
151
  const session = state.sessions[sessionId]
136
152
  if (session === undefined) return false
137
153
  if (session.counts !== undefined && typeof session.counts[path] === 'object' && session.counts[path] !== null) return true
138
- if (Array.isArray(session.recent)) return session.recent.some((entry) => entry.path === path)
154
+ // Deleted files no longer exist on disk — a delete history entry must not
155
+ // authorize media preview for them.
156
+ if (Array.isArray(session.recent)) return session.recent.some((entry) => entry.path === path && entry.op !== 'delete')
139
157
  return false
140
158
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-file-activity",
3
- "version": "0.4.7",
3
+ "version": "0.5.1",
4
4
  "description": "DSH 侧边栏文件活动插件:按 LRU 记录文件读取/新增/修改事件(最近访问),按绝对路径树形统计(文件统计),点击文件浮窗预览(代码高亮/Markdown/图片/PDF)。DSH web plugin: file activity tracker — LRU recent-access list plus per-file read/create/modify counts in a folder tree, with floating preview.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -57,4 +57,4 @@
57
57
  }
58
58
  },
59
59
  "license": "MIT"
60
- }
60
+ }