dsh-file-activity 0.5.7 → 0.5.9
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 +50 -0
- package/README.md +29 -18
- package/lib/api-route.js +72 -76
- package/lib/bash-ops.js +65 -54
- package/lib/bash-parse.js +154 -162
- package/lib/client.js +739 -361
- package/lib/client.src.js +28 -21
- package/lib/cwd.js +6 -6
- package/lib/index.js +68 -83
- package/lib/media-route.js +119 -90
- package/lib/observer.js +30 -24
- package/lib/parts/api.part.js +1 -5
- package/lib/parts/apply.part.js +91 -82
- package/lib/parts/auto-open.part.js +83 -60
- package/lib/parts/document-previews.part.js +62 -0
- package/lib/parts/format.part.js +1 -2
- package/lib/parts/i18n.part.js +15 -1
- package/lib/parts/interceptor.part.js +16 -27
- package/lib/parts/preview-data.part.js +111 -0
- package/lib/parts/preview-float.part.js +276 -0
- package/lib/parts/rows.part.js +1 -11
- package/lib/parts/settings.part.js +71 -0
- package/lib/parts/store.part.js +19 -0
- package/lib/parts/styles.part.js +49 -3
- package/lib/parts/tree.part.js +1 -2
- package/lib/parts/view.part.js +72 -18
- package/lib/persist.js +137 -0
- package/lib/quota-trim.js +121 -0
- package/lib/quota.js +164 -0
- package/lib/state.js +336 -136
- package/lib/store.js +249 -76
- package/package.json +10 -6
- package/lib/parts/preview.part.js +0 -308
package/lib/client.js
CHANGED
|
@@ -1,29 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-file-activity — client half (browser).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
4
|
+
* A native right-Sidebar tab ("文件活动 / File Activity") built on the HOST's
|
|
5
|
+
* own extension points (issue #187 batch 2 — no third-party sidebar service):
|
|
6
|
+
* - the tab type registers into `ctx.sidebarRightTabs` (stage one) and its
|
|
7
|
+
* body / chip title into the keyed `sidebar.right.pane.tab` and
|
|
8
|
+
* `sidebar.right.pane.tab.title` seats (stage two);
|
|
9
|
+
* - clicking a file opens a FLOATING preview implemented by this plugin inside
|
|
10
|
+
* the `shell.overlay` list seat: recent access history (agent + plugin
|
|
11
|
+
* routes), per-file create/modify/read counts flattened by folder, and the
|
|
12
|
+
* preview window itself — clicking outside / Esc / × closes it;
|
|
13
|
+
* - a document-preview descriptor registers with `ctx.documentPreviews`
|
|
14
|
+
* (metadata only: the native document owner reads the bytes);
|
|
15
|
+
* - auto-opens once per session by default, with its toggle in the Web
|
|
16
|
+
* Settings → Plugins tab (`settings.plugins.tab`).
|
|
15
17
|
*
|
|
16
18
|
* Data source: the plugin host half (fs/observed for agent tools) + this
|
|
17
|
-
* half's fetch interception for
|
|
18
|
-
*
|
|
19
|
-
* /file-activity/api/stats.
|
|
19
|
+
* half's fetch interception for the plugin's own file routes, both persisted
|
|
20
|
+
* host-side; the tab polls /file-activity/api/stats.
|
|
20
21
|
*
|
|
21
|
-
* Styling follows the
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
22
|
+
* Styling follows the DSH design language: all colors ride the DSH semantic
|
|
23
|
+
* tokens (--dsw-alias-*), typography rides the font roles (--dsw-font-*),
|
|
24
|
+
* motion rides --ds-*. Flat surfaces (no box-shadow), hairline borders, 28px
|
|
25
|
+
* circular icon controls with hover fills, and 8px-radius rows with hover
|
|
26
|
+
* fills. The stylesheet is injected once per activation and torn down with the
|
|
27
|
+
* fiber, so HMR/disable leaves no residue.
|
|
27
28
|
*
|
|
28
29
|
* BUILD NOTE: this file is the SOURCE TEMPLATE. scripts/build.mjs splices the
|
|
29
30
|
* `lib/parts/*.part.js` pieces into the PART placeholder markers below (each
|
|
@@ -40,13 +41,17 @@ window.__ModuleLoader__.load({
|
|
|
40
41
|
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
41
42
|
const { createElement, useEffect, useMemo, useState, useSyncExternalStore } = require('react')
|
|
42
43
|
|
|
43
|
-
const TAB_ID = 'file-activity
|
|
44
|
+
const TAB_ID = 'dsh-file-activity'
|
|
45
|
+
const TAB_KIND = 'file-activity'
|
|
46
|
+
const PREVIEW_ID = 'dsh-file-activity-preview'
|
|
44
47
|
const AUTO_OPEN_KEY = 'dsh-file-activity:auto-opened:'
|
|
48
|
+
const AUTO_OPEN_PREF_KEY = 'dsh-file-activity:autoOpen'
|
|
45
49
|
const POLL_MS = 6000
|
|
46
50
|
|
|
47
51
|
// ── parts (injected by scripts/build.mjs; keep this exact order — the
|
|
48
52
|
// const initializers below run in splice order) ─────────────────────
|
|
49
|
-
|
|
53
|
+
'use strict'
|
|
54
|
+
// ── i18n ──────────────────────────────────────────────────────────────
|
|
50
55
|
function isZh() {
|
|
51
56
|
try {
|
|
52
57
|
const lang = (navigator.language || 'en').toLowerCase()
|
|
@@ -55,7 +60,6 @@ function isZh() {
|
|
|
55
60
|
return false
|
|
56
61
|
}
|
|
57
62
|
}
|
|
58
|
-
|
|
59
63
|
const strings = {
|
|
60
64
|
title: () => (isZh() ? '文件活动' : 'File Activity'),
|
|
61
65
|
recent: () => (isZh() ? '最近访问' : 'Recent'),
|
|
@@ -91,16 +95,30 @@ const strings = {
|
|
|
91
95
|
isZh() ? '文件位于工作区外,暂无法读取内容' : 'The file is outside the workspace and cannot be read',
|
|
92
96
|
downloadToView: () => (isZh() ? '下载查看' : 'download to view'),
|
|
93
97
|
clickOutsideToClose: () => (isZh() ? '点击外部关闭' : 'Click outside to close'),
|
|
98
|
+
guideDescription: () =>
|
|
99
|
+
isZh()
|
|
100
|
+
? '查看 agent 与侧边栏读写过的文件(最近访问 + 目录统计)'
|
|
101
|
+
: 'Files the agent and the sidebar touched (recent + tree)',
|
|
102
|
+
autoOpenLabel: () => (isZh() ? '会话开始时自动打开' : 'Auto-open on session start'),
|
|
103
|
+
autoOpenHint: () =>
|
|
104
|
+
isZh()
|
|
105
|
+
? '每个会话首次打开时自动显示本页;关闭后仍可从侧边栏右上角的「新标签页」手动打开。'
|
|
106
|
+
: 'Shows this page once per session. When off, open it from the sidebar new-tab control.',
|
|
107
|
+
tabUnavailable: () => (isZh() ? '侧边栏扩展点不可用' : 'Sidebar extension point unavailable'),
|
|
108
|
+
tabUnavailableHint: () =>
|
|
109
|
+
isZh()
|
|
110
|
+
? '本插件未能注册页签(宿主原生 API 可能已变更)。原因见浏览器控制台。'
|
|
111
|
+
: 'This plugin could not register its tab (the host native API may have changed). See the browser console.',
|
|
94
112
|
autoCloseHint: () => (isZh() ? '预览失败,即将自动关闭' : 'Preview failed — closing automatically'),
|
|
95
113
|
}
|
|
96
114
|
|
|
97
|
-
|
|
115
|
+
'use strict'
|
|
116
|
+
// ── path / time formatting helpers ────────────────────────────────────
|
|
98
117
|
function basenameOf(path) {
|
|
99
118
|
const norm = path.split('\\').join('/')
|
|
100
119
|
const idx = norm.lastIndexOf('/')
|
|
101
120
|
return idx === -1 ? norm : norm.slice(idx + 1)
|
|
102
121
|
}
|
|
103
|
-
|
|
104
122
|
/** Compact relative time: 刚刚 / N 分钟前 / N 小时前 / N 天前 / MM/DD. */
|
|
105
123
|
function formatRelative(time) {
|
|
106
124
|
if (typeof time !== 'number' || !Number.isFinite(time)) return ''
|
|
@@ -115,7 +133,6 @@ function formatRelative(time) {
|
|
|
115
133
|
const date = new Date(time)
|
|
116
134
|
return `${date.getMonth() + 1}/${date.getDate()}`
|
|
117
135
|
}
|
|
118
|
-
|
|
119
136
|
/** Local wall-clock HH:MM:SS (used in tooltips; full precision). */
|
|
120
137
|
function formatTime(time) {
|
|
121
138
|
const date = new Date(time)
|
|
@@ -123,7 +140,8 @@ function formatTime(time) {
|
|
|
123
140
|
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
|
124
141
|
}
|
|
125
142
|
|
|
126
|
-
|
|
143
|
+
'use strict'
|
|
144
|
+
// ── directory tree construction ───────────────────────────────────────
|
|
127
145
|
/**
|
|
128
146
|
* Collapse chain directories: a directory whose only child is another
|
|
129
147
|
* directory merges into it (a → a.b → a.b.c …). Deep single-child paths
|
|
@@ -143,7 +161,6 @@ function compressChains(node, isRoot) {
|
|
|
143
161
|
node.compressed = true
|
|
144
162
|
}
|
|
145
163
|
}
|
|
146
|
-
|
|
147
164
|
/**
|
|
148
165
|
* Sort a directory node: directories first (alphabetically), then files
|
|
149
166
|
* (by total activity, then name); recurse into directories.
|
|
@@ -160,7 +177,6 @@ function sortNode(node) {
|
|
|
160
177
|
if (child.type === 'dir') sortNode(child)
|
|
161
178
|
}
|
|
162
179
|
}
|
|
163
|
-
|
|
164
180
|
/**
|
|
165
181
|
* Build a nested directory tree from per-file counts, keyed by the file's
|
|
166
182
|
* absolute path. Every directory node aggregates its subtree counters and
|
|
@@ -208,7 +224,8 @@ function buildTree(counts) {
|
|
|
208
224
|
return root
|
|
209
225
|
}
|
|
210
226
|
|
|
211
|
-
|
|
227
|
+
'use strict'
|
|
228
|
+
// ── tiny external store ───────────────────────────────────────────────
|
|
212
229
|
function createStore(initial) {
|
|
213
230
|
let state = initial
|
|
214
231
|
const listeners = new Set()
|
|
@@ -224,15 +241,33 @@ function createStore(initial) {
|
|
|
224
241
|
},
|
|
225
242
|
}
|
|
226
243
|
}
|
|
227
|
-
|
|
228
|
-
|
|
244
|
+
// ── shared store slot ─────────────────────────────────────────────────
|
|
245
|
+
//
|
|
246
|
+
// The tab body renders inside a session-scoped seat while the floating preview
|
|
247
|
+
// renders inside the root-scoped 'shell.overlay' seat, so the store the two
|
|
248
|
+
// halves share cannot travel as a prop from one to the other. One module-level
|
|
249
|
+
// reference is enough: the overlay seat is mounted for the plugin's whole
|
|
250
|
+
// lifetime and only ever reads the CURRENT store, and a rebuild replaces the
|
|
251
|
+
// reference before the new overlay renders.
|
|
252
|
+
/** The store the overlay seat reads; set once per activation. */
|
|
253
|
+
let sharedDataStore = null
|
|
254
|
+
/** Publish the activation's store for the root-scoped overlay seat. */
|
|
255
|
+
function registerSharedStore(store) {
|
|
256
|
+
sharedDataStore = store
|
|
257
|
+
}
|
|
258
|
+
/** The activation's store, or null before apply() ran. */
|
|
259
|
+
function sharedStore() {
|
|
260
|
+
return sharedDataStore
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
'use strict'
|
|
264
|
+
// ── data access (host routes) ─────────────────────────────────────────
|
|
229
265
|
async function fetchStats(sessionId) {
|
|
230
266
|
const response = await fetch(`/file-activity/api/stats?sessionId=${encodeURIComponent(sessionId)}`)
|
|
231
267
|
const json = await response.json()
|
|
232
268
|
if (json === null || typeof json !== 'object' || json.ok !== true) return null
|
|
233
269
|
return json.value
|
|
234
270
|
}
|
|
235
|
-
|
|
236
271
|
/** Resolve the session working directory through the sidebar's native API. */
|
|
237
272
|
async function fetchSessionCwd(sessionId) {
|
|
238
273
|
try {
|
|
@@ -248,7 +283,6 @@ async function fetchSessionCwd(sessionId) {
|
|
|
248
283
|
return ''
|
|
249
284
|
}
|
|
250
285
|
}
|
|
251
|
-
|
|
252
286
|
function postRecord(sessionId, path, op) {
|
|
253
287
|
if (typeof sessionId !== 'string' || sessionId === '' || typeof path !== 'string' || path === '') return
|
|
254
288
|
void fetch('/file-activity/api/record', {
|
|
@@ -257,7 +291,6 @@ function postRecord(sessionId, path, op) {
|
|
|
257
291
|
body: JSON.stringify({ sessionId, path, op }),
|
|
258
292
|
}).catch(() => {})
|
|
259
293
|
}
|
|
260
|
-
|
|
261
294
|
function postClear(sessionId) {
|
|
262
295
|
void fetch('/file-activity/api/clear', {
|
|
263
296
|
method: 'POST',
|
|
@@ -265,54 +298,41 @@ function postClear(sessionId) {
|
|
|
265
298
|
body: JSON.stringify({ sessionId }),
|
|
266
299
|
}).catch(() => {})
|
|
267
300
|
}
|
|
268
|
-
|
|
269
301
|
/** Plugin media route URL for a recorded path (authorized per session). */
|
|
270
302
|
function mediaUrlOf(sessionId, path) {
|
|
271
303
|
return `/file-activity/file?${new URLSearchParams({ sessionId, path })}`
|
|
272
304
|
}
|
|
273
|
-
|
|
274
305
|
/** Plugin text route URL (`as=text`): fs.read-shaped JSON for recorded text. */
|
|
275
306
|
function textUrlOf(sessionId, path) {
|
|
276
307
|
return `/file-activity/file?${new URLSearchParams({ sessionId, path, as: 'text' })}`
|
|
277
308
|
}
|
|
278
309
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
/** Record
|
|
290
|
-
function
|
|
291
|
-
if (url.pathname !== '/
|
|
292
|
-
if (
|
|
293
|
-
const body = parseBody(init)
|
|
294
|
-
if (typeof body.sessionId !== 'string' || typeof body.path !== 'string') return
|
|
295
|
-
postRecord(body.sessionId, body.path, url.pathname === '/sidebar/api/fs.write' ? 'write' : 'read')
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/** Record sidebar media opens (/sidebar/file?sessionId=...&path=...). */
|
|
299
|
-
function recordMediaOpen(url, init) {
|
|
300
|
-
if (url.pathname !== '/sidebar/file' || methodOf(init) !== 'GET') return
|
|
310
|
+
'use strict'
|
|
311
|
+
// ── fetch interception: the plugin's own file routes ───────────────────
|
|
312
|
+
//
|
|
313
|
+
// Before the native migration this module also watched the THIRD-PARTY
|
|
314
|
+
// sidebar's routes (/sidebar/api/fs.read|fs.write, /sidebar/file). Those are
|
|
315
|
+
// the host's internal surface now, fed by the host's own file provider and
|
|
316
|
+
// document owner — the plugin neither opens nor owns them, so observing them
|
|
317
|
+
// would record host traffic that is already recorded through the plugin's
|
|
318
|
+
// fs/observed host half. What remains is the plugin's own media/text route,
|
|
319
|
+
// whose reads ARE the user's previews.
|
|
320
|
+
/** Record requests for the plugin's own file route (media + text previews). */
|
|
321
|
+
function recordOwnMediaOpen(url, init) {
|
|
322
|
+
if (url.pathname !== '/file-activity/file') return
|
|
323
|
+
if ((init?.method ?? 'GET').toUpperCase() !== 'GET') return
|
|
301
324
|
const sessionId = url.searchParams.get('sessionId')
|
|
302
325
|
const path = url.searchParams.get('path')
|
|
303
326
|
if (sessionId !== null && path !== null) postRecord(sessionId, path, 'read')
|
|
304
327
|
}
|
|
305
|
-
|
|
306
|
-
/** Observe a resolved fetch URL and record sidebar file operations. */
|
|
328
|
+
/** Observe a resolved fetch URL and record plugin file operations. */
|
|
307
329
|
function observeSidebarFetch(url, init) {
|
|
308
330
|
try {
|
|
309
|
-
|
|
310
|
-
recordMediaOpen(url, init)
|
|
331
|
+
recordOwnMediaOpen(url, init)
|
|
311
332
|
} catch {
|
|
312
333
|
// observation must never break the underlying call
|
|
313
334
|
}
|
|
314
335
|
}
|
|
315
|
-
|
|
316
336
|
function installFetchInterceptor() {
|
|
317
337
|
const original = window.fetch.bind(window)
|
|
318
338
|
window.fetch = (input, init) => {
|
|
@@ -333,81 +353,167 @@ function installFetchInterceptor() {
|
|
|
333
353
|
}
|
|
334
354
|
}
|
|
335
355
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
356
|
+
'use strict'
|
|
357
|
+
// ── auto-open (enabled by default) ────────────────────────────────────
|
|
358
|
+
//
|
|
359
|
+
// Native flow: the tab is a PAGE type, so opening it means
|
|
360
|
+
// `ctx.sidebarRight.openTab(kind)` — there is no layout snapshot to inspect
|
|
361
|
+
// and no session id to read from a third-party sidebar service anymore.
|
|
362
|
+
//
|
|
363
|
+
// Timing is the whole problem. The navigation controller throws
|
|
364
|
+
// "sidebarRight: no session surface is mounted" when the right column has not
|
|
365
|
+
// mounted its session surface yet — which is exactly the state during the
|
|
366
|
+
// first paint of a fresh page. The previous third-party sidebar used to answer with a snapshot
|
|
367
|
+
// and the old code simply gave up when it was missing, which is how the tab
|
|
368
|
+
// could silently never auto-open. Here the attempt is RETRIED with the timers
|
|
369
|
+
// until the surface is up, and a permanent failure is recorded through
|
|
370
|
+
// markDegraded() instead of disappearing.
|
|
371
|
+
/** Retry cadence while the right column has not mounted its session surface. */
|
|
372
|
+
const AUTO_OPEN_RETRY_MS = 1000
|
|
373
|
+
/** Give up after this many attempts (~30s) and degrade observably. */
|
|
374
|
+
const AUTO_OPEN_MAX_TRIES = 30
|
|
375
|
+
/**
|
|
376
|
+
* Pending retry timer id (0 = none).
|
|
377
|
+
*
|
|
378
|
+
* There is deliberately NO "already attempted" flag guarding this module: a
|
|
379
|
+
* rejected registration or an HMR rebuild re-runs apply(), and the second run
|
|
380
|
+
* must still be able to bring the tab back. `openTab` is idempotent — it
|
|
381
|
+
* focuses the existing page instead of duplicating it.
|
|
382
|
+
*/
|
|
383
|
+
let autoOpenTimer = 0
|
|
384
|
+
/**
|
|
385
|
+
* Whether this BROWSER TAB already had its auto-open attempt.
|
|
386
|
+
*
|
|
387
|
+
* The native API exposes no "current session id" to a plugin (`openTab`
|
|
388
|
+
* targets the mounted session, and the layout store is the host's own), so a
|
|
389
|
+
* per-session marker cannot be written from here. The marker is therefore
|
|
390
|
+
* per-browser-tab: one attempt per page load, which is exactly the visible
|
|
391
|
+
* behaviour — the session's tab record itself is persisted host-side, so
|
|
392
|
+
* revisiting a session that already shows the tab merely focuses it.
|
|
393
|
+
*/
|
|
394
|
+
function isAutoOpenAttempted() {
|
|
395
|
+
const key = AUTO_OPEN_KEY + 'loaded'
|
|
350
396
|
try {
|
|
351
|
-
return
|
|
397
|
+
return window.sessionStorage.getItem(key) === '1'
|
|
352
398
|
} catch {
|
|
353
|
-
return
|
|
399
|
+
return false
|
|
354
400
|
}
|
|
355
401
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
function isAutoOpenDisabled(snapshot, tabId) {
|
|
359
|
-
const settings = snapshot.prefs?.pluginSettings?.[tabId]
|
|
360
|
-
return settings !== undefined && settings.autoOpen === false
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/** Whether this session was already auto-opened (localStorage marker). */
|
|
364
|
-
function isAutoOpenMarked(sessionId) {
|
|
402
|
+
/** Mark this browser tab as already attempted. */
|
|
403
|
+
function markAutoOpenAttempted() {
|
|
365
404
|
try {
|
|
366
|
-
|
|
405
|
+
window.sessionStorage.setItem(AUTO_OPEN_KEY + 'loaded', '1')
|
|
367
406
|
} catch {
|
|
368
|
-
|
|
407
|
+
// ignore
|
|
369
408
|
}
|
|
370
409
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
410
|
+
/** The most recent controller refusal, surfaced when retries run out. */
|
|
411
|
+
let lastAutoOpenError = null
|
|
412
|
+
/** One open attempt: true when the controller accepted it. */
|
|
413
|
+
function tryOpenTab(ctx, tries) {
|
|
374
414
|
try {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
415
|
+
ctx.sidebarRight.openTab(TAB_KIND, { revealIfOpened: false })
|
|
416
|
+
return true
|
|
417
|
+
} catch (error) {
|
|
418
|
+
if (tries === 0) lastAutoOpenError = error
|
|
419
|
+
return false
|
|
378
420
|
}
|
|
379
421
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
if (
|
|
389
|
-
if (
|
|
390
|
-
|
|
391
|
-
|
|
422
|
+
/**
|
|
423
|
+
* Open the tab once the session surface exists, then stop. The tab record
|
|
424
|
+
* itself is persisted host-side, so a session that already shows it is not
|
|
425
|
+
* reopened: `openTab` focuses the existing page instead of duplicating it.
|
|
426
|
+
* @param ctx - client context carrying the navigation controller.
|
|
427
|
+
* @returns disposer cancelling a pending retry.
|
|
428
|
+
*/
|
|
429
|
+
function installAutoOpen(ctx) {
|
|
430
|
+
if (!autoOpenEnabled()) return () => {}
|
|
431
|
+
if (isAutoOpenAttempted()) return () => {}
|
|
432
|
+
let tries = 0
|
|
433
|
+
const attempt = () => {
|
|
434
|
+
tries += 1
|
|
435
|
+
if (tryOpenTab(ctx, tries - 1)) {
|
|
436
|
+
autoOpenTimer = 0
|
|
437
|
+
markAutoOpenAttempted()
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
if (tries >= AUTO_OPEN_MAX_TRIES) {
|
|
441
|
+
autoOpenTimer = 0
|
|
442
|
+
markDegraded('auto-open', lastAutoOpenError ?? 'sidebarRight: session surface never mounted')
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
autoOpenTimer = window.setTimeout(attempt, AUTO_OPEN_RETRY_MS)
|
|
392
446
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
447
|
+
attempt()
|
|
448
|
+
return () => {
|
|
449
|
+
if (autoOpenTimer !== 0) {
|
|
450
|
+
window.clearTimeout(autoOpenTimer)
|
|
451
|
+
autoOpenTimer = 0
|
|
452
|
+
}
|
|
398
453
|
}
|
|
399
454
|
}
|
|
400
455
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
456
|
+
'use strict'
|
|
457
|
+
// ── native document previews (metadata + plain-text renderer) ──────────
|
|
458
|
+
//
|
|
459
|
+
// The HOST owns file content on the native side: a registered preview only
|
|
460
|
+
// declares WHICH suffixes this implementation recognizes and HOW the document
|
|
461
|
+
// owner should deliver the bytes ('text-pages' = paged text window,
|
|
462
|
+
// 'bytes-complete' = whole file). The owner then injects the prepared
|
|
463
|
+
// DocumentContent into the 'sidebar.right.tab.document' seat, keyed by this
|
|
464
|
+
// implementation's id — so the renderer never fetches anything itself
|
|
465
|
+
// (the responsibility split that made 第三方查看器注册表的 matchFileViewer
|
|
466
|
+
// obsolete; its fetchStrategy belonged to the third-party viewer).
|
|
467
|
+
//
|
|
468
|
+
// Scope of the registration: filenames whose suffixes no shipped renderer
|
|
469
|
+
// claims. Anything the host already renders (code / markdown / image / pdf /
|
|
470
|
+
// html) keeps winning: those implementations outrank this one by longest
|
|
471
|
+
// suffix, and a 'fallback' band type (the shipped text preview) still beats
|
|
472
|
+
// nothing-else-matches. This descriptor exists so a recorded log/diff/ndjson
|
|
473
|
+
// file opens with a real renderer instead of the "nothing can view this"
|
|
474
|
+
// notice.
|
|
475
|
+
//
|
|
476
|
+
// Degradation is explicit and observable (issue #187 batch 2): a rejected
|
|
477
|
+
// registration is logged with its cause and marked on <html> so it can never
|
|
478
|
+
// fail silently.
|
|
479
|
+
/** Suffixes this implementation claims (no leading dot; compound is allowed). */
|
|
480
|
+
const PREVIEW_EXTENSIONS = ['log', 'jsonl', 'ndjson', 'diff', 'patch']
|
|
481
|
+
/**
|
|
482
|
+
* Record an extension-point failure so it is visible to users, tests and e2e.
|
|
483
|
+
* The <html> markers are `data-dfa-degraded` (which stage failed) plus
|
|
484
|
+
* `data-dfa-degraded-cause` (why), which makes a missing tab diagnosable from
|
|
485
|
+
* the page itself instead of only from the browser console.
|
|
486
|
+
*/
|
|
487
|
+
function markDegraded(kind, error) {
|
|
488
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
489
|
+
if (typeof console !== 'undefined' && typeof console.error === 'function') {
|
|
490
|
+
console.error('[dsh-file-activity] ' + kind + ' 注册失败: ' + message)
|
|
491
|
+
}
|
|
405
492
|
try {
|
|
406
|
-
|
|
493
|
+
const root = typeof document === 'undefined' ? undefined : document.documentElement
|
|
494
|
+
if (root !== undefined && root !== null && root.dataset !== undefined) {
|
|
495
|
+
root.dataset.dfaDegraded = kind
|
|
496
|
+
root.dataset.dfaDegradedCause = message.slice(0, 200)
|
|
497
|
+
}
|
|
407
498
|
} catch {
|
|
408
|
-
//
|
|
499
|
+
// observation only: a missing document must not break activation
|
|
409
500
|
}
|
|
410
|
-
|
|
501
|
+
}
|
|
502
|
+
/** Register the native document-preview descriptor (metadata only). */
|
|
503
|
+
function registerDocumentPreviews(ctx) {
|
|
504
|
+
ctx.effect(
|
|
505
|
+
() =>
|
|
506
|
+
ctx.documentPreviews.register({
|
|
507
|
+
id: TAB_ID,
|
|
508
|
+
extensions: PREVIEW_EXTENSIONS,
|
|
509
|
+
// 'extension' lets this implementation win over a shipped one on the
|
|
510
|
+
// same suffix; distinct suffixes keep the shipped renderers in charge.
|
|
511
|
+
priority: 'extension',
|
|
512
|
+
title: () => strings.title(),
|
|
513
|
+
loading: 'text-pages',
|
|
514
|
+
}),
|
|
515
|
+
'dsh-file-activity: document previews',
|
|
516
|
+
)
|
|
411
517
|
}
|
|
412
518
|
|
|
413
519
|
// ── shared icons (inline, stroke=currentColor, matching better-sidebar) ──
|
|
@@ -558,6 +664,27 @@ const icon = {
|
|
|
558
664
|
],
|
|
559
665
|
size,
|
|
560
666
|
),
|
|
667
|
+
// 下载(issue #85 新增):箭头入托盘,图表导出按钮(dsh-mermaid-render
|
|
668
|
+
// 卡片下载 PNG/SVG),stroke=currentColor 风格与其余图标一致。
|
|
669
|
+
download: (size = 16) =>
|
|
670
|
+
iconSvg(
|
|
671
|
+
[
|
|
672
|
+
createElement('path', { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' }),
|
|
673
|
+
createElement('polyline', { points: '7 10 12 15 17 10' }),
|
|
674
|
+
createElement('line', { x1: 12, y1: 15, x2: 12, y2: 3 }),
|
|
675
|
+
],
|
|
676
|
+
size,
|
|
677
|
+
),
|
|
678
|
+
// 复制(issue #85 新增):双层矩形,复制源码按钮(dsh-mermaid-render
|
|
679
|
+
// 卡片复制代码),stroke=currentColor 风格与其余图标一致。
|
|
680
|
+
copy: (size = 16) =>
|
|
681
|
+
iconSvg(
|
|
682
|
+
[
|
|
683
|
+
createElement('rect', { x: 9, y: 9, width: 13, height: 13, rx: 2 }),
|
|
684
|
+
createElement('path', { d: 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1' }),
|
|
685
|
+
],
|
|
686
|
+
size,
|
|
687
|
+
),
|
|
561
688
|
}
|
|
562
689
|
|
|
563
690
|
// Common-language / file-type badges (issue #24): brand fill + contrast
|
|
@@ -714,10 +841,30 @@ const fileIconByExt = (ext, size = 14) => {
|
|
|
714
841
|
return spec === undefined ? icon.file(size) : badgeIcon(spec, size)
|
|
715
842
|
}
|
|
716
843
|
|
|
717
|
-
|
|
718
|
-
//
|
|
844
|
+
'use strict'
|
|
845
|
+
// ── themed stylesheet (injected once per activation) ──────────────────
|
|
846
|
+
// Mirrors the host explorer surface: tight 2px 6px 8px body,
|
|
719
847
|
// 30px rows, box-sizing border-box indentation, folder rows use the
|
|
720
848
|
// strong type face to read as directories, files stay regular.
|
|
849
|
+
/**
|
|
850
|
+
* Inject this activation's stylesheet into the document head exactly once per
|
|
851
|
+
* fiber. Static CSS only: it must not sit behind any service check, or an HMR
|
|
852
|
+
* rebuild / service reload can leave an already-rendered tab unstyled. The
|
|
853
|
+
* disposer removes only this fiber's own <style> element, so a rebuild always
|
|
854
|
+
* keeps at least one copy.
|
|
855
|
+
*/
|
|
856
|
+
function injectStyles(ctx) {
|
|
857
|
+
ctx.effect(() => {
|
|
858
|
+
if (typeof document === 'undefined' || document === null || typeof document.head === 'undefined') return () => {}
|
|
859
|
+
const style = document.createElement('style')
|
|
860
|
+
style.setAttribute('data-dsh-file-activity', 'styles')
|
|
861
|
+
style.textContent = STYLES
|
|
862
|
+
document.head.appendChild(style)
|
|
863
|
+
return () => {
|
|
864
|
+
if (style.parentNode) style.parentNode.removeChild(style)
|
|
865
|
+
}
|
|
866
|
+
}, 'dsh-file-activity: styles')
|
|
867
|
+
}
|
|
721
868
|
const STYLES = `
|
|
722
869
|
.dfa { display:flex; flex-direction:column; height:100%; overflow-y:auto; overflow-x:hidden;
|
|
723
870
|
padding:2px 6px 8px; gap:2px; font:var(--dsw-font-s-14); color:var(--dsw-alias-label-primary); }
|
|
@@ -781,7 +928,7 @@ const STYLES = `
|
|
|
781
928
|
.dfa-fp-hint { flex:none; font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-tertiary); opacity:0.8; }
|
|
782
929
|
.dfa-fp-actions { display:flex; align-items:center; gap:2px; flex:none; }
|
|
783
930
|
/* issue #111: the preview body is a FLEX COLUMN so the mounted viewer
|
|
784
|
-
component (
|
|
931
|
+
component (the host's TextEditor for html/code/markdown, the image
|
|
785
932
|
wrap, and the pdf frame) actually fills the window. Their roots size via
|
|
786
933
|
flex:1 (html iframe .editorHtml, .editorCm, .editorMd, .editorImageWrap,
|
|
787
934
|
.dfa-pdf), which is IGNORED in a block context — so the HTML iframe, which
|
|
@@ -802,10 +949,37 @@ const STYLES = `
|
|
|
802
949
|
@keyframes dfa-row-in { from { opacity:0; transform:translateY(1px); } to { opacity:1; transform:none; } }
|
|
803
950
|
/* issue #60: 移除 #25 的侧边栏页签选中态品牌蓝覆盖([class*="tab"][class*=
|
|
804
951
|
"tabActive"] 全局子串选择器误伤宿主对话/工作区 tab 选中态,出现用户不
|
|
805
|
-
|
|
952
|
+
想要的蓝色高亮)。页签选中态回归宿主原生样式(issue #187 批 2:原生
|
|
953
|
+
dockkit 页签自己负责选中态,本插件不再覆盖)。 */
|
|
954
|
+
/* issue #187 批 2:页签 chip 由本插件的 title 席位渲染(原生 chip 无图标
|
|
955
|
+
API),因此标题连同图标一起画在这里。 */
|
|
956
|
+
.dfa-chip { display:inline-flex; align-items:center; gap:4px; min-width:0; }
|
|
957
|
+
/* 扩展点注册失败的显式提示(绝不静默失效)。 */
|
|
958
|
+
.dfa-degraded { margin:6px 2px; padding:6px 8px; border-radius:6px;
|
|
959
|
+
border:1px solid var(--dsw-alias-state-error-primary); background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); }
|
|
960
|
+
.dfa-degraded-title { font:var(--dsw-font-xs-13); color:var(--dsw-alias-state-error-primary); }
|
|
961
|
+
.dfa-degraded-hint { margin-top:2px; font:var(--dsw-font-xxs-12); opacity:.8; }
|
|
962
|
+
/* 设置页(settings.plugins.tab):开关行。 */
|
|
963
|
+
.dfa-set { display:flex; flex-direction:column; gap:10px; padding:10px 2px; }
|
|
964
|
+
.dfa-set-title { font:var(--dsw-font-sm-14); }
|
|
965
|
+
.dfa-set-row { display:flex; align-items:center; justify-content:space-between; gap:12px; cursor:pointer; }
|
|
966
|
+
.dfa-set-text { display:flex; flex-direction:column; gap:2px; min-width:0; }
|
|
967
|
+
.dfa-set-label { font:var(--dsw-font-xs-13); }
|
|
968
|
+
.dfa-set-hint { font:var(--dsw-font-xxs-12); opacity:.75; }
|
|
969
|
+
.dfa-set-switch { flex:none; width:16px; height:16px; accent-color:var(--dsw-alias-accent); cursor:pointer; }
|
|
970
|
+
/* 浮窗正文:Markdown / 代码 / 纯文本 / 图片四种渲染体。 */
|
|
971
|
+
.dfa-fp-md { height:100%; overflow:auto; padding:2px 4px; }
|
|
972
|
+
.dfa-fp-code { height:100%; overflow:auto; }
|
|
973
|
+
.dfa-fp-pre { margin:0; padding:8px 10px; overflow:auto; height:100%; font:var(--dsw-font-xxs-12);
|
|
974
|
+
white-space:pre-wrap; word-break:break-word; }
|
|
975
|
+
.dfa-fp-img { display:block; margin:auto; max-width:100%; max-height:100%; object-fit:contain; }
|
|
976
|
+
/* HTML 预览(issue #266 C):沙箱 iframe 撑满浮窗主体并内部滚动(与 PDF 一致)。 */
|
|
977
|
+
.dfa-fp-html { display:flex; flex-direction:column; width:100%; height:100%; }
|
|
978
|
+
.dfa-fp-html-frame { flex:1; min-height:0; width:100%; border:none; border-radius:6px; background:#fff; }
|
|
806
979
|
`
|
|
807
980
|
|
|
808
|
-
|
|
981
|
+
'use strict'
|
|
982
|
+
// ── row rendering helpers (recent list & stats tree) ──────────────────
|
|
809
983
|
const opClass = (op) =>
|
|
810
984
|
op === 'create'
|
|
811
985
|
? 'dfa-op-create'
|
|
@@ -822,7 +996,6 @@ const opLabel = (op) =>
|
|
|
822
996
|
: op === 'delete'
|
|
823
997
|
? strings.delete()
|
|
824
998
|
: strings.read()
|
|
825
|
-
|
|
826
999
|
/** Tooltip for a stats file row: absolute path + created / last-seen times. */
|
|
827
1000
|
const fileTitle = (abs, firstSeen, lastSeen) => {
|
|
828
1001
|
const times = []
|
|
@@ -830,7 +1003,6 @@ const fileTitle = (abs, firstSeen, lastSeen) => {
|
|
|
830
1003
|
if (typeof lastSeen === 'number') times.push(`${strings.lastSeen()} ${formatTime(lastSeen)}`)
|
|
831
1004
|
return times.length > 0 ? `${abs}\n${times.join(' · ')}` : abs
|
|
832
1005
|
}
|
|
833
|
-
|
|
834
1006
|
/** Count pills for a file/dir node — only actions that actually happened are
|
|
835
1007
|
* shown (a zero count renders no pill; all-zero nodes render no pill group,
|
|
836
1008
|
* keeping untouched files visually quiet). */
|
|
@@ -849,7 +1021,6 @@ const countPills = (node) => {
|
|
|
849
1021
|
if (pills.length === 0) return null
|
|
850
1022
|
return createElement('span', { className: 'dfa-counts', style: { paddingLeft: '6px' } }, ...pills)
|
|
851
1023
|
}
|
|
852
|
-
|
|
853
1024
|
/** Extension of a file name (lowercase, no leading dot); '' when none.
|
|
854
1025
|
* Dotfiles map to their whole name ('.gitignore' → 'gitignore') so the
|
|
855
1026
|
* badge table can cover them; 'notes.' still yields ''. */
|
|
@@ -859,14 +1030,12 @@ const extOf = (name) => {
|
|
|
859
1030
|
if (dot === 0) return name.slice(1).toLowerCase()
|
|
860
1031
|
return ''
|
|
861
1032
|
}
|
|
862
|
-
|
|
863
1033
|
/** Extension-less but common build files → their badge key. */
|
|
864
1034
|
const NAME_BADGES = {
|
|
865
1035
|
makefile: 'makefile',
|
|
866
1036
|
dockerfile: 'dockerfile',
|
|
867
1037
|
'cmakelists.txt': 'cmake',
|
|
868
1038
|
}
|
|
869
|
-
|
|
870
1039
|
/** Badge key for a file name: basename match first, then extension. */
|
|
871
1040
|
const badgeKeyOf = (name) => {
|
|
872
1041
|
const base = name.toLowerCase()
|
|
@@ -874,7 +1043,6 @@ const badgeKeyOf = (name) => {
|
|
|
874
1043
|
if (named !== undefined) return named
|
|
875
1044
|
return extOf(name)
|
|
876
1045
|
}
|
|
877
|
-
|
|
878
1046
|
/** A stats-tree file row: icon + name + count pills + relative time. */
|
|
879
1047
|
const fileRow = (file, depth, onOpen) =>
|
|
880
1048
|
createElement(
|
|
@@ -891,7 +1059,6 @@ const fileRow = (file, depth, onOpen) =>
|
|
|
891
1059
|
countPills(file),
|
|
892
1060
|
file.lastSeen ? createElement('span', { className: 'dfa-time' }, formatRelative(file.lastSeen)) : null,
|
|
893
1061
|
)
|
|
894
|
-
|
|
895
1062
|
/** One stats-tree node: file rows render inline, dirs toggle collapse. */
|
|
896
1063
|
function renderTreeNode(node, depth, collapsedDirs, onToggleDir, onOpen) {
|
|
897
1064
|
if (node.type === 'file') return fileRow(node, depth, onOpen)
|
|
@@ -917,7 +1084,6 @@ function renderTreeNode(node, depth, collapsedDirs, onToggleDir, onOpen) {
|
|
|
917
1084
|
: node.children.map((child) => renderTreeNode(child, depth + 1, collapsedDirs, onToggleDir, onOpen)),
|
|
918
1085
|
)
|
|
919
1086
|
}
|
|
920
|
-
|
|
921
1087
|
/** A recent-list row: op badge + basename + relative time. */
|
|
922
1088
|
const recentEntry = (entry, onOpen) =>
|
|
923
1089
|
createElement(
|
|
@@ -932,7 +1098,6 @@ const recentEntry = (entry, onOpen) =>
|
|
|
932
1098
|
createElement('span', { className: 'dfa-row-name' }, basenameOf(entry.path)),
|
|
933
1099
|
createElement('span', { className: 'dfa-time' }, formatRelative(entry.time)),
|
|
934
1100
|
)
|
|
935
|
-
|
|
936
1101
|
/** Toggle a key in a Set (directory collapse state). */
|
|
937
1102
|
function toggleInSet(set, key) {
|
|
938
1103
|
const next = new Set(set)
|
|
@@ -940,7 +1105,6 @@ function toggleInSet(set, key) {
|
|
|
940
1105
|
else next.add(key)
|
|
941
1106
|
return next
|
|
942
1107
|
}
|
|
943
|
-
|
|
944
1108
|
/** Clear the current session's records host-side and reset its bucket. */
|
|
945
1109
|
function clearSessionData(dataStore, sessionId) {
|
|
946
1110
|
if (!window.confirm(strings.clearConfirm())) return
|
|
@@ -953,7 +1117,6 @@ function clearSessionData(dataStore, sessionId) {
|
|
|
953
1117
|
},
|
|
954
1118
|
})
|
|
955
1119
|
}
|
|
956
|
-
|
|
957
1120
|
/** Manual refresh: fetch stats + the authoritative cwd for this session. */
|
|
958
1121
|
function refreshSessionData(dataStore, sessionId, setCwd, setError) {
|
|
959
1122
|
if (sessionId === '') return
|
|
@@ -976,10 +1139,41 @@ function refreshSessionData(dataStore, sessionId, setCwd, setError) {
|
|
|
976
1139
|
})
|
|
977
1140
|
}
|
|
978
1141
|
|
|
979
|
-
|
|
1142
|
+
'use strict'
|
|
1143
|
+
// ── view component ────────────────────────────────────────────────────
|
|
980
1144
|
/** Shared empty bucket for sessions that have never loaded data (stable ref). */
|
|
981
1145
|
const EMPTY_SESSION = { recent: [], counts: {}, loading: true }
|
|
982
|
-
|
|
1146
|
+
/** tab.visible out of the host's SidebarRightTabInfo; undefined when the
|
|
1147
|
+
* information (or its shape) is unavailable. */
|
|
1148
|
+
function readSeatVisible(info) {
|
|
1149
|
+
if (info === null || typeof info !== 'object') return undefined
|
|
1150
|
+
const tab = info.tab
|
|
1151
|
+
if (tab === null || typeof tab !== 'object') return undefined
|
|
1152
|
+
const visible = tab.visible
|
|
1153
|
+
return typeof visible === 'boolean' ? visible : undefined
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Resolve whether this seat is on screen.
|
|
1157
|
+
*
|
|
1158
|
+
* The native seat hands the component **no** `visible` prop: the host dispatches
|
|
1159
|
+
* it as `renderSlot('sidebar.right.pane.tab', {}, { hookContext })`, so the owner
|
|
1160
|
+
* props share is empty and everything live arrives through the injected
|
|
1161
|
+
* `useTabInfo()` hook (SidebarRightTabInfo.tab.visible). Reading `props.visible`
|
|
1162
|
+
* alone is therefore always `undefined` — the pre-#266 (`if (!visible) return`)
|
|
1163
|
+
* guard was constant-true, so an opened panel never loaded and never polled
|
|
1164
|
+
* (it only showed data after a manual Refresh).
|
|
1165
|
+
*
|
|
1166
|
+
* Order: the host hook first, then the legacy prop, then visible by default.
|
|
1167
|
+
* Defaulting to visible is the deliberate failure direction (issue #266): if the
|
|
1168
|
+
* hook is unavailable or the host shape changes again, the panel must still
|
|
1169
|
+
* load — the cost is one polling panel, the alternative is a blank one.
|
|
1170
|
+
*/
|
|
1171
|
+
function useSeatVisible(useTabInfo, legacyVisible) {
|
|
1172
|
+
const info = typeof useTabInfo === 'function' ? useTabInfo() : undefined
|
|
1173
|
+
const seatVisible = readSeatVisible(info)
|
|
1174
|
+
if (seatVisible !== undefined) return seatVisible
|
|
1175
|
+
return legacyVisible !== false
|
|
1176
|
+
}
|
|
983
1177
|
/**
|
|
984
1178
|
* Polling loader for one session: fetches stats on mount and on a fixed
|
|
985
1179
|
* interval while visible, prefers the sidebar's authoritative session.cwd
|
|
@@ -1022,7 +1216,6 @@ function useSessionLoader(visible, sessionId, scope, dataStore, setCwd, setError
|
|
|
1022
1216
|
}
|
|
1023
1217
|
}, [visible, sessionId, dataStore])
|
|
1024
1218
|
}
|
|
1025
|
-
|
|
1026
1219
|
/** Error banner element, or null when the last load succeeded. */
|
|
1027
1220
|
function renderError(error) {
|
|
1028
1221
|
if (!error) return null
|
|
@@ -1038,7 +1231,6 @@ function renderError(error) {
|
|
|
1038
1231
|
strings.loadError(),
|
|
1039
1232
|
)
|
|
1040
1233
|
}
|
|
1041
|
-
|
|
1042
1234
|
/** "最近访问" section: collapsible head with refresh/clear actions. */
|
|
1043
1235
|
function renderRecentSection(recent, recentOpen, onToggle, onRefresh, onClear, onOpen) {
|
|
1044
1236
|
return createElement(
|
|
@@ -1094,7 +1286,6 @@ function renderRecentSection(recent, recentOpen, onToggle, onRefresh, onClear, o
|
|
|
1094
1286
|
),
|
|
1095
1287
|
)
|
|
1096
1288
|
}
|
|
1097
|
-
|
|
1098
1289
|
/** "文件统计" section: the directory tree, or an empty hint. */
|
|
1099
1290
|
function renderStatsSection(tree, collapsedDirs, onToggleDir, onOpen) {
|
|
1100
1291
|
return createElement(
|
|
@@ -1110,20 +1301,31 @@ function renderStatsSection(tree, collapsedDirs, onToggleDir, onOpen) {
|
|
|
1110
1301
|
),
|
|
1111
1302
|
)
|
|
1112
1303
|
}
|
|
1113
|
-
|
|
1114
1304
|
/**
|
|
1115
1305
|
* The file-activity tab. Each session renders only its own store bucket:
|
|
1116
1306
|
* a fresh conversation shows an empty list immediately, with no residue
|
|
1117
1307
|
* from the previous session. Clicking any file opens a FLOATING preview
|
|
1118
1308
|
* that reuses the sidebar's NATIVE viewer via matchFileViewer.
|
|
1119
1309
|
*/
|
|
1120
|
-
function FileActivityView({
|
|
1310
|
+
function FileActivityView({
|
|
1311
|
+
ctx,
|
|
1312
|
+
store,
|
|
1313
|
+
scope,
|
|
1314
|
+
sessionId: seatSessionId,
|
|
1315
|
+
visible: legacyVisible,
|
|
1316
|
+
useTabInfo,
|
|
1317
|
+
dataStore,
|
|
1318
|
+
}) {
|
|
1121
1319
|
const data = useSyncExternalStore(dataStore.subscribe, dataStore.getSnapshot)
|
|
1122
1320
|
const [cwd, setCwd] = useState(scope?.cwd || '')
|
|
1123
1321
|
const [error, setError] = useState(false)
|
|
1124
1322
|
const [recentOpen, setRecentOpen] = useState(true)
|
|
1125
1323
|
const [collapsedDirs, setCollapsedDirs] = useState(() => new Set())
|
|
1126
|
-
|
|
1324
|
+
// Native seats pass sessionId directly; the legacy scope object still works.
|
|
1325
|
+
const sessionId = seatSessionId ?? scope?.sessionId ?? ''
|
|
1326
|
+
// issue #266 A: the native seat passes NO visible prop — visibility comes
|
|
1327
|
+
// from the injected useTabInfo() (see useSeatVisible).
|
|
1328
|
+
const visible = useSeatVisible(useTabInfo, legacyVisible)
|
|
1127
1329
|
const sessionData = (data.bySession ?? {})[sessionId] ?? EMPTY_SESSION
|
|
1128
1330
|
const tree = useMemo(() => buildTree(sessionData.counts ?? {}), [sessionData.counts])
|
|
1129
1331
|
useEffect(() => {
|
|
@@ -1142,8 +1344,10 @@ function FileActivityView({ ctx, store, scope, visible, dataStore }) {
|
|
|
1142
1344
|
closePreviewOnHidden(visible, dataStore)
|
|
1143
1345
|
}, [visible, dataStore])
|
|
1144
1346
|
const toggleDir = (path) => setCollapsedDirs((prev) => toggleInSet(prev, path))
|
|
1145
|
-
|
|
1146
|
-
|
|
1347
|
+
// The floating preview itself lives in the root-scoped 'shell.overlay' seat
|
|
1348
|
+
// (registered by registerPreviewOverlay), so this view only publishes the
|
|
1349
|
+
// target — together with the owning session, which authorizes the routes.
|
|
1350
|
+
const openPreview = (path) => dataStore.set({ preview: { abs: path, name: basenameOf(path), sessionId } })
|
|
1147
1351
|
const onClear = () => clearSessionData(dataStore, sessionId)
|
|
1148
1352
|
const onRefresh = () => refreshSessionData(dataStore, sessionId, setCwd, setError)
|
|
1149
1353
|
const recent = sessionData.recent ?? []
|
|
@@ -1151,21 +1355,67 @@ function FileActivityView({ ctx, store, scope, visible, dataStore }) {
|
|
|
1151
1355
|
'div',
|
|
1152
1356
|
{ className: 'dfa' },
|
|
1153
1357
|
renderError(error),
|
|
1358
|
+
renderDegraded(),
|
|
1154
1359
|
renderRecentSection(recent, recentOpen, () => setRecentOpen((v) => !v), onRefresh, onClear, openPreview),
|
|
1155
1360
|
renderStatsSection(tree, collapsedDirs, toggleDir, openPreview),
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1361
|
+
)
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* Degradation notice: shown when this activation failed to register one of the
|
|
1365
|
+
* host extension points (registerTabType / documentPreviews / auto-open). The
|
|
1366
|
+
* panel is also marked with data-dfa-degraded so the e2e suite can assert the
|
|
1367
|
+
* failure instead of hunting a missing tab.
|
|
1368
|
+
* @returns the notice element, or null when nothing degraded.
|
|
1369
|
+
*/
|
|
1370
|
+
function renderDegraded() {
|
|
1371
|
+
try {
|
|
1372
|
+
const markers = typeof document === 'undefined' ? undefined : document.documentElement?.dataset
|
|
1373
|
+
if (markers === undefined || markers === null || markers.dfaDegraded === undefined) return null
|
|
1374
|
+
} catch {
|
|
1375
|
+
return null
|
|
1376
|
+
}
|
|
1377
|
+
return createElement(
|
|
1378
|
+
'div',
|
|
1379
|
+
{ className: 'dfa-degraded', 'data-dfa-degraded': '1' },
|
|
1380
|
+
createElement('div', { className: 'dfa-degraded-title' }, strings.tabUnavailable()),
|
|
1381
|
+
createElement('div', { className: 'dfa-degraded-hint' }, strings.tabUnavailableHint()),
|
|
1165
1382
|
)
|
|
1166
1383
|
}
|
|
1167
1384
|
|
|
1168
|
-
|
|
1385
|
+
'use strict'
|
|
1386
|
+
// ── preview data access (routes, viewer choice, loading) ────────────────
|
|
1387
|
+
//
|
|
1388
|
+
// The floating window used to mount the third-party sidebar's built-in viewer,
|
|
1389
|
+
// picked by the third-party sidebar matchFileViewer(path), and fed it the bytes its
|
|
1390
|
+
// fetchStrategy asked for. That service is gone (issue #187 batch 2), so this
|
|
1391
|
+
// module does the reading itself through the plugin's own routes:
|
|
1392
|
+
//
|
|
1393
|
+
// /file-activity/file?sessionId&path → raw bytes (image / pdf)
|
|
1394
|
+
// /file-activity/file?sessionId&path&as=text → fs.read-shaped JSON text
|
|
1395
|
+
//
|
|
1396
|
+
// The route matters: file activity records files the agent touched ANYWHERE
|
|
1397
|
+
// (scratch files in /tmp, sibling repos, ~/.dsh), while the host's file
|
|
1398
|
+
// provider is fenced to the session workspace. The plugin route authorizes
|
|
1399
|
+
// exactly the paths this session recorded.
|
|
1400
|
+
/** Image suffixes the browser renders natively. */
|
|
1401
|
+
const IMAGE_EXT = /^(svg|png|jpe?g|gif|webp|avif|bmp|ico)$/i
|
|
1402
|
+
/** Suffixes rendered as Markdown. */
|
|
1403
|
+
const MARKDOWN_EXT = /^(md|markdown|mdx)$/i
|
|
1404
|
+
/** Suffixes rendered as a sandboxed HTML document (issue #266 C). */
|
|
1405
|
+
const HTML_EXT = /^(html?|xhtml)$/i
|
|
1406
|
+
/** Lower-case suffix of a path ('' when it has none). extOf (rows.ts) works on
|
|
1407
|
+
* a file NAME, so the basename is sliced off first. */
|
|
1408
|
+
function extOfPath(path) {
|
|
1409
|
+
return extOf(path.slice(path.lastIndexOf('/') + 1))
|
|
1410
|
+
}
|
|
1411
|
+
/** Official UI atoms come from the host's staticModules (zero install). */
|
|
1412
|
+
function uiAtoms() {
|
|
1413
|
+
try {
|
|
1414
|
+
return require('@deepseek-ai/dsh-client-ui-primitives')
|
|
1415
|
+
} catch {
|
|
1416
|
+
return {}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1169
1419
|
/** Resolve a possibly-relative path against the session cwd. */
|
|
1170
1420
|
function resolvePath(path, cwd) {
|
|
1171
1421
|
if (typeof path !== 'string' || path === '') return path
|
|
@@ -1173,12 +1423,10 @@ function resolvePath(path, cwd) {
|
|
|
1173
1423
|
if (typeof cwd === 'string' && cwd !== '') return `${cwd.replace(/\/+$/, '')}/${path}`
|
|
1174
1424
|
return path
|
|
1175
1425
|
}
|
|
1176
|
-
|
|
1177
1426
|
/** Whether the fs.read API response carries a text content payload. */
|
|
1178
1427
|
function isFsReadOk(json) {
|
|
1179
1428
|
return json !== null && typeof json === 'object' && json.ok === true && typeof json.value?.content === 'string'
|
|
1180
1429
|
}
|
|
1181
|
-
|
|
1182
1430
|
/** Error load state from an fs.read API response (or a generic message).
|
|
1183
1431
|
* Raw system errors are translated to friendly, locale-aware messages
|
|
1184
1432
|
* (issue #68): deleted files and workspace-fenced paths must never surface
|
|
@@ -1192,20 +1440,87 @@ function fsReadError(json, viewer) {
|
|
|
1192
1440
|
else message = raw
|
|
1193
1441
|
return { status: 'error', viewer, message }
|
|
1194
1442
|
}
|
|
1195
|
-
|
|
1443
|
+
/** Plugin text route (fs.read-shaped JSON), or null on any failure. */
|
|
1444
|
+
async function fetchTextContent(sessionId, path) {
|
|
1445
|
+
try {
|
|
1446
|
+
const response = await fetch(textUrlOf(sessionId, path))
|
|
1447
|
+
return await response.json()
|
|
1448
|
+
} catch {
|
|
1449
|
+
return null
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Load the recorded file's text through the plugin's own route, falling back
|
|
1454
|
+
* to the host sidebar route when the plugin refuses the path. Whatever the
|
|
1455
|
+
* host viewer used to do with its own fetchStrategy, this window does the
|
|
1456
|
+
* reading itself now — the routes are the same ones the previous
|
|
1457
|
+
* implementation used for its fsRead strategy.
|
|
1458
|
+
*/
|
|
1459
|
+
async function loadFsReadContent(viewer, path, scope, sessionId) {
|
|
1460
|
+
const cwd = scope === null || scope === undefined ? '' : scope.cwd
|
|
1461
|
+
const target = resolvePath(path, cwd ?? '')
|
|
1462
|
+
const own = await fetchTextContent(sessionId, target)
|
|
1463
|
+
if (isFsReadOk(own)) return { status: 'ready', viewer, content: own.value.content }
|
|
1464
|
+
try {
|
|
1465
|
+
const response = await fetch('/sidebar/api/fs.read', {
|
|
1466
|
+
method: 'POST',
|
|
1467
|
+
headers: { 'content-type': 'application/json' },
|
|
1468
|
+
body: JSON.stringify({ sessionId, path: target }),
|
|
1469
|
+
})
|
|
1470
|
+
const json = await response.json()
|
|
1471
|
+
if (isFsReadOk(json)) return { status: 'ready', viewer, content: json.value.content }
|
|
1472
|
+
return fsReadError(json, viewer)
|
|
1473
|
+
} catch {
|
|
1474
|
+
return fsReadError(own, viewer)
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
/** Which renderer a recorded path gets. */
|
|
1478
|
+
function viewerOf(path) {
|
|
1479
|
+
const ext = extOfPath(path)
|
|
1480
|
+
if (IMAGE_EXT.test(ext)) return 'image'
|
|
1481
|
+
if (ext === 'pdf') return 'pdf'
|
|
1482
|
+
if (HTML_EXT.test(ext)) return 'html'
|
|
1483
|
+
if (MARKDOWN_EXT.test(ext)) return 'markdown'
|
|
1484
|
+
return 'text'
|
|
1485
|
+
}
|
|
1486
|
+
/** Fetch the bytes the chosen renderer needs (media URL, or text content). */
|
|
1487
|
+
async function fetchPreviewLoad(target) {
|
|
1488
|
+
const viewer = { id: viewerOf(target.abs) }
|
|
1489
|
+
// image / pdf / html are served as BYTES by the plugin's own media route;
|
|
1490
|
+
// html goes through an iframe (see HtmlBody), not through the text path.
|
|
1491
|
+
if (viewer.id === 'image' || viewer.id === 'pdf' || viewer.id === 'html') {
|
|
1492
|
+
return { status: 'ready', viewer, mediaUrl: mediaUrlOf(target.sessionId, target.abs) }
|
|
1493
|
+
}
|
|
1494
|
+
return loadFsReadContent(viewer, target.abs, null, target.sessionId)
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
'use strict'
|
|
1498
|
+
// ── floating preview window (own renderer, shell.overlay seat) ─────────
|
|
1499
|
+
//
|
|
1500
|
+
// History: this window used to mount the third-party sidebar's built-in
|
|
1501
|
+
// viewer and let it fetch its own bytes. That service is gone (issue #187
|
|
1502
|
+
// batch 2), so the window renders the recorded file itself:
|
|
1503
|
+
//
|
|
1504
|
+
// image (svg/png/jpeg/gif/webp/avif/bmp/ico) → <img src> on the plugin media route
|
|
1505
|
+
// pdf → <iframe src> on the same route
|
|
1506
|
+
// markdown → host MarkdownText atom
|
|
1507
|
+
// anything else → host CodeBlock atom, else <pre>
|
|
1508
|
+
//
|
|
1509
|
+
// The overlay lives in the root-scoped 'shell.overlay' list seat (the host's
|
|
1510
|
+
// own floating layer: above every column, click-through unless the entry opts
|
|
1511
|
+
// into pointer events) and renders nothing at all while no preview is open.
|
|
1512
|
+
// Data access (routes, viewer choice, loading) lives in preview-data.ts.
|
|
1196
1513
|
/** Milliseconds after which an error-state preview closes itself (issue #76):
|
|
1197
1514
|
* a shell that failed to load any content is useless, so it must not linger
|
|
1198
1515
|
* over the main UI until the user finds the × button. */
|
|
1199
1516
|
const AUTO_CLOSE_MS = 2500
|
|
1200
|
-
|
|
1201
1517
|
/** Whether a pointerdown target lies inside the floating window. The window
|
|
1202
|
-
* surface carries the
|
|
1518
|
+
* surface carries the .dfa-fp class; anything else counts as "outside"
|
|
1203
1519
|
* and dismisses the preview (issue #76 — click anywhere outside closes). */
|
|
1204
1520
|
function isInsideFloating(target) {
|
|
1205
1521
|
if (!target || typeof target.closest !== 'function') return false
|
|
1206
1522
|
return target.closest('.dfa-fp') !== null
|
|
1207
1523
|
}
|
|
1208
|
-
|
|
1209
1524
|
/** Click behavior for the window surface: in the error state ANY click
|
|
1210
1525
|
* closes the shell (there is no content to interact with), otherwise the
|
|
1211
1526
|
* click is swallowed so the viewer's own interactions keep working. */
|
|
@@ -1214,81 +1529,17 @@ function previewClickAction(load, event) {
|
|
|
1214
1529
|
if (event && event.stopPropagation) event.stopPropagation()
|
|
1215
1530
|
return 'stop'
|
|
1216
1531
|
}
|
|
1217
|
-
|
|
1218
1532
|
/** Close the floating preview when the tab goes hidden (switching tabs /
|
|
1219
1533
|
* operating the main UI), so it never lingers over the interface. */
|
|
1220
1534
|
function closePreviewOnHidden(visible, dataStore) {
|
|
1221
1535
|
if (!visible) dataStore.set({ preview: null })
|
|
1222
1536
|
}
|
|
1223
|
-
|
|
1224
|
-
/**
|
|
1225
|
-
* Load fsRead content through the sidebar API and resolve the viewer's
|
|
1226
|
-
* load state (ready with text, or error with the API message). When the
|
|
1227
|
-
* sidebar refuses a recorded path (its workspace fence, e.g. agent-read
|
|
1228
|
-
* files under ~/.dsh), fall back to the plugin's own text route, which
|
|
1229
|
-
* authorizes exactly the paths this session recorded (issue #68).
|
|
1230
|
-
*/
|
|
1231
|
-
async function loadFsReadContent(viewer, path, scope, sessionId) {
|
|
1232
|
-
const target = resolvePath(path, scope?.cwd ?? '')
|
|
1233
|
-
const response = await fetch('/sidebar/api/fs.read', {
|
|
1234
|
-
method: 'POST',
|
|
1235
|
-
headers: { 'content-type': 'application/json' },
|
|
1236
|
-
body: JSON.stringify({ sessionId, path: target }),
|
|
1237
|
-
})
|
|
1238
|
-
const json = await response.json()
|
|
1239
|
-
if (isFsReadOk(json)) return { status: 'ready', viewer, content: json.value.content }
|
|
1240
|
-
// The sidebar refused — try OUR recorded-path text route before giving up.
|
|
1241
|
-
const textJson = await fetchTextContent(sessionId, path)
|
|
1242
|
-
if (isFsReadOk(textJson)) return { status: 'ready', viewer, content: textJson.value.content }
|
|
1243
|
-
return fsReadError(json, viewer)
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
/** Plugin text route (fs.read-shaped JSON), or null on any failure. */
|
|
1247
|
-
async function fetchTextContent(sessionId, path) {
|
|
1248
|
-
try {
|
|
1249
|
-
const response = await fetch(textUrlOf(sessionId, path))
|
|
1250
|
-
return await response.json()
|
|
1251
|
-
} catch {
|
|
1252
|
-
return null
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
/**
|
|
1257
|
-
* Fetch the bytes the viewer's fetchStrategy needs (fsRead text /
|
|
1258
|
-
* mediaUrl / customData) and resolve its load state.
|
|
1259
|
-
*/
|
|
1260
|
-
async function fetchPreviewLoad(viewer, path, scope, sessionId) {
|
|
1261
|
-
const strategy = viewer.fetchStrategy
|
|
1262
|
-
if (strategy === 'fsRead') return loadFsReadContent(viewer, path, scope, sessionId)
|
|
1263
|
-
if (strategy === 'mediaUrl') {
|
|
1264
|
-
return { status: 'ready', viewer, mediaUrl: mediaUrlOf(sessionId, path) }
|
|
1265
|
-
}
|
|
1266
|
-
if (strategy === 'custom') {
|
|
1267
|
-
const data = await (viewer.load?.(path, scope) ?? Promise.resolve(undefined))
|
|
1268
|
-
return { status: 'ready', viewer, customData: data }
|
|
1269
|
-
}
|
|
1270
|
-
// 'binary-download' and anything else: mount the viewer's own
|
|
1271
|
-
// component (it handles the download / media itself).
|
|
1272
|
-
return { status: 'ready', viewer }
|
|
1273
|
-
}
|
|
1274
|
-
|
|
1275
|
-
/**
|
|
1276
|
-
* Resolve the file's viewer through the sidebar registry and load the
|
|
1277
|
-
* bytes it needs; failures become an error state shown in the window.
|
|
1278
|
-
*/
|
|
1279
|
-
function usePreviewLoader(service, path, sessionId, scope) {
|
|
1537
|
+
function usePreviewLoader(target) {
|
|
1280
1538
|
const [load, setLoad] = useState({ status: 'loading', viewer: null })
|
|
1281
1539
|
useEffect(() => {
|
|
1282
1540
|
let cancelled = false
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
setLoad({ status: 'error', viewer: null, message: strings.previewUnsupported() })
|
|
1286
|
-
return () => {
|
|
1287
|
-
cancelled = true
|
|
1288
|
-
}
|
|
1289
|
-
}
|
|
1290
|
-
setLoad({ status: 'loading', viewer })
|
|
1291
|
-
fetchPreviewLoad(viewer, path, scope, sessionId)
|
|
1541
|
+
setLoad({ status: 'loading', viewer: { id: viewerOf(target.abs) } })
|
|
1542
|
+
fetchPreviewLoad(target)
|
|
1292
1543
|
.then((next) => {
|
|
1293
1544
|
if (!cancelled) setLoad(next)
|
|
1294
1545
|
})
|
|
@@ -1296,18 +1547,64 @@ function usePreviewLoader(service, path, sessionId, scope) {
|
|
|
1296
1547
|
if (!cancelled)
|
|
1297
1548
|
setLoad({
|
|
1298
1549
|
status: 'error',
|
|
1299
|
-
viewer,
|
|
1550
|
+
viewer: null,
|
|
1300
1551
|
message: error instanceof Error ? error.message : String(error),
|
|
1301
1552
|
})
|
|
1302
1553
|
})
|
|
1303
1554
|
return () => {
|
|
1304
1555
|
cancelled = true
|
|
1305
1556
|
}
|
|
1306
|
-
}, [
|
|
1557
|
+
}, [target.abs, target.sessionId])
|
|
1307
1558
|
return load
|
|
1308
1559
|
}
|
|
1309
|
-
|
|
1310
|
-
|
|
1560
|
+
/** Text body: the host's own Markdown / code atoms, with a plain <pre> fallback. */
|
|
1561
|
+
function TextBody({ load, path }) {
|
|
1562
|
+
const text = load.content ?? ''
|
|
1563
|
+
const ui = uiAtoms()
|
|
1564
|
+
if (load.viewer?.id === 'markdown' && typeof ui.MarkdownText === 'function') {
|
|
1565
|
+
return createElement('div', { className: 'dfa-fp-md' }, createElement(ui.MarkdownText, { text }))
|
|
1566
|
+
}
|
|
1567
|
+
if (typeof ui.CodeBlock === 'function') {
|
|
1568
|
+
return createElement(
|
|
1569
|
+
'div',
|
|
1570
|
+
{ className: 'dfa-fp-code' },
|
|
1571
|
+
createElement(ui.CodeBlock, { code: text, language: extOfPath(path) }),
|
|
1572
|
+
)
|
|
1573
|
+
}
|
|
1574
|
+
return createElement('pre', { className: 'dfa-fp-pre' }, text)
|
|
1575
|
+
}
|
|
1576
|
+
/** Image body: the plugin's media route renders the recorded bytes. */
|
|
1577
|
+
function ImageBody({ load, title }) {
|
|
1578
|
+
return createElement('img', { className: 'dfa-fp-img', src: load.mediaUrl, alt: title })
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* HTML body: a SANDBOXED iframe over the plugin's media route (issue #266 C —
|
|
1582
|
+
* the README promised a sandboxed iframe while the code rendered the markup as
|
|
1583
|
+
* a code block).
|
|
1584
|
+
*
|
|
1585
|
+
* Two independent sandbox layers, deliberately without `allow-same-origin`:
|
|
1586
|
+
* - the iframe's `sandbox` attribute gives the document an opaque origin, so
|
|
1587
|
+
* a previewed page cannot read the host's storage, cookies or same-origin
|
|
1588
|
+
* APIs, and cannot reach the parent document;
|
|
1589
|
+
* - the route answers text/html with a response-level
|
|
1590
|
+
* `Content-Security-Policy: sandbox …` header, so the SAME protection holds
|
|
1591
|
+
* when the URL is opened directly instead of inside this iframe.
|
|
1592
|
+
* Scripts/forms stay enabled so interactive documents (charts, demos) work;
|
|
1593
|
+
* navigation of the top window is not granted.
|
|
1594
|
+
*/
|
|
1595
|
+
function HtmlBody({ load, title }) {
|
|
1596
|
+
return createElement(
|
|
1597
|
+
'div',
|
|
1598
|
+
{ className: 'dfa-fp-html' },
|
|
1599
|
+
createElement('iframe', {
|
|
1600
|
+
className: 'dfa-fp-html-frame',
|
|
1601
|
+
src: load.mediaUrl,
|
|
1602
|
+
sandbox: 'allow-scripts allow-forms',
|
|
1603
|
+
title,
|
|
1604
|
+
}),
|
|
1605
|
+
)
|
|
1606
|
+
}
|
|
1607
|
+
/** Preview window body: loading note / error panel / renderer mount. */
|
|
1311
1608
|
function renderPreviewBody(load, ctx, store, scope, path, title, sessionId) {
|
|
1312
1609
|
if (load.status === 'loading') {
|
|
1313
1610
|
return createElement('div', { className: 'dfa-fp-note' }, strings.loading())
|
|
@@ -1323,39 +1620,15 @@ function renderPreviewBody(load, ctx, store, scope, path, title, sessionId) {
|
|
|
1323
1620
|
createElement('div', { style: { marginTop: '6px', fontSize: '11px', opacity: 0.7 } }, strings.autoCloseHint()),
|
|
1324
1621
|
)
|
|
1325
1622
|
}
|
|
1326
|
-
|
|
1623
|
+
const props = { load, path, title, sessionId }
|
|
1624
|
+
if (load.viewer?.id === 'image') return ImageBody(props)
|
|
1625
|
+
if (load.viewer?.id === 'html') return HtmlBody(props)
|
|
1626
|
+
if (load.viewer?.id === 'pdf') {
|
|
1327
1627
|
const url = mediaUrlOf(sessionId, path)
|
|
1328
1628
|
return createElement(PdfPreview, { src: url, download: `${url}&download=1`, title })
|
|
1329
1629
|
}
|
|
1330
|
-
return
|
|
1331
|
-
ctx,
|
|
1332
|
-
store,
|
|
1333
|
-
scope,
|
|
1334
|
-
path,
|
|
1335
|
-
title,
|
|
1336
|
-
viewerId: load.viewer.id,
|
|
1337
|
-
content: load.content,
|
|
1338
|
-
mediaUrl: load.mediaUrl,
|
|
1339
|
-
customData: load.customData,
|
|
1340
|
-
})
|
|
1630
|
+
return TextBody(props)
|
|
1341
1631
|
}
|
|
1342
|
-
|
|
1343
|
-
/**
|
|
1344
|
-
* A floating preview window. Instead of re-implementing rendering, it
|
|
1345
|
-
* asks the sidebar registry for the file's viewer (`matchFileViewer`),
|
|
1346
|
-
* fetches the bytes the viewer's fetchStrategy needs (fsRead text /
|
|
1347
|
-
* mediaUrl / customData), then mounts that viewer's own component — so
|
|
1348
|
-
* code gets syntax highlighting and markdown gets rendered by the SAME
|
|
1349
|
-
* built-in renderers the sidebar's editor tab uses.
|
|
1350
|
-
*
|
|
1351
|
-
* Media caveat: the sidebar's own media route (/sidebar/file) only serves
|
|
1352
|
-
* files inside the session working directory, while file activity records
|
|
1353
|
-
* files the agent touched anywhere (/tmp scratch files, sibling repos…).
|
|
1354
|
-
* Media bytes therefore come from OUR route (/file-activity/file), which
|
|
1355
|
-
* authorizes exactly the paths this session recorded; PDF is the one
|
|
1356
|
-
* built-in viewer that fetches its own URL internally (it ignores the
|
|
1357
|
-
* `mediaUrl` prop), so it gets a small iframe preview instead.
|
|
1358
|
-
*/
|
|
1359
1632
|
/**
|
|
1360
1633
|
* Dismissal affordances (issue #76 — the preview must never linger):
|
|
1361
1634
|
* 1. pointerdown anywhere OUTSIDE the window closes it (capture phase, so
|
|
@@ -1369,12 +1642,11 @@ function usePreviewDismiss(load, onClose) {
|
|
|
1369
1642
|
useEffect(() => {
|
|
1370
1643
|
if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return () => {}
|
|
1371
1644
|
const handler = (event) => {
|
|
1372
|
-
if (!isInsideFloating(event
|
|
1645
|
+
if (!isInsideFloating(event && event.target)) onClose()
|
|
1373
1646
|
}
|
|
1374
1647
|
document.addEventListener('pointerdown', handler, true)
|
|
1375
1648
|
return () => document.removeEventListener('pointerdown', handler, true)
|
|
1376
1649
|
}, [onClose])
|
|
1377
|
-
|
|
1378
1650
|
useEffect(() => {
|
|
1379
1651
|
if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return () => {}
|
|
1380
1652
|
const handler = (event) => {
|
|
@@ -1383,7 +1655,6 @@ function usePreviewDismiss(load, onClose) {
|
|
|
1383
1655
|
document.addEventListener('keydown', handler)
|
|
1384
1656
|
return () => document.removeEventListener('keydown', handler)
|
|
1385
1657
|
}, [onClose])
|
|
1386
|
-
|
|
1387
1658
|
useEffect(() => {
|
|
1388
1659
|
if (load.status !== 'error') return undefined
|
|
1389
1660
|
if (typeof window === 'undefined' || typeof window.setTimeout !== 'function') return undefined
|
|
@@ -1391,7 +1662,6 @@ function usePreviewDismiss(load, onClose) {
|
|
|
1391
1662
|
return () => window.clearTimeout(timer)
|
|
1392
1663
|
}, [load.status, onClose])
|
|
1393
1664
|
}
|
|
1394
|
-
|
|
1395
1665
|
/** The floating window element: head (title + hint + close) and body. */
|
|
1396
1666
|
function renderFloatingWindow(load, ctx, store, scope, path, title, sessionId, onClose) {
|
|
1397
1667
|
return createElement(
|
|
@@ -1401,6 +1671,7 @@ function renderFloatingWindow(load, ctx, store, scope, path, title, sessionId, o
|
|
|
1401
1671
|
'div',
|
|
1402
1672
|
{
|
|
1403
1673
|
className: 'dfa-fp',
|
|
1674
|
+
'data-dfa-preview': '1',
|
|
1404
1675
|
onClick: (event) => {
|
|
1405
1676
|
if (previewClickAction(load, event) === 'close') onClose()
|
|
1406
1677
|
},
|
|
@@ -1433,24 +1704,50 @@ function renderFloatingWindow(load, ctx, store, scope, path, title, sessionId, o
|
|
|
1433
1704
|
),
|
|
1434
1705
|
)
|
|
1435
1706
|
}
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1707
|
+
/**
|
|
1708
|
+
* The floating preview, mounted by the root-scoped 'shell.overlay' list seat
|
|
1709
|
+
* (the host's own floating layer: above every column, click-through unless the
|
|
1710
|
+
* entry opts into pointer events). It renders nothing at all while no preview
|
|
1711
|
+
* is open, so the layer stays invisible.
|
|
1712
|
+
*/
|
|
1713
|
+
function FloatingPreviewOverlay() {
|
|
1714
|
+
const store = sharedStore()
|
|
1715
|
+
const state = useSyncExternalStore(
|
|
1716
|
+
store === null ? () => () => {} : store.subscribe,
|
|
1717
|
+
store === null ? () => null : store.getSnapshot,
|
|
1718
|
+
)
|
|
1719
|
+
const preview = state === null || state === undefined ? null : state.preview
|
|
1720
|
+
const sessionId = preview === null ? '' : preview.sessionId
|
|
1721
|
+
// Hooks must run unconditionally: the loader is fed an empty target while
|
|
1722
|
+
// the window is closed (it then never resolves a load, and nothing renders).
|
|
1723
|
+
const target = preview ?? { abs: '', name: '', sessionId: '' }
|
|
1724
|
+
const load = usePreviewLoader(target)
|
|
1725
|
+
const onClose = () => {
|
|
1726
|
+
if (store !== null) store.set({ preview: null })
|
|
1727
|
+
}
|
|
1443
1728
|
usePreviewDismiss(load, onClose)
|
|
1444
|
-
|
|
1729
|
+
if (preview === null) return null
|
|
1730
|
+
return renderFloatingWindow(load, undefined, null, null, preview.abs, preview.name, sessionId, onClose)
|
|
1731
|
+
}
|
|
1732
|
+
/** Register the overlay seat (list slot: additive, never replaces host UI). */
|
|
1733
|
+
function registerPreviewOverlay(ctx, dataStore) {
|
|
1734
|
+
void dataStore
|
|
1735
|
+
ctx.effect(
|
|
1736
|
+
() =>
|
|
1737
|
+
ctx.slots.inject('shell.overlay', () =>
|
|
1738
|
+
ctx.slots.register(
|
|
1739
|
+
{ name: 'shell.overlay', id: PREVIEW_ID, order: 100, label: () => strings.title() },
|
|
1740
|
+
FloatingPreviewOverlay,
|
|
1741
|
+
),
|
|
1742
|
+
),
|
|
1743
|
+
'dsh-file-activity: preview overlay',
|
|
1744
|
+
)
|
|
1445
1745
|
}
|
|
1446
|
-
|
|
1447
1746
|
/**
|
|
1448
|
-
* Lightweight PDF preview.
|
|
1449
|
-
*
|
|
1450
|
-
*
|
|
1451
|
-
*
|
|
1452
|
-
* from OUR media route in a native browser PDF frame, with a download
|
|
1453
|
-
* fallback in its toolbar.
|
|
1747
|
+
* Lightweight PDF preview. The recorded file often lives outside the session
|
|
1748
|
+
* workspace, and the host's own PDF route is fenced to it — so the bytes come
|
|
1749
|
+
* from the plugin's media route inside a native browser PDF frame, with a
|
|
1750
|
+
* download fallback in its toolbar.
|
|
1454
1751
|
*/
|
|
1455
1752
|
function PdfPreview({ src, download, title }) {
|
|
1456
1753
|
return createElement(
|
|
@@ -1474,102 +1771,180 @@ function PdfPreview({ src, download, title }) {
|
|
|
1474
1771
|
)
|
|
1475
1772
|
}
|
|
1476
1773
|
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
return () => {
|
|
1495
|
-
if (style.parentNode) style.parentNode.removeChild(style)
|
|
1496
|
-
}
|
|
1497
|
-
}, 'dsh-file-activity: styles')
|
|
1774
|
+
'use strict'
|
|
1775
|
+
// ── settings tab (replaces 迁移前的 settings.pluginToggles) ────
|
|
1776
|
+
//
|
|
1777
|
+
// The host renders no per-plugin toggle UI for third-party tabs, so this
|
|
1778
|
+
// plugin contributes its own tab to the Web Settings → Plugins section
|
|
1779
|
+
// ('settings.plugins.tab': a list seat declared by the settings section). The
|
|
1780
|
+
// section only exists while it is mounted, so the seat is declared with
|
|
1781
|
+
// ctx.slots.inject — the declarative form that survives HMR and late mounts
|
|
1782
|
+
// (a plain register on a not-yet-mounted seat would silently contribute
|
|
1783
|
+
// nothing).
|
|
1784
|
+
/** Auto-open preference (plugin-owned; 迁移前的插件偏好已不存在). */
|
|
1785
|
+
function autoOpenEnabled() {
|
|
1786
|
+
try {
|
|
1787
|
+
return window.localStorage.getItem(AUTO_OPEN_PREF_KEY) !== '0'
|
|
1788
|
+
} catch {
|
|
1789
|
+
return true
|
|
1790
|
+
}
|
|
1498
1791
|
}
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1792
|
+
/** Persist the auto-open preference. */
|
|
1793
|
+
function setAutoOpenEnabled(enabled) {
|
|
1794
|
+
try {
|
|
1795
|
+
window.localStorage.setItem(AUTO_OPEN_PREF_KEY, enabled ? '1' : '0')
|
|
1796
|
+
} catch {
|
|
1797
|
+
// storage unavailable (private mode): the in-memory toggle still applies
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
/** One labeled switch row. */
|
|
1801
|
+
function settingsRow(label, hint, checked, onChange) {
|
|
1802
|
+
return createElement(
|
|
1803
|
+
'label',
|
|
1804
|
+
{ className: 'dfa-set-row' },
|
|
1805
|
+
createElement(
|
|
1806
|
+
'span',
|
|
1807
|
+
{ className: 'dfa-set-text' },
|
|
1808
|
+
createElement('span', { className: 'dfa-set-label' }, label),
|
|
1809
|
+
createElement('span', { className: 'dfa-set-hint' }, hint),
|
|
1810
|
+
),
|
|
1811
|
+
createElement('input', {
|
|
1812
|
+
type: 'checkbox',
|
|
1813
|
+
className: 'dfa-set-switch',
|
|
1814
|
+
checked,
|
|
1815
|
+
onChange: (event) => onChange(event?.target?.checked === true),
|
|
1816
|
+
}),
|
|
1817
|
+
)
|
|
1818
|
+
}
|
|
1819
|
+
/** Settings panel body: the auto-open switch plus its explanatory hint. */
|
|
1820
|
+
function FileActivitySettings() {
|
|
1821
|
+
const [enabled, setEnabled] = useState(autoOpenEnabled)
|
|
1822
|
+
return createElement(
|
|
1823
|
+
'div',
|
|
1824
|
+
{ className: 'dfa-set', 'data-dfa-settings': '1' },
|
|
1825
|
+
createElement('div', { className: 'dfa-set-title' }, strings.title()),
|
|
1826
|
+
settingsRow(strings.autoOpenLabel(), strings.autoOpenHint(), enabled, (next) => {
|
|
1827
|
+
setAutoOpenEnabled(next)
|
|
1828
|
+
setEnabled(next)
|
|
1829
|
+
}),
|
|
1830
|
+
)
|
|
1831
|
+
}
|
|
1832
|
+
/** Register the settings tab into the settings section's list seat. */
|
|
1833
|
+
function registerSettingsTab(ctx) {
|
|
1834
|
+
ctx.effect(
|
|
1835
|
+
() =>
|
|
1836
|
+
ctx.slots.inject('settings.plugins.tab', () =>
|
|
1837
|
+
ctx.slots.register(
|
|
1838
|
+
{ name: 'settings.plugins.tab', id: TAB_ID, order: 60, label: () => strings.title() },
|
|
1839
|
+
FileActivitySettings,
|
|
1840
|
+
),
|
|
1841
|
+
),
|
|
1842
|
+
'dsh-file-activity: settings tab',
|
|
1843
|
+
)
|
|
1509
1844
|
}
|
|
1510
1845
|
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1846
|
+
'use strict'
|
|
1847
|
+
// ── plugin body (native sidebar extension points) ─────────────────────
|
|
1848
|
+
//
|
|
1849
|
+
// Everything this half needs comes from Cordis services the HOST provides
|
|
1850
|
+
// (issue #187 batch 2): the tab registry, the slot registry, the document
|
|
1851
|
+
// preview registry and the right-Sidebar navigation controller. There is no
|
|
1852
|
+
// third-party sidebar package in the picture anymore, so the page keeps
|
|
1853
|
+
// working on any host that ships those four services.
|
|
1854
|
+
//
|
|
1855
|
+
// The stylesheet is pure static CSS and must NOT depend on any of them:
|
|
1856
|
+
// inject it first, unconditionally. If it lived behind an early return, an HMR
|
|
1857
|
+
// rebuild or service reload could leave the already-rendered tab WITHOUT its
|
|
1858
|
+
// stylesheet — the raw white-text list you see when the CSS is gone. Each
|
|
1859
|
+
// fiber owns its own <style> element and the disposer removes only that
|
|
1860
|
+
// element, so a rebuild always keeps at least one copy.
|
|
1861
|
+
/** registerTab step one: the tab TYPE (what a file-activity page is). */
|
|
1862
|
+
function registerTabType(ctx) {
|
|
1514
1863
|
ctx.effect(
|
|
1515
1864
|
() =>
|
|
1516
|
-
|
|
1865
|
+
ctx.sidebarRightTabs.register({
|
|
1517
1866
|
id: TAB_ID,
|
|
1867
|
+
kind: TAB_KIND,
|
|
1868
|
+
// No patterns: this is a page type, opened by kind (ctx.sidebarRight.openTab).
|
|
1518
1869
|
title: () => strings.title(),
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
},
|
|
1533
|
-
],
|
|
1534
|
-
},
|
|
1535
|
-
component: (props) => createElement(FileActivityView, { ...props, dataStore }),
|
|
1870
|
+
// Guide entry (issue #266 B): the sidebar's "new tab" page is the guide
|
|
1871
|
+
// tab, and its doors come from `sidebarRightTabs.guide()` — the entries
|
|
1872
|
+
// every registered type contributes. Without one the page was reachable
|
|
1873
|
+
// ONLY through auto-open, so closing its chip made it impossible to
|
|
1874
|
+
// reopen from the UI. Picking the capsule opens this kind in the guide's
|
|
1875
|
+
// place (host GuideBody → actions.openTab(kind, { replaceTab: true })).
|
|
1876
|
+
guide: [
|
|
1877
|
+
{
|
|
1878
|
+
order: 20,
|
|
1879
|
+
title: () => strings.title(),
|
|
1880
|
+
description: () => strings.guideDescription(),
|
|
1881
|
+
},
|
|
1882
|
+
],
|
|
1536
1883
|
}),
|
|
1537
|
-
'dsh-file-activity: tab
|
|
1884
|
+
'dsh-file-activity: tab type',
|
|
1538
1885
|
)
|
|
1539
1886
|
}
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1887
|
+
/** registerTab step two: the body seat (keyed by the definition id). */
|
|
1888
|
+
function registerTabBody(ctx, dataStore) {
|
|
1889
|
+
ctx.effect(
|
|
1890
|
+
() =>
|
|
1891
|
+
ctx.slots.inject('sidebar.right.pane.tab', () =>
|
|
1892
|
+
ctx.slots.register({ name: 'sidebar.right.pane.tab', key: TAB_ID }, function (props) {
|
|
1893
|
+
const injected = Object.assign({}, props, { dataStore })
|
|
1894
|
+
return createElement(FileActivityView, injected)
|
|
1895
|
+
}),
|
|
1896
|
+
),
|
|
1897
|
+
'dsh-file-activity: tab body',
|
|
1898
|
+
)
|
|
1899
|
+
}
|
|
1900
|
+
/** registerTab step three: the chip title seat (a live, localized label). */
|
|
1901
|
+
function registerTabTitle(ctx) {
|
|
1902
|
+
ctx.effect(
|
|
1903
|
+
() =>
|
|
1904
|
+
ctx.slots.inject('sidebar.right.pane.tab.title', () =>
|
|
1905
|
+
ctx.slots.register({ name: 'sidebar.right.pane.tab.title', key: TAB_ID }, () =>
|
|
1906
|
+
createElement('span', { className: 'dfa-chip' }, strings.title()),
|
|
1907
|
+
),
|
|
1908
|
+
),
|
|
1909
|
+
'dsh-file-activity: tab title',
|
|
1910
|
+
)
|
|
1911
|
+
}
|
|
1912
|
+
/**
|
|
1913
|
+
* Run one registration, converting a throw into an observable failure.
|
|
1914
|
+
*
|
|
1915
|
+
* Swallowing is deliberate: the host throws for a taken id or a taken kind, and
|
|
1916
|
+
* letting it escape would fail the WHOLE activation (the stylesheet, the
|
|
1917
|
+
* floating preview and the settings tab would go with it). What must never
|
|
1918
|
+
* happen is a SILENT failure, so the cause is logged, marked on <html> and
|
|
1919
|
+
* rendered as a notice inside the tab body.
|
|
1920
|
+
*/
|
|
1921
|
+
function guarded(kind, register) {
|
|
1922
|
+
try {
|
|
1923
|
+
register()
|
|
1924
|
+
} catch (error) {
|
|
1925
|
+
markDegraded(kind, error)
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
exports.inject = ['slots', 'sidebarRightTabs', 'documentPreviews', 'sidebarRight']
|
|
1543
1929
|
exports.apply = function apply(ctx) {
|
|
1544
|
-
// Stylesheet first, unconditionally (HMR pitfall — see
|
|
1930
|
+
// Stylesheet first, unconditionally (HMR pitfall — see the header note).
|
|
1545
1931
|
injectStyles(ctx)
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
// 以为插件坏了——明确提示安装方式。
|
|
1551
|
-
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
|
|
1552
|
-
console.warn(
|
|
1553
|
-
'[dsh-file-activity] dsh-better-sidebar 未安装:文件活动页签无法挂载。请安装宿主插件:dsh plugin --profile web add dsh-better-sidebar dsh-file-activity',
|
|
1554
|
-
)
|
|
1555
|
-
}
|
|
1556
|
-
return
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
// Per-session data store: { bySession: { [sessionId]: { recent, counts, loading } }, preview }
|
|
1560
|
-
// Each conversation reads/writes only its own bucket, so switching
|
|
1561
|
-
// sessions never leaks another session's file activity into the view.
|
|
1932
|
+
// Failing loudly beats failing silently: a rejected registration (id taken,
|
|
1933
|
+
// kind taken, seat missing) is recorded so the user and the e2e suite can
|
|
1934
|
+
// see WHY the tab never appeared.
|
|
1935
|
+
guarded('tab', () => registerTabType(ctx))
|
|
1562
1936
|
const dataStore = createStore({ bySession: {}, preview: null })
|
|
1563
|
-
|
|
1564
|
-
|
|
1937
|
+
registerSharedStore(dataStore)
|
|
1938
|
+
registerTabBody(ctx, dataStore)
|
|
1939
|
+
registerTabTitle(ctx)
|
|
1940
|
+
registerPreviewOverlay(ctx, dataStore)
|
|
1941
|
+
registerSettingsTab(ctx)
|
|
1942
|
+
guarded('previews', () => registerDocumentPreviews(ctx))
|
|
1565
1943
|
// sidebar operations → host record route
|
|
1566
1944
|
ctx.effect(() => installFetchInterceptor(), 'dsh-file-activity: sidebar fetch observation')
|
|
1567
|
-
registerTab(ctx, dataStore)
|
|
1568
|
-
|
|
1569
1945
|
// auto-open once per session (default on)
|
|
1570
|
-
ctx.effect(() => installAutoOpen(ctx
|
|
1946
|
+
ctx.effect(() => installAutoOpen(ctx), 'dsh-file-activity: auto-open')
|
|
1571
1947
|
}
|
|
1572
|
-
|
|
1573
1948
|
// Internal functions exposed for the render-path test suite only; inert in
|
|
1574
1949
|
// the browser bundle (plain properties on the exports object).
|
|
1575
1950
|
exports.__test = {
|
|
@@ -1578,10 +1953,13 @@ exports.__test = {
|
|
|
1578
1953
|
fetchTextContent,
|
|
1579
1954
|
textUrlOf,
|
|
1580
1955
|
strings,
|
|
1956
|
+
viewerOf,
|
|
1581
1957
|
renderPreviewBody,
|
|
1582
1958
|
previewClickAction,
|
|
1583
1959
|
isInsideFloating,
|
|
1584
1960
|
closePreviewOnHidden,
|
|
1961
|
+
autoOpenEnabled,
|
|
1962
|
+
PREVIEW_EXTENSIONS,
|
|
1585
1963
|
AUTO_CLOSE_MS,
|
|
1586
1964
|
// Static stylesheet text, so the render-path suite can assert the floating
|
|
1587
1965
|
// preview body keeps its flex-fill container (issue #111).
|