dsh-file-activity 0.5.0 → 0.5.2
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 +19 -0
- package/README.md +3 -4
- package/lib/api-route.js +18 -4
- package/lib/client.js +1270 -845
- package/lib/cwd.js +14 -0
- package/lib/index.js +20 -12
- package/lib/media-route.js +8 -3
- package/lib/parts/api.part.js +41 -41
- package/lib/parts/apply.part.js +69 -60
- package/lib/parts/auto-open.part.js +69 -68
- package/lib/parts/format.part.js +26 -26
- package/lib/parts/i18n.part.js +41 -40
- package/lib/parts/interceptor.part.js +50 -50
- package/lib/parts/preview.part.js +205 -160
- package/lib/parts/rows.part.js +157 -113
- package/lib/parts/store.part.js +16 -16
- package/lib/parts/styles.part.js +8 -5
- package/lib/parts/tree.part.js +79 -66
- package/lib/parts/view.part.js +172 -118
- package/lib/state.js +15 -8
- package/lib/store.js +4 -11
- package/package.json +3 -2
- package/lib/fence.js +0 -57
- package/lib/http.js +0 -34
- package/lib/parts/icons.part.js +0 -48
package/lib/client.js
CHANGED
|
@@ -46,399 +46,669 @@ window.__ModuleLoader__.load({
|
|
|
46
46
|
|
|
47
47
|
// ── parts (injected by scripts/build.mjs; keep this exact order — the
|
|
48
48
|
// const initializers below run in splice order) ─────────────────────
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Sort a directory node: directories first (alphabetically), then files
|
|
143
|
-
* (by total activity, then name); recurse into directories.
|
|
144
|
-
*/
|
|
145
|
-
function sortNode(node) {
|
|
146
|
-
node.children.sort((a, b) => {
|
|
147
|
-
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
|
|
148
|
-
if (a.type === 'dir') return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
|
149
|
-
const ta = a.read + a.create + a.modify
|
|
150
|
-
const tb = b.read + b.create + b.modify
|
|
151
|
-
return tb - ta || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
|
152
|
-
})
|
|
153
|
-
for (const child of node.children) {
|
|
154
|
-
if (child.type === 'dir') sortNode(child)
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Build a nested directory tree from per-file counts, keyed by the file's
|
|
160
|
-
* absolute path. Every directory node aggregates its subtree counters and
|
|
161
|
-
* sorts directories first (alphabetically), then files (by activity).
|
|
162
|
-
*/
|
|
163
|
-
function buildTree(counts) {
|
|
164
|
-
const root = { type: 'dir', name: '', path: '', children: [], read: 0, create: 0, modify: 0 }
|
|
165
|
-
for (const [abs, counter] of Object.entries(counts)) {
|
|
166
|
-
const parts = abs.split('/').filter((part) => part !== '')
|
|
167
|
-
if (parts.length === 0) continue
|
|
168
|
-
const name = parts[parts.length - 1]
|
|
169
|
-
let node = root
|
|
170
|
-
for (const dir of parts.slice(0, -1)) {
|
|
171
|
-
let child = node.children.find((c) => c.type === 'dir' && c.name === dir)
|
|
172
|
-
if (child === undefined) {
|
|
173
|
-
child = { type: 'dir', name: dir, path: `${node.path}/${dir}`, children: [], read: 0, create: 0, modify: 0 }
|
|
174
|
-
node.children.push(child)
|
|
175
|
-
}
|
|
176
|
-
node = child
|
|
177
|
-
node.read += counter.read
|
|
178
|
-
node.create += counter.create
|
|
179
|
-
node.modify += counter.modify
|
|
180
|
-
}
|
|
181
|
-
node.children.push({
|
|
182
|
-
type: 'file', name, abs,
|
|
183
|
-
read: counter.read, create: counter.create, modify: counter.modify,
|
|
184
|
-
firstSeen: counter.firstSeen, lastSeen: counter.lastSeen,
|
|
185
|
-
})
|
|
186
|
-
}
|
|
187
|
-
sortNode(root)
|
|
188
|
-
compressChains(root, true)
|
|
189
|
-
return root
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// ── tiny external store ───────────────────────────────────────────────
|
|
193
|
-
function createStore(initial) {
|
|
194
|
-
let state = initial
|
|
195
|
-
const listeners = new Set()
|
|
196
|
-
return {
|
|
197
|
-
getSnapshot: () => state,
|
|
198
|
-
set(patch) {
|
|
199
|
-
state = { ...state, ...patch }
|
|
200
|
-
for (const listener of [...listeners]) listener()
|
|
201
|
-
},
|
|
202
|
-
subscribe(listener) {
|
|
203
|
-
listeners.add(listener)
|
|
204
|
-
return () => listeners.delete(listener)
|
|
205
|
-
},
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// ── data access (host routes) ─────────────────────────────────────────
|
|
210
|
-
async function fetchStats(sessionId) {
|
|
211
|
-
const response = await fetch(`/file-activity/api/stats?sessionId=${encodeURIComponent(sessionId)}`)
|
|
212
|
-
const json = await response.json()
|
|
213
|
-
if (json === null || typeof json !== 'object' || json.ok !== true) return null
|
|
214
|
-
return json.value
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** Resolve the session working directory through the sidebar's native API. */
|
|
218
|
-
async function fetchSessionCwd(sessionId) {
|
|
219
|
-
try {
|
|
220
|
-
const response = await fetch('/sidebar/api/session.cwd', {
|
|
221
|
-
method: 'POST',
|
|
222
|
-
headers: { 'content-type': 'application/json' },
|
|
223
|
-
body: JSON.stringify({ sessionId }),
|
|
224
|
-
})
|
|
225
|
-
const json = await response.json()
|
|
226
|
-
const cwd = json?.value?.cwd
|
|
227
|
-
return typeof cwd === 'string' && cwd !== '' ? cwd : ''
|
|
228
|
-
} catch {
|
|
229
|
-
return ''
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function postRecord(sessionId, path, op) {
|
|
234
|
-
if (typeof sessionId !== 'string' || sessionId === '' || typeof path !== 'string' || path === '') return
|
|
235
|
-
void fetch('/file-activity/api/record', {
|
|
236
|
-
method: 'POST',
|
|
237
|
-
headers: { 'content-type': 'application/json' },
|
|
238
|
-
body: JSON.stringify({ sessionId, path, op }),
|
|
239
|
-
}).catch(() => {})
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function postClear(sessionId) {
|
|
243
|
-
void fetch('/file-activity/api/clear', {
|
|
244
|
-
method: 'POST',
|
|
245
|
-
headers: { 'content-type': 'application/json' },
|
|
246
|
-
body: JSON.stringify({ sessionId }),
|
|
247
|
-
}).catch(() => {})
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/** Plugin media route URL for a recorded path (authorized per session). */
|
|
251
|
-
function mediaUrlOf(sessionId, path) {
|
|
252
|
-
return `/file-activity/file?${new URLSearchParams({ sessionId, path })}`
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// ── fetch interception: sidebar file operations ───────────────────────
|
|
256
|
-
function methodOf(init) {
|
|
257
|
-
return (init?.method ?? 'GET').toUpperCase()
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/** POST body as a plain object (non-string bodies are ignored). */
|
|
261
|
-
function parseBody(init) {
|
|
262
|
-
return typeof init?.body === 'string' ? JSON.parse(init.body) : {}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/** Record fs.read / fs.write POSTs observed on the sidebar API. */
|
|
266
|
-
function recordSidebarFs(url, init) {
|
|
267
|
-
if (url.pathname !== '/sidebar/api/fs.read' && url.pathname !== '/sidebar/api/fs.write') return
|
|
268
|
-
if (methodOf(init) !== 'POST') return
|
|
269
|
-
const body = parseBody(init)
|
|
270
|
-
if (typeof body.sessionId !== 'string' || typeof body.path !== 'string') return
|
|
271
|
-
postRecord(body.sessionId, body.path, url.pathname === '/sidebar/api/fs.write' ? 'write' : 'read')
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** Record sidebar media opens (/sidebar/file?sessionId=...&path=...). */
|
|
275
|
-
function recordMediaOpen(url, init) {
|
|
276
|
-
if (url.pathname !== '/sidebar/file' || methodOf(init) !== 'GET') return
|
|
277
|
-
const sessionId = url.searchParams.get('sessionId')
|
|
278
|
-
const path = url.searchParams.get('path')
|
|
279
|
-
if (sessionId !== null && path !== null) postRecord(sessionId, path, 'read')
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/** Observe a resolved fetch URL and record sidebar file operations. */
|
|
283
|
-
function observeSidebarFetch(url, init) {
|
|
284
|
-
try {
|
|
285
|
-
recordSidebarFs(url, init)
|
|
286
|
-
recordMediaOpen(url, init)
|
|
287
|
-
} catch {
|
|
288
|
-
// observation must never break the underlying call
|
|
289
|
-
}
|
|
290
|
-
}
|
|
49
|
+
// ── i18n ──────────────────────────────────────────────────────────────
|
|
50
|
+
function isZh() {
|
|
51
|
+
try {
|
|
52
|
+
const lang = (navigator.language || 'en').toLowerCase()
|
|
53
|
+
return lang.startsWith('zh')
|
|
54
|
+
} catch {
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const strings = {
|
|
60
|
+
title: () => (isZh() ? '文件活动' : 'File Activity'),
|
|
61
|
+
recent: () => (isZh() ? '最近访问' : 'Recent'),
|
|
62
|
+
stats: () => (isZh() ? '文件统计' : 'File Stats'),
|
|
63
|
+
empty: () => (isZh() ? '暂无文件活动记录' : 'No file activity yet'),
|
|
64
|
+
emptyHint: () =>
|
|
65
|
+
isZh()
|
|
66
|
+
? '在侧边栏打开文件、编辑保存,或让 agent 读写文件(创建/读取/修改),都会记录在这里。点击任意文件将在侧边栏内用原生预览打开(代码高亮 / Markdown 渲染 / 图片 / PDF…)。'
|
|
67
|
+
: 'Opening files in the sidebar, editing, or agent file operations (create/read/modify) are recorded here. Click any file to open it in the sidebar with native preview (syntax highlighting / Markdown rendering / images / PDF…).',
|
|
68
|
+
refresh: () => (isZh() ? '刷新' : 'Refresh'),
|
|
69
|
+
clear: () => (isZh() ? '清空' : 'Clear'),
|
|
70
|
+
clearConfirm: () => (isZh() ? '确定清空当前会话的全部文件活动记录?' : 'Clear all file activity for this session?'),
|
|
71
|
+
read: () => (isZh() ? '读取' : 'read'),
|
|
72
|
+
create: () => (isZh() ? '新增' : 'create'),
|
|
73
|
+
modify: () => (isZh() ? '修改' : 'modify'),
|
|
74
|
+
delete: () => (isZh() ? '删除' : 'delete'),
|
|
75
|
+
readShort: () => (isZh() ? '读' : 'R'),
|
|
76
|
+
createShort: () => (isZh() ? '增' : 'C'),
|
|
77
|
+
modifyShort: () => (isZh() ? '改' : 'M'),
|
|
78
|
+
loadError: () => (isZh() ? '加载失败' : 'Load failed'),
|
|
79
|
+
created: () => (isZh() ? '创建' : 'Created'),
|
|
80
|
+
lastSeen: () => (isZh() ? '最近访问' : 'Last seen'),
|
|
81
|
+
justNow: () => (isZh() ? '刚刚' : 'just now'),
|
|
82
|
+
minutesAgo: (m) => (isZh() ? `${m} 分钟前` : `${m}m ago`),
|
|
83
|
+
hoursAgo: (h) => (isZh() ? `${h} 小时前` : `${h}h ago`),
|
|
84
|
+
daysAgo: (d) => (isZh() ? `${d} 天前` : `${d}d ago`),
|
|
85
|
+
closePreview: () => (isZh() ? '关闭预览' : 'Close preview'),
|
|
86
|
+
loading: () => (isZh() ? '加载中…' : 'Loading…'),
|
|
87
|
+
previewUnsupported: () => (isZh() ? '该文件类型暂不支持预览' : 'This file type cannot be previewed yet'),
|
|
88
|
+
previewFailed: () => (isZh() ? '预览加载失败' : 'Preview failed to load'),
|
|
89
|
+
downloadToView: () => (isZh() ? '下载查看' : 'download to view'),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── path / time formatting helpers ────────────────────────────────────
|
|
93
|
+
function basenameOf(path) {
|
|
94
|
+
const norm = path.split('\\').join('/')
|
|
95
|
+
const idx = norm.lastIndexOf('/')
|
|
96
|
+
return idx === -1 ? norm : norm.slice(idx + 1)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Compact relative time: 刚刚 / N 分钟前 / N 小时前 / N 天前 / MM/DD. */
|
|
100
|
+
function formatRelative(time) {
|
|
101
|
+
if (typeof time !== 'number' || !Number.isFinite(time)) return ''
|
|
102
|
+
const diff = Date.now() - time
|
|
103
|
+
if (diff < 30_000) return strings.justNow()
|
|
104
|
+
const minutes = Math.floor(diff / 60_000)
|
|
105
|
+
if (minutes < 60) return strings.minutesAgo(minutes)
|
|
106
|
+
const hours = Math.floor(minutes / 60)
|
|
107
|
+
if (hours < 24) return strings.hoursAgo(hours)
|
|
108
|
+
const days = Math.floor(hours / 24)
|
|
109
|
+
if (days < 7) return strings.daysAgo(days)
|
|
110
|
+
const date = new Date(time)
|
|
111
|
+
return `${date.getMonth() + 1}/${date.getDate()}`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Local wall-clock HH:MM:SS (used in tooltips; full precision). */
|
|
115
|
+
function formatTime(time) {
|
|
116
|
+
const date = new Date(time)
|
|
117
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
118
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── directory tree construction ───────────────────────────────────────
|
|
122
|
+
/**
|
|
123
|
+
* Collapse chain directories: a directory whose only child is another
|
|
124
|
+
* directory merges into it (a → a.b → a.b.c …). Deep single-child paths
|
|
125
|
+
* render as one dotted label with the file(s) directly beneath.
|
|
126
|
+
* `root` itself is never collapsed (its name is '' and would drop the
|
|
127
|
+
* top-level directory).
|
|
128
|
+
*/
|
|
129
|
+
function compressChains(node, isRoot) {
|
|
130
|
+
for (const child of node.children) {
|
|
131
|
+
if (child.type === 'dir') compressChains(child, false)
|
|
132
|
+
}
|
|
133
|
+
if (isRoot) return
|
|
134
|
+
while (node.children.length === 1 && node.children[0].type === 'dir') {
|
|
135
|
+
const only = node.children[0]
|
|
136
|
+
node.name = `${node.name}.${only.name}`
|
|
137
|
+
node.children = only.children
|
|
138
|
+
node.compressed = true
|
|
139
|
+
}
|
|
140
|
+
}
|
|
291
141
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
window.fetch = original
|
|
309
|
-
}
|
|
310
|
-
}
|
|
142
|
+
/**
|
|
143
|
+
* Sort a directory node: directories first (alphabetically), then files
|
|
144
|
+
* (by total activity, then name); recurse into directories.
|
|
145
|
+
*/
|
|
146
|
+
function sortNode(node) {
|
|
147
|
+
node.children.sort((a, b) => {
|
|
148
|
+
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
|
|
149
|
+
if (a.type === 'dir') return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
|
150
|
+
const ta = a.read + a.create + a.modify
|
|
151
|
+
const tb = b.read + b.create + b.modify
|
|
152
|
+
return tb - ta || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
|
153
|
+
})
|
|
154
|
+
for (const child of node.children) {
|
|
155
|
+
if (child.type === 'dir') sortNode(child)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
311
158
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Build a nested directory tree from per-file counts, keyed by the file's
|
|
161
|
+
* absolute path. Every directory node aggregates its subtree counters and
|
|
162
|
+
* sorts directories first (alphabetically), then files (by activity).
|
|
163
|
+
*/
|
|
164
|
+
function buildTree(counts) {
|
|
165
|
+
const root = { type: 'dir', name: '', path: '', children: [], read: 0, create: 0, modify: 0 }
|
|
166
|
+
for (const [abs, counter] of Object.entries(counts)) {
|
|
167
|
+
const parts = abs.split('/').filter((part) => part !== '')
|
|
168
|
+
if (parts.length === 0) continue
|
|
169
|
+
const name = parts[parts.length - 1]
|
|
170
|
+
let node = root
|
|
171
|
+
for (const dir of parts.slice(0, -1)) {
|
|
172
|
+
let child = node.children.find((c) => c.type === 'dir' && c.name === dir)
|
|
173
|
+
if (child === undefined) {
|
|
174
|
+
child = {
|
|
175
|
+
type: 'dir',
|
|
176
|
+
name: dir,
|
|
177
|
+
path: `${node.path}/${dir}`,
|
|
178
|
+
children: [],
|
|
179
|
+
read: 0,
|
|
180
|
+
create: 0,
|
|
181
|
+
modify: 0,
|
|
319
182
|
}
|
|
183
|
+
node.children.push(child)
|
|
320
184
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
function sidebarSnapshot(service) {
|
|
326
|
-
try {
|
|
327
|
-
return service.getSnapshot?.()
|
|
328
|
-
} catch {
|
|
329
|
-
return null
|
|
330
|
-
}
|
|
185
|
+
node = child
|
|
186
|
+
node.read += counter.read
|
|
187
|
+
node.create += counter.create
|
|
188
|
+
node.modify += counter.modify
|
|
331
189
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
190
|
+
node.children.push({
|
|
191
|
+
type: 'file',
|
|
192
|
+
name,
|
|
193
|
+
abs,
|
|
194
|
+
read: counter.read,
|
|
195
|
+
create: counter.create,
|
|
196
|
+
modify: counter.modify,
|
|
197
|
+
firstSeen: counter.firstSeen,
|
|
198
|
+
lastSeen: counter.lastSeen,
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
sortNode(root)
|
|
202
|
+
compressChains(root, true)
|
|
203
|
+
return root
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── tiny external store ───────────────────────────────────────────────
|
|
207
|
+
function createStore(initial) {
|
|
208
|
+
let state = initial
|
|
209
|
+
const listeners = new Set()
|
|
210
|
+
return {
|
|
211
|
+
getSnapshot: () => state,
|
|
212
|
+
set(patch) {
|
|
213
|
+
state = { ...state, ...patch }
|
|
214
|
+
for (const listener of [...listeners]) listener()
|
|
215
|
+
},
|
|
216
|
+
subscribe(listener) {
|
|
217
|
+
listeners.add(listener)
|
|
218
|
+
return () => listeners.delete(listener)
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── data access (host routes) ─────────────────────────────────────────
|
|
224
|
+
async function fetchStats(sessionId) {
|
|
225
|
+
const response = await fetch(`/file-activity/api/stats?sessionId=${encodeURIComponent(sessionId)}`)
|
|
226
|
+
const json = await response.json()
|
|
227
|
+
if (json === null || typeof json !== 'object' || json.ok !== true) return null
|
|
228
|
+
return json.value
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Resolve the session working directory through the sidebar's native API. */
|
|
232
|
+
async function fetchSessionCwd(sessionId) {
|
|
233
|
+
try {
|
|
234
|
+
const response = await fetch('/sidebar/api/session.cwd', {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: { 'content-type': 'application/json' },
|
|
237
|
+
body: JSON.stringify({ sessionId }),
|
|
238
|
+
})
|
|
239
|
+
const json = await response.json()
|
|
240
|
+
const cwd = json?.value?.cwd
|
|
241
|
+
return typeof cwd === 'string' && cwd !== '' ? cwd : ''
|
|
242
|
+
} catch {
|
|
243
|
+
return ''
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function postRecord(sessionId, path, op) {
|
|
248
|
+
if (typeof sessionId !== 'string' || sessionId === '' || typeof path !== 'string' || path === '') return
|
|
249
|
+
void fetch('/file-activity/api/record', {
|
|
250
|
+
method: 'POST',
|
|
251
|
+
headers: { 'content-type': 'application/json' },
|
|
252
|
+
body: JSON.stringify({ sessionId, path, op }),
|
|
253
|
+
}).catch(() => {})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function postClear(sessionId) {
|
|
257
|
+
void fetch('/file-activity/api/clear', {
|
|
258
|
+
method: 'POST',
|
|
259
|
+
headers: { 'content-type': 'application/json' },
|
|
260
|
+
body: JSON.stringify({ sessionId }),
|
|
261
|
+
}).catch(() => {})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Plugin media route URL for a recorded path (authorized per session). */
|
|
265
|
+
function mediaUrlOf(sessionId, path) {
|
|
266
|
+
return `/file-activity/file?${new URLSearchParams({ sessionId, path })}`
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── fetch interception: sidebar file operations ───────────────────────
|
|
270
|
+
function methodOf(init) {
|
|
271
|
+
return (init?.method ?? 'GET').toUpperCase()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** POST body as a plain object (non-string bodies are ignored). */
|
|
275
|
+
function parseBody(init) {
|
|
276
|
+
return typeof init?.body === 'string' ? JSON.parse(init.body) : {}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Record fs.read / fs.write POSTs observed on the sidebar API. */
|
|
280
|
+
function recordSidebarFs(url, init) {
|
|
281
|
+
if (url.pathname !== '/sidebar/api/fs.read' && url.pathname !== '/sidebar/api/fs.write') return
|
|
282
|
+
if (methodOf(init) !== 'POST') return
|
|
283
|
+
const body = parseBody(init)
|
|
284
|
+
if (typeof body.sessionId !== 'string' || typeof body.path !== 'string') return
|
|
285
|
+
postRecord(body.sessionId, body.path, url.pathname === '/sidebar/api/fs.write' ? 'write' : 'read')
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Record sidebar media opens (/sidebar/file?sessionId=...&path=...). */
|
|
289
|
+
function recordMediaOpen(url, init) {
|
|
290
|
+
if (url.pathname !== '/sidebar/file' || methodOf(init) !== 'GET') return
|
|
291
|
+
const sessionId = url.searchParams.get('sessionId')
|
|
292
|
+
const path = url.searchParams.get('path')
|
|
293
|
+
if (sessionId !== null && path !== null) postRecord(sessionId, path, 'read')
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Observe a resolved fetch URL and record sidebar file operations. */
|
|
297
|
+
function observeSidebarFetch(url, init) {
|
|
298
|
+
try {
|
|
299
|
+
recordSidebarFs(url, init)
|
|
300
|
+
recordMediaOpen(url, init)
|
|
301
|
+
} catch {
|
|
302
|
+
// observation must never break the underlying call
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function installFetchInterceptor() {
|
|
307
|
+
const original = window.fetch.bind(window)
|
|
308
|
+
window.fetch = (input, init) => {
|
|
309
|
+
const result = original(input, init)
|
|
310
|
+
let url
|
|
311
|
+
try {
|
|
312
|
+
if (typeof input === 'string') url = new URL(input, window.location.href)
|
|
313
|
+
else if (input instanceof URL) url = input
|
|
314
|
+
else return result // Request instances: skip observation
|
|
315
|
+
} catch {
|
|
316
|
+
return result
|
|
337
317
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
// ignore
|
|
354
|
-
}
|
|
318
|
+
observeSidebarFetch(url, init)
|
|
319
|
+
return result
|
|
320
|
+
}
|
|
321
|
+
return () => {
|
|
322
|
+
window.fetch = original
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── auto-open (enabled by default) ────────────────────────────────────
|
|
327
|
+
function findTabIn(state, tabId) {
|
|
328
|
+
const leaves = (node) => (node.kind === 'leaf' ? [node] : (node.children ?? []).flatMap(leaves))
|
|
329
|
+
for (const node of [state?.splits, state?.bottomSplits]) {
|
|
330
|
+
if (node === undefined || node === null) continue
|
|
331
|
+
for (const leaf of leaves(node)) {
|
|
332
|
+
if ((leaf.tabs ?? []).some((tab) => tab.type === tabId)) return true
|
|
355
333
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
334
|
+
}
|
|
335
|
+
return false
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Current sidebar snapshot, or null when the service is not ready. */
|
|
339
|
+
function sidebarSnapshot(service) {
|
|
340
|
+
try {
|
|
341
|
+
return service.getSnapshot?.()
|
|
342
|
+
} catch {
|
|
343
|
+
return null
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The user disabled auto-open for this tab in the sidebar settings. */
|
|
348
|
+
function isAutoOpenDisabled(snapshot, tabId) {
|
|
349
|
+
const settings = snapshot.prefs?.pluginSettings?.[tabId]
|
|
350
|
+
return settings !== undefined && settings.autoOpen === false
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Whether this session was already auto-opened (localStorage marker). */
|
|
354
|
+
function isAutoOpenMarked(sessionId) {
|
|
355
|
+
try {
|
|
356
|
+
return Boolean(window.localStorage.getItem(AUTO_OPEN_KEY + sessionId))
|
|
357
|
+
} catch {
|
|
358
|
+
return true
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Persist the auto-opened marker for this session. */
|
|
363
|
+
function markAutoOpened(sessionId) {
|
|
364
|
+
try {
|
|
365
|
+
window.localStorage.setItem(AUTO_OPEN_KEY + sessionId, '1')
|
|
366
|
+
} catch {
|
|
367
|
+
// ignore
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Open the tab once per session unless disabled in the plugin settings. */
|
|
372
|
+
function tryAutoOpen(service, tabId) {
|
|
373
|
+
const snapshot = sidebarSnapshot(service)
|
|
374
|
+
if (snapshot === undefined || snapshot === null || snapshot.sessionId === undefined || snapshot.state === undefined)
|
|
375
|
+
return
|
|
376
|
+
const sessionId = snapshot.sessionId
|
|
377
|
+
if (isAutoOpenDisabled(snapshot, tabId)) return
|
|
378
|
+
if (isAutoOpenMarked(sessionId)) return
|
|
379
|
+
if (findTabIn(snapshot.state, tabId)) {
|
|
380
|
+
markAutoOpened(sessionId)
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
service.openTab({ type: tabId, title: strings.title(), path: '' })
|
|
385
|
+
markAutoOpened(sessionId)
|
|
386
|
+
} catch (error) {
|
|
387
|
+
console.error('[dsh-file-activity] auto-open failed:', error)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function installAutoOpen(ctx, tabId) {
|
|
392
|
+
const service = ctx.betterSidebar
|
|
393
|
+
tryAutoOpen(service, tabId)
|
|
394
|
+
let off = () => {}
|
|
395
|
+
try {
|
|
396
|
+
off = service.subscribeState?.(() => tryAutoOpen(service, tabId)) ?? off
|
|
397
|
+
} catch {
|
|
398
|
+
// service may lack subscribeState on older versions
|
|
399
|
+
}
|
|
400
|
+
return off
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ── shared icons (inline, stroke=currentColor, matching better-sidebar) ──
|
|
404
|
+
// Single source of truth for the plugin UI icon set (issue #54 阶段 0).
|
|
405
|
+
// Extracted from dsh-file-activity's lib/parts/icons.part.js; every plugin's
|
|
406
|
+
// scripts/build.mjs splices this file via the `shared: true` piece marker.
|
|
407
|
+
// Keep the stroke=currentColor outline style — it inherits the surrounding
|
|
408
|
+
// text color and reads on both light and dark themes.
|
|
409
|
+
const ICON_STROKE = 1.8
|
|
410
|
+
const iconSvg = (children, size) =>
|
|
411
|
+
createElement(
|
|
412
|
+
'svg',
|
|
413
|
+
{
|
|
414
|
+
width: size,
|
|
415
|
+
height: size,
|
|
416
|
+
viewBox: '0 0 24 24',
|
|
417
|
+
fill: 'none',
|
|
418
|
+
stroke: 'currentColor',
|
|
419
|
+
strokeWidth: ICON_STROKE,
|
|
420
|
+
strokeLinecap: 'round',
|
|
421
|
+
strokeLinejoin: 'round',
|
|
422
|
+
'aria-hidden': 'true',
|
|
423
|
+
},
|
|
424
|
+
children.map((child, i) =>
|
|
425
|
+
child === null || child === undefined || typeof child === 'boolean'
|
|
396
426
|
? child
|
|
397
|
-
: createElement(child.type, { key: i, ...child.props })
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
427
|
+
: createElement(child.type, { key: i, ...child.props }),
|
|
428
|
+
),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
const icon = {
|
|
432
|
+
clock: (size = 16) =>
|
|
433
|
+
iconSvg([createElement('circle', { cx: 12, cy: 12, r: 9 }), createElement('path', { d: 'M12 7v5l3 2' })], size),
|
|
434
|
+
refresh: (size = 16) =>
|
|
435
|
+
iconSvg(
|
|
436
|
+
[
|
|
405
437
|
createElement('path', { d: 'M21 12a9 9 0 1 1-2.64-6.36' }),
|
|
406
438
|
createElement('polyline', { points: '21 3 21 9 15 9' }),
|
|
407
|
-
],
|
|
408
|
-
|
|
439
|
+
],
|
|
440
|
+
size,
|
|
441
|
+
),
|
|
442
|
+
trash: (size = 16) =>
|
|
443
|
+
iconSvg(
|
|
444
|
+
[
|
|
409
445
|
createElement('path', { d: 'M3 6h18' }),
|
|
410
446
|
createElement('path', { d: 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6' }),
|
|
411
447
|
createElement('path', { d: 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2' }),
|
|
412
|
-
],
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
448
|
+
],
|
|
449
|
+
size,
|
|
450
|
+
),
|
|
451
|
+
chevronRight: (size = 14) => iconSvg([createElement('polyline', { points: '9 6 15 12 9 18' })], size),
|
|
452
|
+
chevronDown: (size = 14) => iconSvg([createElement('polyline', { points: '6 9 12 15 18 9' })], size),
|
|
453
|
+
file: (size = 16) =>
|
|
454
|
+
iconSvg(
|
|
455
|
+
[
|
|
420
456
|
createElement('path', { d: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }),
|
|
421
457
|
createElement('path', { d: 'M14 2v6h6' }),
|
|
422
|
-
],
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
458
|
+
],
|
|
459
|
+
size,
|
|
460
|
+
),
|
|
461
|
+
folder: (size = 16) =>
|
|
462
|
+
iconSvg(
|
|
463
|
+
[
|
|
464
|
+
createElement('path', {
|
|
465
|
+
d: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
|
|
466
|
+
}),
|
|
467
|
+
],
|
|
468
|
+
size,
|
|
469
|
+
),
|
|
470
|
+
external: (size = 15) =>
|
|
471
|
+
iconSvg(
|
|
472
|
+
[
|
|
427
473
|
createElement('path', { d: 'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6' }),
|
|
428
474
|
createElement('polyline', { points: '15 3 21 3 21 9' }),
|
|
429
475
|
createElement('line', { x1: 10, y1: 14, x2: 21, y2: 3 }),
|
|
430
|
-
],
|
|
431
|
-
|
|
476
|
+
],
|
|
477
|
+
size,
|
|
478
|
+
),
|
|
479
|
+
close: (size = 15) =>
|
|
480
|
+
iconSvg(
|
|
481
|
+
[
|
|
432
482
|
createElement('line', { x1: 18, y1: 6, x2: 6, y2: 18 }),
|
|
433
483
|
createElement('line', { x1: 6, y1: 6, x2: 18, y2: 18 }),
|
|
434
|
-
],
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
484
|
+
],
|
|
485
|
+
size,
|
|
486
|
+
),
|
|
487
|
+
help: (size = 16) =>
|
|
488
|
+
iconSvg(
|
|
489
|
+
[
|
|
490
|
+
createElement('circle', { cx: 12, cy: 12, r: 9 }),
|
|
491
|
+
createElement('path', { d: 'M9.1 9.2a3 3 0 0 1 5.8 1.2c0 1.8-2.7 2.4-2.7 3.6' }),
|
|
492
|
+
createElement('line', { x1: 12, y1: 17.2, x2: 12.01, y2: 17.2 }),
|
|
493
|
+
],
|
|
494
|
+
size,
|
|
495
|
+
),
|
|
496
|
+
// ── generic action icons (issue #54 阶段 0) ─────────────────────────────
|
|
497
|
+
// Added for the upcoming plugin UI refresh: save/confirm (check), add/
|
|
498
|
+
// install (plus), market search (search), settings entry (settings).
|
|
499
|
+
check: (size = 16) => iconSvg([createElement('polyline', { points: '20 6 9 17 4 12' })], size),
|
|
500
|
+
plus: (size = 16) =>
|
|
501
|
+
iconSvg(
|
|
502
|
+
[
|
|
503
|
+
createElement('line', { x1: 12, y1: 5, x2: 12, y2: 19 }),
|
|
504
|
+
createElement('line', { x1: 5, y1: 12, x2: 19, y2: 12 }),
|
|
505
|
+
],
|
|
506
|
+
size,
|
|
507
|
+
),
|
|
508
|
+
pencil: (size = 15) =>
|
|
509
|
+
iconSvg([createElement('path', { d: 'M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z' })], size),
|
|
510
|
+
search: (size = 16) =>
|
|
511
|
+
iconSvg(
|
|
512
|
+
[
|
|
513
|
+
createElement('circle', { cx: 11, cy: 11, r: 8 }),
|
|
514
|
+
createElement('line', { x1: 21, y1: 21, x2: 16.65, y2: 16.65 }),
|
|
515
|
+
],
|
|
516
|
+
size,
|
|
517
|
+
),
|
|
518
|
+
settings: (size = 16) =>
|
|
519
|
+
iconSvg(
|
|
520
|
+
[
|
|
521
|
+
createElement('circle', { cx: 12, cy: 12, r: 3 }),
|
|
522
|
+
createElement('path', {
|
|
523
|
+
d: 'M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z',
|
|
524
|
+
}),
|
|
525
|
+
],
|
|
526
|
+
size,
|
|
527
|
+
),
|
|
528
|
+
// 警告(issue #54 阶段 1 新增):安全护栏告警类型图标(投毒/提示注入),
|
|
529
|
+
// 三角警示 + 感叹号,stroke=currentColor 风格与其余图标一致。
|
|
530
|
+
alert: (size = 16) =>
|
|
531
|
+
iconSvg(
|
|
532
|
+
[
|
|
533
|
+
createElement('path', {
|
|
534
|
+
d: 'M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z',
|
|
535
|
+
}),
|
|
536
|
+
createElement('line', { x1: 12, y1: 9, x2: 12, y2: 13 }),
|
|
537
|
+
createElement('line', { x1: 12, y1: 17, x2: 12.01, y2: 17 }),
|
|
538
|
+
],
|
|
539
|
+
size,
|
|
540
|
+
),
|
|
541
|
+
// 代码(issue #54 阶段 1 新增):尖括号 `</>`,预览/代码切换的代码视图
|
|
542
|
+
// 图标(dsh-mermaid-render 卡片),stroke=currentColor 风格与其余图标一致。
|
|
543
|
+
code: (size = 16) =>
|
|
544
|
+
iconSvg(
|
|
545
|
+
[
|
|
546
|
+
createElement('polyline', { points: '16 18 22 12 16 6' }),
|
|
547
|
+
createElement('polyline', { points: '8 6 2 12 8 18' }),
|
|
548
|
+
],
|
|
549
|
+
size,
|
|
550
|
+
),
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Common-language / file-type badges (issue #24): brand fill + contrast
|
|
554
|
+
// ink, reading on both light and dark themes. Unmapped extensions keep the
|
|
555
|
+
// neutral currentColor file icon above. [bg, fg ink, short mark]
|
|
556
|
+
const FILE_BADGES = {
|
|
557
|
+
// JavaScript / TypeScript
|
|
558
|
+
js: ['#F7DF1E', '#323330', 'JS'],
|
|
559
|
+
mjs: ['#F7DF1E', '#323330', 'JS'],
|
|
560
|
+
cjs: ['#F7DF1E', '#323330', 'JS'],
|
|
561
|
+
ts: ['#3178C6', '#ffffff', 'TS'],
|
|
562
|
+
mts: ['#3178C6', '#ffffff', 'TS'],
|
|
563
|
+
cts: ['#3178C6', '#ffffff', 'TS'],
|
|
564
|
+
tsx: ['#3178C6', '#ffffff', 'TSX'],
|
|
565
|
+
jsx: ['#3178C6', '#ffffff', 'JSX'],
|
|
566
|
+
// 后端语言
|
|
567
|
+
java: ['#007396', '#ffffff', 'JAVA'],
|
|
568
|
+
c: ['#A8B9CC', '#111111', 'C'],
|
|
569
|
+
cpp: ['#00599C', '#ffffff', 'C++'],
|
|
570
|
+
cxx: ['#00599C', '#ffffff', 'C++'],
|
|
571
|
+
cc: ['#00599C', '#ffffff', 'C++'],
|
|
572
|
+
hpp: ['#00599C', '#ffffff', 'C++'],
|
|
573
|
+
h: ['#A8B9CC', '#111111', 'H'],
|
|
574
|
+
hh: ['#A8B9CC', '#111111', 'H'],
|
|
575
|
+
cs: ['#68217A', '#ffffff', 'C#'],
|
|
576
|
+
csharp: ['#68217A', '#ffffff', 'C#'],
|
|
577
|
+
go: ['#00ADD8', '#ffffff', 'GO'],
|
|
578
|
+
rs: ['#CE422B', '#ffffff', 'RS'],
|
|
579
|
+
rb: ['#B51624', '#ffffff', 'RB'],
|
|
580
|
+
php: ['#777BB4', '#ffffff', 'PHP'],
|
|
581
|
+
py: ['#3776AB', '#ffffff', 'PY'],
|
|
582
|
+
swift: ['#F05138', '#ffffff', 'SWIFT'],
|
|
583
|
+
kt: ['#7F52FF', '#ffffff', 'KT'],
|
|
584
|
+
kotlin: ['#7F52FF', '#ffffff', 'KT'],
|
|
585
|
+
dart: ['#0175C2', '#ffffff', 'DART'],
|
|
586
|
+
scala: ['#DC322F', '#ffffff', 'SCALA'],
|
|
587
|
+
lua: ['#2C2C7C', '#ffffff', 'LUA'],
|
|
588
|
+
pl: ['#0298C3', '#ffffff', 'PERL'],
|
|
589
|
+
r: ['#336DC3', '#ffffff', 'R'],
|
|
590
|
+
m: ['#C1272D', '#ffffff', 'MAT'],
|
|
591
|
+
mm: ['#C1272D', '#ffffff', 'MAT'],
|
|
592
|
+
// Web / 前端
|
|
593
|
+
html: ['#E34F26', '#ffffff', '</>'],
|
|
594
|
+
htm: ['#E34F26', '#ffffff', '</>'],
|
|
595
|
+
css: ['#663399', '#ffffff', 'CSS'],
|
|
596
|
+
scss: ['#CD6799', '#ffffff', 'SCSS'],
|
|
597
|
+
sass: ['#CD6799', '#ffffff', 'SCSS'],
|
|
598
|
+
vue: ['#42B883', '#ffffff', 'VUE'],
|
|
599
|
+
svelte: ['#FF3E00', '#ffffff', 'SVELTE'],
|
|
600
|
+
// 数据 / 结构化
|
|
601
|
+
json: ['#F7DF1E', '#323330', '{}'],
|
|
602
|
+
sql: ['#00758F', '#ffffff', 'SQL'],
|
|
603
|
+
csv: ['#2E7D32', '#ffffff', 'CSV'],
|
|
604
|
+
db: ['#0F62FE', '#ffffff', 'DB'],
|
|
605
|
+
sqlite: ['#0F62FE', '#ffffff', 'DB'],
|
|
606
|
+
sqlite3: ['#0F62FE', '#ffffff', 'DB'],
|
|
607
|
+
xml: ['#FF6F00', '#ffffff', 'XML'],
|
|
608
|
+
svg: ['#FF6F00', '#ffffff', 'SVG'],
|
|
609
|
+
// 文档
|
|
610
|
+
md: ['#42A5F5', '#ffffff', 'M↓'],
|
|
611
|
+
markdown: ['#42A5F5', '#ffffff', 'M↓'],
|
|
612
|
+
txt: ['#90A4AE', '#ffffff', 'TXT'],
|
|
613
|
+
text: ['#90A4AE', '#ffffff', 'TXT'],
|
|
614
|
+
log: ['#90A4AE', '#ffffff', 'TXT'],
|
|
615
|
+
pdf: ['#E5202B', '#ffffff', 'PDF'],
|
|
616
|
+
doc: ['#2B579A', '#ffffff', 'DOC'],
|
|
617
|
+
docx: ['#2B579A', '#ffffff', 'DOC'],
|
|
618
|
+
xls: ['#217346', '#ffffff', 'XLS'],
|
|
619
|
+
xlsx: ['#217346', '#ffffff', 'XLS'],
|
|
620
|
+
ppt: ['#D24726', '#ffffff', 'PPT'],
|
|
621
|
+
pptx: ['#D24726', '#ffffff', 'PPT'],
|
|
622
|
+
// 配置 / 构建
|
|
623
|
+
yml: ['#CB171E', '#ffffff', 'YML'],
|
|
624
|
+
yaml: ['#CB171E', '#ffffff', 'YML'],
|
|
625
|
+
toml: ['#8D6E63', '#ffffff', 'TOML'],
|
|
626
|
+
ini: ['#546E7A', '#ffffff', 'CFG'],
|
|
627
|
+
cfg: ['#546E7A', '#ffffff', 'CFG'],
|
|
628
|
+
config: ['#546E7A', '#ffffff', 'CFG'],
|
|
629
|
+
env: ['#F9A825', '#323330', 'ENV'],
|
|
630
|
+
properties: ['#7B1FA2', '#ffffff', 'PROP'],
|
|
631
|
+
lock: ['#37474F', '#ffffff', 'LOCK'],
|
|
632
|
+
dockerfile: ['#2496ED', '#ffffff', 'DOCK'],
|
|
633
|
+
docker: ['#2496ED', '#ffffff', 'DOCK'],
|
|
634
|
+
makefile: ['#607D8B', '#ffffff', 'MAKE'],
|
|
635
|
+
gradle: ['#02303A', '#ffffff', 'GRADLE'],
|
|
636
|
+
cmake: ['#265774', '#ffffff', 'CMAKE'],
|
|
637
|
+
ipynb: ['#F37726', '#ffffff', 'JNB'],
|
|
638
|
+
// 脚本 / Shell
|
|
639
|
+
sh: ['#89E051', '#111111', '>_'],
|
|
640
|
+
bash: ['#89E051', '#111111', '>_'],
|
|
641
|
+
zsh: ['#89E051', '#111111', '>_'],
|
|
642
|
+
ps1: ['#012456', '#ffffff', 'PS1'],
|
|
643
|
+
bat: ['#546E7A', '#ffffff', 'CMD'],
|
|
644
|
+
cmd: ['#546E7A', '#ffffff', 'CMD'],
|
|
645
|
+
// 打包 / 二进制
|
|
646
|
+
zip: ['#FFA726', '#323330', 'ZIP'],
|
|
647
|
+
tar: ['#FFA726', '#323330', 'ZIP'],
|
|
648
|
+
gz: ['#FFA726', '#323330', 'ZIP'],
|
|
649
|
+
'7z': ['#FFA726', '#323330', 'ZIP'],
|
|
650
|
+
rar: ['#FFA726', '#323330', 'ZIP'],
|
|
651
|
+
exe: ['#0078D4', '#ffffff', 'EXE'],
|
|
652
|
+
msi: ['#0078D4', '#ffffff', 'EXE'],
|
|
653
|
+
wasm: ['#654FF0', '#ffffff', 'WASM'],
|
|
654
|
+
// 图片 / 媒体
|
|
655
|
+
png: ['#8E44AD', '#ffffff', 'IMG'],
|
|
656
|
+
jpg: ['#8E44AD', '#ffffff', 'IMG'],
|
|
657
|
+
jpeg: ['#8E44AD', '#ffffff', 'IMG'],
|
|
658
|
+
gif: ['#8E44AD', '#ffffff', 'IMG'],
|
|
659
|
+
webp: ['#8E44AD', '#ffffff', 'IMG'],
|
|
660
|
+
ico: ['#8E44AD', '#ffffff', 'IMG'],
|
|
661
|
+
bmp: ['#8E44AD', '#ffffff', 'IMG'],
|
|
662
|
+
// 版本控制
|
|
663
|
+
gitignore: ['#F05032', '#ffffff', 'GIT'],
|
|
664
|
+
gitattributes: ['#F05032', '#ffffff', 'GIT'],
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** One self-colored badge svg: rounded brand rect + short contrast mark.
|
|
668
|
+
* Mark font scales by length so 5-6 char marks (JAVA/SCALA/SWIFT) stay
|
|
669
|
+
* inside the 24×24 viewBox. */
|
|
670
|
+
const badgeIcon = ([bg, fg, mark], size) =>
|
|
671
|
+
createElement(
|
|
672
|
+
'svg',
|
|
673
|
+
{
|
|
674
|
+
width: size,
|
|
675
|
+
height: size,
|
|
676
|
+
viewBox: '0 0 24 24',
|
|
677
|
+
'aria-hidden': 'true',
|
|
678
|
+
},
|
|
679
|
+
createElement('rect', { x: 1, y: 1, width: 22, height: 22, rx: 5, fill: bg }),
|
|
680
|
+
createElement(
|
|
681
|
+
'text',
|
|
682
|
+
{
|
|
683
|
+
x: 12,
|
|
684
|
+
y: 16,
|
|
685
|
+
textAnchor: 'middle',
|
|
686
|
+
fontSize: mark.length <= 2 ? 9 : mark.length <= 4 ? 7 : 5.5,
|
|
687
|
+
fontWeight: 700,
|
|
688
|
+
fill: fg,
|
|
689
|
+
},
|
|
690
|
+
mark,
|
|
691
|
+
),
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
/** File-type icon dispatcher: branded badge for known extensions, the
|
|
695
|
+
* neutral file icon for everything else (case-insensitive, tolerates a
|
|
696
|
+
* leading dot like ".md"). */
|
|
697
|
+
const fileIconByExt = (ext, size = 14) => {
|
|
698
|
+
const spec =
|
|
699
|
+
FILE_BADGES[
|
|
700
|
+
String(ext ?? '')
|
|
701
|
+
.toLowerCase()
|
|
702
|
+
.replace(/^\./, '')
|
|
703
|
+
]
|
|
704
|
+
return spec === undefined ? icon.file(size) : badgeIcon(spec, size)
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ── themed stylesheet (injected once per activation) ──────────────────
|
|
708
|
+
// Mirrors the better-sidebar explorer surface: tight 2px 6px 8px body,
|
|
709
|
+
// 30px rows, box-sizing border-box indentation, folder rows use the
|
|
710
|
+
// strong type face to read as directories, files stay regular.
|
|
711
|
+
const STYLES = `
|
|
442
712
|
.dfa { display:flex; flex-direction:column; height:100%; overflow-y:auto; overflow-x:hidden;
|
|
443
713
|
padding:2px 6px 8px; gap:2px; font:var(--dsw-font-s-14); color:var(--dsw-alias-label-primary); }
|
|
444
714
|
.dfa-iconbtn { display:inline-flex; align-items:center; justify-content:center; width:24px; height:24px; padding:0;
|
|
@@ -510,520 +780,675 @@ window.__ModuleLoader__.load({
|
|
|
510
780
|
.dfa-pdf-download:hover { text-decoration:underline; }
|
|
511
781
|
.dfa-pdf-frame { flex:1; min-height:0; width:100%; border:none; border-radius:6px; background:transparent; }
|
|
512
782
|
@keyframes dfa-row-in { from { opacity:0; transform:translateY(1px); } to { opacity:1; transform:none; } }
|
|
783
|
+
/* issue #60: 移除 #25 的侧边栏页签选中态品牌蓝覆盖([class*="tab"][class*=
|
|
784
|
+
"tabActive"] 全局子串选择器误伤宿主对话/工作区 tab 选中态,出现用户不
|
|
785
|
+
想要的蓝色高亮)。页签选中态回归宿主(dsh-better-sidebar)默认样式。 */
|
|
513
786
|
`
|
|
514
787
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
788
|
+
// ── row rendering helpers (recent list & stats tree) ──────────────────
|
|
789
|
+
const opClass = (op) =>
|
|
790
|
+
op === 'create'
|
|
791
|
+
? 'dfa-op-create'
|
|
792
|
+
: op === 'modify'
|
|
793
|
+
? 'dfa-op-modify'
|
|
794
|
+
: op === 'delete'
|
|
795
|
+
? 'dfa-op-delete'
|
|
796
|
+
: 'dfa-op-read'
|
|
797
|
+
const opLabel = (op) =>
|
|
798
|
+
op === 'create'
|
|
799
|
+
? strings.create()
|
|
800
|
+
: op === 'modify'
|
|
801
|
+
? strings.modify()
|
|
802
|
+
: op === 'delete'
|
|
803
|
+
? strings.delete()
|
|
804
|
+
: strings.read()
|
|
805
|
+
|
|
806
|
+
/** Tooltip for a stats file row: absolute path + created / last-seen times. */
|
|
807
|
+
const fileTitle = (abs, firstSeen, lastSeen) => {
|
|
808
|
+
const times = []
|
|
809
|
+
if (typeof firstSeen === 'number') times.push(`${strings.created()} ${formatTime(firstSeen)}`)
|
|
810
|
+
if (typeof lastSeen === 'number') times.push(`${strings.lastSeen()} ${formatTime(lastSeen)}`)
|
|
811
|
+
return times.length > 0 ? `${abs}\n${times.join(' · ')}` : abs
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** Count pills for a file/dir node — only actions that actually happened are
|
|
815
|
+
* shown (a zero count renders no pill; all-zero nodes render no pill group,
|
|
816
|
+
* keeping untouched files visually quiet). */
|
|
817
|
+
const countPills = (node) => {
|
|
818
|
+
const pills = []
|
|
819
|
+
if (node.read > 0)
|
|
820
|
+
pills.push(createElement('span', { className: 'dfa-count dfa-count-read' }, `${strings.readShort()} ${node.read}`))
|
|
821
|
+
if (node.create > 0)
|
|
822
|
+
pills.push(
|
|
823
|
+
createElement('span', { className: 'dfa-count dfa-count-create' }, `${strings.createShort()} ${node.create}`),
|
|
824
|
+
)
|
|
825
|
+
if (node.modify > 0)
|
|
826
|
+
pills.push(
|
|
827
|
+
createElement('span', { className: 'dfa-count dfa-count-modify' }, `${strings.modifyShort()} ${node.modify}`),
|
|
828
|
+
)
|
|
829
|
+
if (pills.length === 0) return null
|
|
830
|
+
return createElement('span', { className: 'dfa-counts', style: { paddingLeft: '6px' } }, ...pills)
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** Extension of a file name (lowercase, no leading dot); '' when none.
|
|
834
|
+
* Dotfiles map to their whole name ('.gitignore' → 'gitignore') so the
|
|
835
|
+
* badge table can cover them; 'notes.' still yields ''. */
|
|
836
|
+
const extOf = (name) => {
|
|
837
|
+
const dot = name.lastIndexOf('.')
|
|
838
|
+
if (dot > 0) return name.slice(dot + 1).toLowerCase()
|
|
839
|
+
if (dot === 0) return name.slice(1).toLowerCase()
|
|
840
|
+
return ''
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Extension-less but common build files → their badge key. */
|
|
844
|
+
const NAME_BADGES = {
|
|
845
|
+
makefile: 'makefile',
|
|
846
|
+
dockerfile: 'dockerfile',
|
|
847
|
+
'cmakelists.txt': 'cmake',
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Badge key for a file name: basename match first, then extension. */
|
|
851
|
+
const badgeKeyOf = (name) => {
|
|
852
|
+
const base = name.toLowerCase()
|
|
853
|
+
const named = NAME_BADGES[base]
|
|
854
|
+
if (named !== undefined) return named
|
|
855
|
+
return extOf(name)
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** A stats-tree file row: icon + name + count pills + relative time. */
|
|
859
|
+
const fileRow = (file, depth, onOpen) =>
|
|
860
|
+
createElement(
|
|
861
|
+
'div',
|
|
862
|
+
{
|
|
863
|
+
key: file.abs,
|
|
864
|
+
className: 'dfa-row',
|
|
865
|
+
onClick: () => onOpen(file.abs),
|
|
866
|
+
style: { paddingLeft: 8 + depth * 20 },
|
|
867
|
+
title: fileTitle(file.abs, file.firstSeen, file.lastSeen),
|
|
868
|
+
},
|
|
869
|
+
createElement('span', { className: 'dfa-row-icon dfa-icon-file' }, fileIconByExt(badgeKeyOf(file.name))),
|
|
870
|
+
createElement('span', { className: 'dfa-row-name dfa-name-file' }, file.name),
|
|
871
|
+
countPills(file),
|
|
872
|
+
file.lastSeen ? createElement('span', { className: 'dfa-time' }, formatRelative(file.lastSeen)) : null,
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
/** One stats-tree node: file rows render inline, dirs toggle collapse. */
|
|
876
|
+
function renderTreeNode(node, depth, collapsedDirs, onToggleDir, onOpen) {
|
|
877
|
+
if (node.type === 'file') return fileRow(node, depth, onOpen)
|
|
878
|
+
const collapsed = collapsedDirs.has(node.path)
|
|
879
|
+
return createElement(
|
|
880
|
+
'div',
|
|
881
|
+
{ key: node.path },
|
|
882
|
+
createElement(
|
|
883
|
+
'div',
|
|
884
|
+
{
|
|
885
|
+
className: 'dfa-row dfa-row-dir',
|
|
886
|
+
onClick: () => onToggleDir(node.path),
|
|
887
|
+
style: { paddingLeft: 8 + depth * 20 },
|
|
888
|
+
title: `${node.path}/`,
|
|
889
|
+
},
|
|
890
|
+
createElement('span', { className: 'dfa-chevron' }, collapsed ? icon.chevronRight(13) : icon.chevronDown(13)),
|
|
891
|
+
createElement('span', { className: 'dfa-row-icon dfa-icon-folder' }, icon.folder(14)),
|
|
892
|
+
createElement('span', { className: 'dfa-row-name' }, node.compressed ? node.name : node.name + '/'),
|
|
893
|
+
countPills(node),
|
|
894
|
+
),
|
|
895
|
+
collapsed
|
|
896
|
+
? null
|
|
897
|
+
: node.children.map((child) => renderTreeNode(child, depth + 1, collapsedDirs, onToggleDir, onOpen)),
|
|
898
|
+
)
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** A recent-list row: op badge + basename + relative time. */
|
|
902
|
+
const recentEntry = (entry, onOpen) =>
|
|
903
|
+
createElement(
|
|
904
|
+
'div',
|
|
905
|
+
{
|
|
906
|
+
key: `${entry.path}:${entry.time}:${entry.op}`,
|
|
907
|
+
className: 'dfa-row',
|
|
908
|
+
onClick: () => onOpen(entry.path),
|
|
909
|
+
title: entry.path,
|
|
910
|
+
},
|
|
911
|
+
createElement('span', { className: `dfa-op ${opClass(entry.op)}` }, opLabel(entry.op)),
|
|
912
|
+
createElement('span', { className: 'dfa-row-name' }, basenameOf(entry.path)),
|
|
913
|
+
createElement('span', { className: 'dfa-time' }, formatRelative(entry.time)),
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
/** Toggle a key in a Set (directory collapse state). */
|
|
917
|
+
function toggleInSet(set, key) {
|
|
918
|
+
const next = new Set(set)
|
|
919
|
+
if (next.has(key)) next.delete(key)
|
|
920
|
+
else next.add(key)
|
|
921
|
+
return next
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** Clear the current session's records host-side and reset its bucket. */
|
|
925
|
+
function clearSessionData(dataStore, sessionId) {
|
|
926
|
+
if (!window.confirm(strings.clearConfirm())) return
|
|
927
|
+
postClear(sessionId)
|
|
928
|
+
const current = dataStore.getSnapshot()
|
|
929
|
+
dataStore.set({
|
|
930
|
+
bySession: {
|
|
931
|
+
...(current.bySession ?? {}),
|
|
932
|
+
[sessionId]: { recent: [], counts: {}, loading: false },
|
|
933
|
+
},
|
|
934
|
+
})
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** Manual refresh: fetch stats + the authoritative cwd for this session. */
|
|
938
|
+
function refreshSessionData(dataStore, sessionId, setCwd, setError) {
|
|
939
|
+
if (sessionId === '') return
|
|
940
|
+
void fetchStats(sessionId)
|
|
941
|
+
.then((value) => {
|
|
942
|
+
if (value === null) return
|
|
943
|
+
setCwd((prev) => prev || value.cwd || '')
|
|
613
944
|
const current = dataStore.getSnapshot()
|
|
614
945
|
dataStore.set({
|
|
615
946
|
bySession: {
|
|
616
947
|
...(current.bySession ?? {}),
|
|
617
|
-
[sessionId]: { recent: [], counts: {}, loading: false },
|
|
948
|
+
[sessionId]: { recent: value.recent ?? [], counts: value.counts ?? {}, loading: false },
|
|
618
949
|
},
|
|
619
950
|
})
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
...(current.bySession ?? {}),
|
|
632
|
-
[sessionId]: { recent: value.recent ?? [], counts: value.counts ?? {}, loading: false },
|
|
633
|
-
},
|
|
634
|
-
})
|
|
635
|
-
setError(false)
|
|
636
|
-
}).catch(() => setError(true))
|
|
637
|
-
void fetchSessionCwd(sessionId).then((cwd) => {
|
|
638
|
-
if (cwd !== '') setCwd(cwd)
|
|
639
|
-
})
|
|
640
|
-
}
|
|
951
|
+
setError(false)
|
|
952
|
+
})
|
|
953
|
+
.catch(() => setError(true))
|
|
954
|
+
void fetchSessionCwd(sessionId).then((cwd) => {
|
|
955
|
+
if (cwd !== '') setCwd(cwd)
|
|
956
|
+
})
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// ── view component ────────────────────────────────────────────────────
|
|
960
|
+
/** Shared empty bucket for sessions that have never loaded data (stable ref). */
|
|
961
|
+
const EMPTY_SESSION = { recent: [], counts: {}, loading: true }
|
|
641
962
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
963
|
+
/**
|
|
964
|
+
* Polling loader for one session: fetches stats on mount and on a fixed
|
|
965
|
+
* interval while visible, prefers the sidebar's authoritative session.cwd
|
|
966
|
+
* for relative display, and writes results into the per-session bucket.
|
|
967
|
+
*/
|
|
968
|
+
function useSessionLoader(visible, sessionId, scope, dataStore, setCwd, setError) {
|
|
969
|
+
useEffect(() => {
|
|
970
|
+
if (!visible || sessionId === '') return
|
|
971
|
+
let cancelled = false
|
|
972
|
+
const load = () => {
|
|
973
|
+
void fetchStats(sessionId)
|
|
974
|
+
.then((value) => {
|
|
975
|
+
if (cancelled || value === null) return
|
|
976
|
+
setCwd((prev) => prev || value.cwd || '')
|
|
977
|
+
const current = dataStore.getSnapshot()
|
|
978
|
+
dataStore.set({
|
|
979
|
+
bySession: {
|
|
980
|
+
...(current.bySession ?? {}),
|
|
981
|
+
[sessionId]: {
|
|
982
|
+
recent: value.recent ?? [],
|
|
983
|
+
counts: value.counts ?? {},
|
|
984
|
+
loading: false,
|
|
664
985
|
},
|
|
665
|
-
}
|
|
666
|
-
setError(false)
|
|
667
|
-
}).catch(() => {
|
|
668
|
-
if (!cancelled) setError(true)
|
|
986
|
+
},
|
|
669
987
|
})
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
if (!cancelled
|
|
988
|
+
setError(false)
|
|
989
|
+
})
|
|
990
|
+
.catch(() => {
|
|
991
|
+
if (!cancelled) setError(true)
|
|
674
992
|
})
|
|
675
|
-
const timer = window.setInterval(load, POLL_MS)
|
|
676
|
-
return () => {
|
|
677
|
-
cancelled = true
|
|
678
|
-
window.clearInterval(timer)
|
|
679
|
-
}
|
|
680
|
-
}, [visible, sessionId, dataStore])
|
|
681
993
|
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
994
|
+
load()
|
|
995
|
+
void fetchSessionCwd(sessionId).then((cwd) => {
|
|
996
|
+
if (!cancelled && cwd !== '') setCwd(cwd)
|
|
997
|
+
})
|
|
998
|
+
const timer = window.setInterval(load, POLL_MS)
|
|
999
|
+
return () => {
|
|
1000
|
+
cancelled = true
|
|
1001
|
+
window.clearInterval(timer)
|
|
687
1002
|
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
1003
|
+
}, [visible, sessionId, dataStore])
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** Error banner element, or null when the last load succeeded. */
|
|
1007
|
+
function renderError(error) {
|
|
1008
|
+
if (!error) return null
|
|
1009
|
+
return createElement(
|
|
1010
|
+
'div',
|
|
1011
|
+
{
|
|
1012
|
+
style: {
|
|
1013
|
+
color: 'var(--dsw-alias-state-error-primary)',
|
|
1014
|
+
padding: '4px 6px',
|
|
1015
|
+
font: 'var(--dsw-font-xxs-12)',
|
|
1016
|
+
},
|
|
1017
|
+
},
|
|
1018
|
+
strings.loadError(),
|
|
1019
|
+
)
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** "最近访问" section: collapsible head with refresh/clear actions. */
|
|
1023
|
+
function renderRecentSection(recent, recentOpen, onToggle, onRefresh, onClear, onOpen) {
|
|
1024
|
+
return createElement(
|
|
1025
|
+
'div',
|
|
1026
|
+
{ className: 'dfa-section' },
|
|
1027
|
+
createElement(
|
|
1028
|
+
'div',
|
|
1029
|
+
{ className: 'dfa-section-head' },
|
|
1030
|
+
createElement(
|
|
1031
|
+
'button',
|
|
1032
|
+
{ className: 'dfa-section-head-toggle', onClick: onToggle },
|
|
1033
|
+
recentOpen ? icon.chevronDown(13) : icon.chevronRight(13),
|
|
1034
|
+
strings.recent(),
|
|
1035
|
+
),
|
|
1036
|
+
createElement(
|
|
1037
|
+
'span',
|
|
1038
|
+
{ className: 'dfa-section-head-actions' },
|
|
694
1039
|
createElement(
|
|
695
|
-
'
|
|
696
|
-
{
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
),
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
1040
|
+
'button',
|
|
1041
|
+
{
|
|
1042
|
+
className: 'dfa-iconbtn dfa-iconbtn-xs',
|
|
1043
|
+
onClick: onRefresh,
|
|
1044
|
+
title: strings.refresh(),
|
|
1045
|
+
'aria-label': strings.refresh(),
|
|
1046
|
+
},
|
|
1047
|
+
icon.refresh(14),
|
|
1048
|
+
),
|
|
1049
|
+
createElement(
|
|
1050
|
+
'button',
|
|
1051
|
+
{
|
|
1052
|
+
className: 'dfa-iconbtn dfa-iconbtn-xs dfa-iconbtn-danger',
|
|
1053
|
+
onClick: onClear,
|
|
1054
|
+
title: strings.clear(),
|
|
1055
|
+
'aria-label': strings.clear(),
|
|
1056
|
+
},
|
|
1057
|
+
icon.trash(14),
|
|
1058
|
+
),
|
|
1059
|
+
),
|
|
1060
|
+
),
|
|
1061
|
+
!recentOpen
|
|
1062
|
+
? null
|
|
1063
|
+
: recent.length === 0
|
|
1064
|
+
? createElement(
|
|
1065
|
+
'div',
|
|
1066
|
+
{ className: 'dfa-empty' },
|
|
1067
|
+
strings.empty(),
|
|
1068
|
+
createElement('span', { className: 'dfa-empty-hint' }, strings.emptyHint()),
|
|
1069
|
+
)
|
|
1070
|
+
: createElement(
|
|
1071
|
+
'div',
|
|
1072
|
+
{ className: 'dfa-list' },
|
|
1073
|
+
recent.map((entry) => recentEntry(entry, onOpen)),
|
|
706
1074
|
),
|
|
1075
|
+
)
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** "文件统计" section: the directory tree, or an empty hint. */
|
|
1079
|
+
function renderStatsSection(tree, collapsedDirs, onToggleDir, onOpen) {
|
|
1080
|
+
return createElement(
|
|
1081
|
+
'div',
|
|
1082
|
+
{ className: 'dfa-section' },
|
|
1083
|
+
createElement('div', { className: 'dfa-section-head' }, strings.stats()),
|
|
1084
|
+
tree.children.length === 0
|
|
1085
|
+
? createElement('div', { className: 'dfa-empty' }, strings.empty())
|
|
1086
|
+
: createElement(
|
|
1087
|
+
'div',
|
|
1088
|
+
{ className: 'dfa-list' },
|
|
1089
|
+
tree.children.map((child) => renderTreeNode(child, 0, collapsedDirs, onToggleDir, onOpen)),
|
|
707
1090
|
),
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
'div',
|
|
711
|
-
{ className: 'dfa-empty' },
|
|
712
|
-
strings.empty(),
|
|
713
|
-
createElement('span', { className: 'dfa-empty-hint' }, strings.emptyHint()),
|
|
714
|
-
)
|
|
715
|
-
: createElement('div', { className: 'dfa-list' }, recent.map((entry) => recentEntry(entry, onOpen))),
|
|
716
|
-
)
|
|
717
|
-
}
|
|
1091
|
+
)
|
|
1092
|
+
}
|
|
718
1093
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
1094
|
+
/**
|
|
1095
|
+
* The file-activity tab. Each session renders only its own store bucket:
|
|
1096
|
+
* a fresh conversation shows an empty list immediately, with no residue
|
|
1097
|
+
* from the previous session. Clicking any file opens a FLOATING preview
|
|
1098
|
+
* that reuses the sidebar's NATIVE viewer via matchFileViewer.
|
|
1099
|
+
*/
|
|
1100
|
+
function FileActivityView({ ctx, store, scope, visible, dataStore }) {
|
|
1101
|
+
const data = useSyncExternalStore(dataStore.subscribe, dataStore.getSnapshot)
|
|
1102
|
+
const [cwd, setCwd] = useState(scope?.cwd || '')
|
|
1103
|
+
const [error, setError] = useState(false)
|
|
1104
|
+
const [recentOpen, setRecentOpen] = useState(true)
|
|
1105
|
+
const [collapsedDirs, setCollapsedDirs] = useState(() => new Set())
|
|
1106
|
+
const sessionId = scope?.sessionId ?? ''
|
|
1107
|
+
const sessionData = (data.bySession ?? {})[sessionId] ?? EMPTY_SESSION
|
|
1108
|
+
const tree = useMemo(() => buildTree(sessionData.counts ?? {}), [sessionData.counts])
|
|
1109
|
+
useEffect(() => {
|
|
1110
|
+
if (scope?.cwd) setCwd(scope.cwd)
|
|
1111
|
+
}, [scope?.cwd])
|
|
1112
|
+
useSessionLoader(visible, sessionId, scope, dataStore, setCwd, setError)
|
|
1113
|
+
// Switching conversations closes any floating preview left open by the
|
|
1114
|
+
// previous session (preview is shared UI state; session data never
|
|
1115
|
+
// crosses sessions anymore).
|
|
1116
|
+
useEffect(() => {
|
|
1117
|
+
dataStore.set({ preview: null })
|
|
1118
|
+
}, [sessionId, dataStore])
|
|
1119
|
+
const toggleDir = (path) => setCollapsedDirs((prev) => toggleInSet(prev, path))
|
|
1120
|
+
const openPreview = (path) => dataStore.set({ preview: { abs: path, name: basenameOf(path) } })
|
|
1121
|
+
const closePreview = () => dataStore.set({ preview: null })
|
|
1122
|
+
const onClear = () => clearSessionData(dataStore, sessionId)
|
|
1123
|
+
const onRefresh = () => refreshSessionData(dataStore, sessionId, setCwd, setError)
|
|
1124
|
+
const recent = sessionData.recent ?? []
|
|
1125
|
+
return createElement(
|
|
1126
|
+
'div',
|
|
1127
|
+
{ className: 'dfa' },
|
|
1128
|
+
renderError(error),
|
|
1129
|
+
renderRecentSection(recent, recentOpen, () => setRecentOpen((v) => !v), onRefresh, onClear, openPreview),
|
|
1130
|
+
renderStatsSection(tree, collapsedDirs, toggleDir, openPreview),
|
|
1131
|
+
data.preview
|
|
1132
|
+
? createElement(FloatingPreview, {
|
|
1133
|
+
ctx,
|
|
1134
|
+
store,
|
|
1135
|
+
scope,
|
|
1136
|
+
preview: data.preview,
|
|
1137
|
+
onClose: closePreview,
|
|
1138
|
+
})
|
|
1139
|
+
: null,
|
|
1140
|
+
)
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// ── floating preview window (reuses the sidebar's native viewer) ──────
|
|
1144
|
+
/** Resolve a possibly-relative path against the session cwd. */
|
|
1145
|
+
function resolvePath(path, cwd) {
|
|
1146
|
+
if (typeof path !== 'string' || path === '') return path
|
|
1147
|
+
if (path.startsWith('/')) return path
|
|
1148
|
+
if (typeof cwd === 'string' && cwd !== '') return `${cwd.replace(/\/+$/, '')}/${path}`
|
|
1149
|
+
return path
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/** Whether the fs.read API response carries a text content payload. */
|
|
1153
|
+
function isFsReadOk(json) {
|
|
1154
|
+
return json !== null && typeof json === 'object' && json.ok === true && typeof json.value?.content === 'string'
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/** Error load state from an fs.read API response (or a generic message). */
|
|
1158
|
+
function fsReadError(json, viewer) {
|
|
1159
|
+
return { status: 'error', viewer, message: json?.error?.message ?? strings.previewFailed() }
|
|
1160
|
+
}
|
|
785
1161
|
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
1162
|
+
/**
|
|
1163
|
+
* Load fsRead content through the sidebar API and resolve the viewer's
|
|
1164
|
+
* load state (ready with text, or error with the API message).
|
|
1165
|
+
*/
|
|
1166
|
+
async function loadFsReadContent(viewer, path, scope, sessionId) {
|
|
1167
|
+
const target = resolvePath(path, scope?.cwd ?? '')
|
|
1168
|
+
const response = await fetch('/sidebar/api/fs.read', {
|
|
1169
|
+
method: 'POST',
|
|
1170
|
+
headers: { 'content-type': 'application/json' },
|
|
1171
|
+
body: JSON.stringify({ sessionId, path: target }),
|
|
1172
|
+
})
|
|
1173
|
+
const json = await response.json()
|
|
1174
|
+
if (isFsReadOk(json)) return { status: 'ready', viewer, content: json.value.content }
|
|
1175
|
+
return fsReadError(json, viewer)
|
|
1176
|
+
}
|
|
790
1177
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
1178
|
+
/**
|
|
1179
|
+
* Fetch the bytes the viewer's fetchStrategy needs (fsRead text /
|
|
1180
|
+
* mediaUrl / customData) and resolve its load state.
|
|
1181
|
+
*/
|
|
1182
|
+
async function fetchPreviewLoad(viewer, path, scope, sessionId) {
|
|
1183
|
+
const strategy = viewer.fetchStrategy
|
|
1184
|
+
if (strategy === 'fsRead') return loadFsReadContent(viewer, path, scope, sessionId)
|
|
1185
|
+
if (strategy === 'mediaUrl') {
|
|
1186
|
+
return { status: 'ready', viewer, mediaUrl: mediaUrlOf(sessionId, path) }
|
|
1187
|
+
}
|
|
1188
|
+
if (strategy === 'custom') {
|
|
1189
|
+
const data = await (viewer.load?.(path, scope) ?? Promise.resolve(undefined))
|
|
1190
|
+
return { status: 'ready', viewer, customData: data }
|
|
1191
|
+
}
|
|
1192
|
+
// 'binary-download' and anything else: mount the viewer's own
|
|
1193
|
+
// component (it handles the download / media itself).
|
|
1194
|
+
return { status: 'ready', viewer }
|
|
1195
|
+
}
|
|
806
1196
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1197
|
+
/**
|
|
1198
|
+
* Resolve the file's viewer through the sidebar registry and load the
|
|
1199
|
+
* bytes it needs; failures become an error state shown in the window.
|
|
1200
|
+
*/
|
|
1201
|
+
function usePreviewLoader(service, path, sessionId, scope) {
|
|
1202
|
+
const [load, setLoad] = useState({ status: 'loading', viewer: null })
|
|
1203
|
+
useEffect(() => {
|
|
1204
|
+
let cancelled = false
|
|
1205
|
+
const viewer = service?.matchFileViewer?.(path)
|
|
1206
|
+
if (!viewer) {
|
|
1207
|
+
setLoad({ status: 'error', viewer: null, message: strings.previewUnsupported() })
|
|
1208
|
+
return () => {
|
|
1209
|
+
cancelled = true
|
|
820
1210
|
}
|
|
821
|
-
// 'binary-download' and anything else: mount the viewer's own
|
|
822
|
-
// component (it handles the download / media itself).
|
|
823
|
-
return { status: 'ready', viewer }
|
|
824
1211
|
}
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
setLoad({ status: 'error', viewer: null, message: strings.previewUnsupported() })
|
|
837
|
-
return () => { cancelled = true }
|
|
838
|
-
}
|
|
839
|
-
setLoad({ status: 'loading', viewer })
|
|
840
|
-
fetchPreviewLoad(viewer, path, scope, sessionId)
|
|
841
|
-
.then((next) => {
|
|
842
|
-
if (!cancelled) setLoad(next)
|
|
843
|
-
})
|
|
844
|
-
.catch((error) => {
|
|
845
|
-
if (!cancelled) setLoad({ status: 'error', viewer, message: error instanceof Error ? error.message : String(error) })
|
|
1212
|
+
setLoad({ status: 'loading', viewer })
|
|
1213
|
+
fetchPreviewLoad(viewer, path, scope, sessionId)
|
|
1214
|
+
.then((next) => {
|
|
1215
|
+
if (!cancelled) setLoad(next)
|
|
1216
|
+
})
|
|
1217
|
+
.catch((error) => {
|
|
1218
|
+
if (!cancelled)
|
|
1219
|
+
setLoad({
|
|
1220
|
+
status: 'error',
|
|
1221
|
+
viewer,
|
|
1222
|
+
message: error instanceof Error ? error.message : String(error),
|
|
846
1223
|
})
|
|
847
|
-
return () => { cancelled = true }
|
|
848
|
-
}, [path, sessionId, scope])
|
|
849
|
-
return load
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
/** Preview window body: loading note / error panel / viewer mount. */
|
|
853
|
-
function renderPreviewBody(load, ctx, store, scope, path, title, sessionId) {
|
|
854
|
-
if (load.status === 'loading') {
|
|
855
|
-
return createElement('div', { className: 'dfa-fp-note' }, strings.loading())
|
|
856
|
-
}
|
|
857
|
-
if (load.status === 'error') {
|
|
858
|
-
return createElement('div', { className: 'dfa-fp-err' },
|
|
859
|
-
strings.previewFailed(),
|
|
860
|
-
load.message
|
|
861
|
-
? createElement('div', { style: { marginTop: '6px', fontSize: '11px', opacity: 0.85 } }, load.message)
|
|
862
|
-
: null,
|
|
863
|
-
)
|
|
864
|
-
}
|
|
865
|
-
if (load.viewer.id === 'pdf') {
|
|
866
|
-
const url = mediaUrlOf(sessionId, path)
|
|
867
|
-
return createElement(PdfPreview, { src: url, download: `${url}&download=1`, title })
|
|
868
|
-
}
|
|
869
|
-
return createElement(load.viewer.component, {
|
|
870
|
-
ctx, store, scope, path, title,
|
|
871
|
-
viewerId: load.viewer.id,
|
|
872
|
-
content: load.content,
|
|
873
|
-
mediaUrl: load.mediaUrl,
|
|
874
|
-
customData: load.customData,
|
|
875
1224
|
})
|
|
1225
|
+
return () => {
|
|
1226
|
+
cancelled = true
|
|
876
1227
|
}
|
|
1228
|
+
}, [path, sessionId, scope])
|
|
1229
|
+
return load
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/** Preview window body: loading note / error panel / viewer mount. */
|
|
1233
|
+
function renderPreviewBody(load, ctx, store, scope, path, title, sessionId) {
|
|
1234
|
+
if (load.status === 'loading') {
|
|
1235
|
+
return createElement('div', { className: 'dfa-fp-note' }, strings.loading())
|
|
1236
|
+
}
|
|
1237
|
+
if (load.status === 'error') {
|
|
1238
|
+
return createElement(
|
|
1239
|
+
'div',
|
|
1240
|
+
{ className: 'dfa-fp-err' },
|
|
1241
|
+
strings.previewFailed(),
|
|
1242
|
+
load.message
|
|
1243
|
+
? createElement('div', { style: { marginTop: '6px', fontSize: '11px', opacity: 0.85 } }, load.message)
|
|
1244
|
+
: null,
|
|
1245
|
+
)
|
|
1246
|
+
}
|
|
1247
|
+
if (load.viewer.id === 'pdf') {
|
|
1248
|
+
const url = mediaUrlOf(sessionId, path)
|
|
1249
|
+
return createElement(PdfPreview, { src: url, download: `${url}&download=1`, title })
|
|
1250
|
+
}
|
|
1251
|
+
return createElement(load.viewer.component, {
|
|
1252
|
+
ctx,
|
|
1253
|
+
store,
|
|
1254
|
+
scope,
|
|
1255
|
+
path,
|
|
1256
|
+
title,
|
|
1257
|
+
viewerId: load.viewer.id,
|
|
1258
|
+
content: load.content,
|
|
1259
|
+
mediaUrl: load.mediaUrl,
|
|
1260
|
+
customData: load.customData,
|
|
1261
|
+
})
|
|
1262
|
+
}
|
|
877
1263
|
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1264
|
+
/**
|
|
1265
|
+
* A floating preview window. Instead of re-implementing rendering, it
|
|
1266
|
+
* asks the sidebar registry for the file's viewer (`matchFileViewer`),
|
|
1267
|
+
* fetches the bytes the viewer's fetchStrategy needs (fsRead text /
|
|
1268
|
+
* mediaUrl / customData), then mounts that viewer's own component — so
|
|
1269
|
+
* code gets syntax highlighting and markdown gets rendered by the SAME
|
|
1270
|
+
* built-in renderers the sidebar's editor tab uses.
|
|
1271
|
+
*
|
|
1272
|
+
* Media caveat: the sidebar's own media route (/sidebar/file) only serves
|
|
1273
|
+
* files inside the session working directory, while file activity records
|
|
1274
|
+
* files the agent touched anywhere (/tmp scratch files, sibling repos…).
|
|
1275
|
+
* Media bytes therefore come from OUR route (/file-activity/file), which
|
|
1276
|
+
* authorizes exactly the paths this session recorded; PDF is the one
|
|
1277
|
+
* built-in viewer that fetches its own URL internally (it ignores the
|
|
1278
|
+
* `mediaUrl` prop), so it gets a small iframe preview instead.
|
|
1279
|
+
*/
|
|
1280
|
+
function FloatingPreview({ ctx, store, scope, preview, onClose }) {
|
|
1281
|
+
const sessionId = scope?.sessionId ?? ''
|
|
1282
|
+
const path = preview.abs
|
|
1283
|
+
const title = preview.name
|
|
1284
|
+
const service = ctx.betterSidebar
|
|
1285
|
+
const load = usePreviewLoader(service, path, sessionId, scope)
|
|
1286
|
+
|
|
1287
|
+
// Clicking outside is the primary dismiss (the overlay's onClick);
|
|
1288
|
+
// Escape is a keyboard affordance. Both call onClose.
|
|
1289
|
+
useEffect(() => {
|
|
1290
|
+
if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return () => {}
|
|
1291
|
+
const handler = (event) => {
|
|
1292
|
+
if (event && event.key === 'Escape') onClose()
|
|
1293
|
+
}
|
|
1294
|
+
document.addEventListener('keydown', handler)
|
|
1295
|
+
return () => document.removeEventListener('keydown', handler)
|
|
1296
|
+
}, [onClose])
|
|
1297
|
+
|
|
1298
|
+
return createElement(
|
|
1299
|
+
'div',
|
|
1300
|
+
{ className: 'dfa-fp-overlay', onClick: onClose },
|
|
1301
|
+
createElement(
|
|
1302
|
+
'div',
|
|
1303
|
+
{
|
|
1304
|
+
className: 'dfa-fp',
|
|
1305
|
+
onClick: (event) => {
|
|
1306
|
+
if (event && event.stopPropagation) event.stopPropagation()
|
|
1307
|
+
},
|
|
1308
|
+
},
|
|
1309
|
+
createElement(
|
|
911
1310
|
'div',
|
|
912
|
-
{ className: 'dfa-fp-
|
|
1311
|
+
{ className: 'dfa-fp-head' },
|
|
1312
|
+
createElement('span', { className: 'dfa-fp-title' }, title),
|
|
913
1313
|
createElement(
|
|
914
|
-
'
|
|
915
|
-
{ className: 'dfa-fp'
|
|
1314
|
+
'span',
|
|
1315
|
+
{ className: 'dfa-fp-actions' },
|
|
916
1316
|
createElement(
|
|
917
|
-
'
|
|
918
|
-
{
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
'
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
),
|
|
926
|
-
),
|
|
1317
|
+
'button',
|
|
1318
|
+
{
|
|
1319
|
+
className: 'dfa-iconbtn',
|
|
1320
|
+
title: strings.closePreview(),
|
|
1321
|
+
'aria-label': strings.closePreview(),
|
|
1322
|
+
onClick: () => onClose(),
|
|
1323
|
+
},
|
|
1324
|
+
icon.close(15),
|
|
927
1325
|
),
|
|
928
|
-
createElement('div', { className: 'dfa-fp-body' }, renderPreviewBody(load, ctx, store, scope, path, title, sessionId)),
|
|
929
|
-
),
|
|
930
|
-
)
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
/**
|
|
934
|
-
* Lightweight PDF preview. better-sidebar's built-in PdfView fetches
|
|
935
|
-
* `/sidebar/file` internally (it ignores any injected `mediaUrl` prop),
|
|
936
|
-
* and that route refuses files outside the session working directory — so
|
|
937
|
-
* a recorded /tmp PDF would never load. This tiny view embeds the bytes
|
|
938
|
-
* from OUR media route in a native browser PDF frame, with a download
|
|
939
|
-
* fallback in its toolbar.
|
|
940
|
-
*/
|
|
941
|
-
function PdfPreview({ src, download, title }) {
|
|
942
|
-
return createElement('div', { className: 'dfa-pdf' },
|
|
943
|
-
createElement('div', { className: 'dfa-pdf-toolbar' },
|
|
944
|
-
createElement('a', { className: 'dfa-pdf-download', href: download, download: true, title: strings.downloadToView() },
|
|
945
|
-
strings.downloadToView()),
|
|
946
1326
|
),
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
* service reload could leave the already-rendered tab WITHOUT its
|
|
957
|
-
* stylesheet — the raw white-text list you see when the CSS is gone.
|
|
958
|
-
* Each fiber owns its own <style> element and the disposer removes
|
|
959
|
-
* only that element, so a rebuild always keeps at least one copy.
|
|
960
|
-
*/
|
|
961
|
-
function injectStyles(ctx) {
|
|
962
|
-
ctx.effect(() => {
|
|
963
|
-
if (typeof document === 'undefined' || document === null || typeof document.head === 'undefined') return () => {}
|
|
964
|
-
const style = document.createElement('style')
|
|
965
|
-
style.setAttribute('data-dsh-file-activity', 'styles')
|
|
966
|
-
style.textContent = STYLES
|
|
967
|
-
document.head.appendChild(style)
|
|
968
|
-
return () => {
|
|
969
|
-
if (style.parentNode) style.parentNode.removeChild(style)
|
|
970
|
-
}
|
|
971
|
-
}, 'dsh-file-activity: styles')
|
|
972
|
-
}
|
|
1327
|
+
),
|
|
1328
|
+
createElement(
|
|
1329
|
+
'div',
|
|
1330
|
+
{ className: 'dfa-fp-body' },
|
|
1331
|
+
renderPreviewBody(load, ctx, store, scope, path, title, sessionId),
|
|
1332
|
+
),
|
|
1333
|
+
),
|
|
1334
|
+
)
|
|
1335
|
+
}
|
|
973
1336
|
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1337
|
+
/**
|
|
1338
|
+
* Lightweight PDF preview. better-sidebar's built-in PdfView fetches
|
|
1339
|
+
* `/sidebar/file` internally (it ignores any injected `mediaUrl` prop),
|
|
1340
|
+
* and that route refuses files outside the session working directory — so
|
|
1341
|
+
* a recorded /tmp PDF would never load. This tiny view embeds the bytes
|
|
1342
|
+
* from OUR media route in a native browser PDF frame, with a download
|
|
1343
|
+
* fallback in its toolbar.
|
|
1344
|
+
*/
|
|
1345
|
+
function PdfPreview({ src, download, title }) {
|
|
1346
|
+
return createElement(
|
|
1347
|
+
'div',
|
|
1348
|
+
{ className: 'dfa-pdf' },
|
|
1349
|
+
createElement(
|
|
1350
|
+
'div',
|
|
1351
|
+
{ className: 'dfa-pdf-toolbar' },
|
|
1352
|
+
createElement(
|
|
1353
|
+
'a',
|
|
1354
|
+
{
|
|
1355
|
+
className: 'dfa-pdf-download',
|
|
1356
|
+
href: download,
|
|
1357
|
+
download: true,
|
|
1358
|
+
title: strings.downloadToView(),
|
|
1359
|
+
},
|
|
1360
|
+
strings.downloadToView(),
|
|
1361
|
+
),
|
|
1362
|
+
),
|
|
1363
|
+
createElement('iframe', { className: 'dfa-pdf-frame', src, title }),
|
|
1364
|
+
)
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// ── plugin body ───────────────────────────────────────────────────────
|
|
1368
|
+
/**
|
|
1369
|
+
* The stylesheet is pure static CSS and must NOT depend on the
|
|
1370
|
+
* betterSidebar service: inject it first, unconditionally. If it lived
|
|
1371
|
+
* behind the `service === undefined` early return, an HMR rebuild or
|
|
1372
|
+
* service reload could leave the already-rendered tab WITHOUT its
|
|
1373
|
+
* stylesheet — the raw white-text list you see when the CSS is gone.
|
|
1374
|
+
* Each fiber owns its own <style> element and the disposer removes
|
|
1375
|
+
* only that element, so a rebuild always keeps at least one copy.
|
|
1376
|
+
*/
|
|
1377
|
+
function injectStyles(ctx) {
|
|
1378
|
+
ctx.effect(() => {
|
|
1379
|
+
if (typeof document === 'undefined' || document === null || typeof document.head === 'undefined') return () => {}
|
|
1380
|
+
const style = document.createElement('style')
|
|
1381
|
+
style.setAttribute('data-dsh-file-activity', 'styles')
|
|
1382
|
+
style.textContent = STYLES
|
|
1383
|
+
document.head.appendChild(style)
|
|
1384
|
+
return () => {
|
|
1385
|
+
if (style.parentNode) style.parentNode.removeChild(style)
|
|
983
1386
|
}
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1387
|
+
}, 'dsh-file-activity: styles')
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/** Mount probe: report client activation to the host state (synthetic
|
|
1391
|
+
* session id, invisible in the UI — confirms the client half actually
|
|
1392
|
+
* loaded after a page refresh). */
|
|
1393
|
+
function mountProbe() {
|
|
1394
|
+
void fetch('/file-activity/api/record', {
|
|
1395
|
+
method: 'POST',
|
|
1396
|
+
headers: { 'content-type': 'application/json' },
|
|
1397
|
+
body: JSON.stringify({ sessionId: '__probe__', path: 'mounted', op: 'read' }),
|
|
1398
|
+
}).catch(() => {})
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/** Register the tab (enabled by default in the Side card settings). */
|
|
1402
|
+
function registerTab(ctx, dataStore) {
|
|
1403
|
+
const service = ctx.betterSidebar
|
|
1404
|
+
ctx.effect(
|
|
1405
|
+
() =>
|
|
1406
|
+
service.registerTab({
|
|
989
1407
|
id: TAB_ID,
|
|
990
1408
|
title: () => strings.title(),
|
|
991
1409
|
icon: (size) => icon.clock(size),
|
|
992
1410
|
order: 15,
|
|
993
1411
|
single: true,
|
|
994
1412
|
settings: {
|
|
995
|
-
pluginToggles: [
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1413
|
+
pluginToggles: [
|
|
1414
|
+
{
|
|
1415
|
+
key: 'autoOpen',
|
|
1416
|
+
title: () => (isZh() ? '会话开始时自动打开' : 'Auto-open on session start'),
|
|
1417
|
+
desc: () =>
|
|
1418
|
+
isZh()
|
|
1419
|
+
? '每个会话首次打开时自动显示本页(可在侧边栏设置中关闭)'
|
|
1420
|
+
: 'Opens this tab once per session by default (turn off here)',
|
|
1421
|
+
type: 'switch',
|
|
1422
|
+
},
|
|
1423
|
+
],
|
|
1001
1424
|
},
|
|
1002
1425
|
component: (props) => createElement(FileActivityView, { ...props, dataStore }),
|
|
1003
|
-
}),
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1426
|
+
}),
|
|
1427
|
+
'dsh-file-activity: tab registration',
|
|
1428
|
+
)
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
exports.inject = ['betterSidebar']
|
|
1432
|
+
|
|
1433
|
+
exports.apply = function apply(ctx) {
|
|
1434
|
+
// Stylesheet first, unconditionally (HMR pitfall — see injectStyles).
|
|
1435
|
+
injectStyles(ctx)
|
|
1436
|
+
const service = ctx.betterSidebar
|
|
1437
|
+
if (service === undefined) return
|
|
1438
|
+
|
|
1439
|
+
// Per-session data store: { bySession: { [sessionId]: { recent, counts, loading } }, preview }
|
|
1440
|
+
// Each conversation reads/writes only its own bucket, so switching
|
|
1441
|
+
// sessions never leaks another session's file activity into the view.
|
|
1442
|
+
const dataStore = createStore({ bySession: {}, preview: null })
|
|
1443
|
+
mountProbe()
|
|
1444
|
+
|
|
1445
|
+
// sidebar operations → host record route
|
|
1446
|
+
ctx.effect(() => installFetchInterceptor(), 'dsh-file-activity: sidebar fetch observation')
|
|
1447
|
+
registerTab(ctx, dataStore)
|
|
1448
|
+
|
|
1449
|
+
// auto-open once per session (default on)
|
|
1450
|
+
ctx.effect(() => installAutoOpen(ctx, TAB_ID), 'dsh-file-activity: auto-open')
|
|
1451
|
+
}
|
|
1027
1452
|
|
|
1028
1453
|
|
|
1029
1454
|
return module.exports
|