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/lib/client.js CHANGED
@@ -1,29 +1,30 @@
1
1
  /**
2
2
  * dsh-file-activity — client half (browser).
3
3
  *
4
- * Extends dsh-better-sidebar with a "文件活动 / File Activity" tab:
5
- * - recent file access history (agent + sidebar operations),
6
- * - per-file create/modify/read counts flattened by folder, with multi-level
7
- * folders shown as dotted paths (a.b.c.d) and their files indented below,
8
- * - clicking any file opens a FLOATING preview that reuses the sidebar's
9
- * NATIVE viewer via `ctx.betterSidebar.matchFileViewer(path)` — its own
10
- * `component` is mounted (built-in markdown / code / image / pdf / html
11
- * renderers), so code gets syntax highlighting and markdown gets rendered
12
- * with no hand-rolled preview; clicking outside / Esc / × closes it,
13
- * - auto-opens once per session by default (toggleable in the sidebar
14
- * settings, enabled by default).
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 sidebar file operations (fs.read / fs.write /
18
- * /sidebar/file media opens), both persisted host-side; the tab polls
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 dsh-better-sidebar design language: all colors ride the
22
- * DSH semantic tokens (--dsw-alias-*), typography rides the font roles
23
- * (--dsw-font-*), motion rides --ds-*. Flat surfaces (no box-shadow), hairline
24
- * borders, 28px circular icon controls with hover fills, and 8px-radius rows
25
- * with hover fills. The stylesheet is injected once per activation and torn
26
- * down with the fiber, so HMR/disable leaves no residue.
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:recent'
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
- // ── i18n ──────────────────────────────────────────────────────────────
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
- // ── path / time formatting helpers ────────────────────────────────────
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
- // ── directory tree construction ───────────────────────────────────────
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
- // ── tiny external store ───────────────────────────────────────────────
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
- // ── data access (host routes) ─────────────────────────────────────────
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
- // ── fetch interception: sidebar file operations ───────────────────────
280
- function methodOf(init) {
281
- return (init?.method ?? 'GET').toUpperCase()
282
- }
283
-
284
- /** POST body as a plain object (non-string bodies are ignored). */
285
- function parseBody(init) {
286
- return typeof init?.body === 'string' ? JSON.parse(init.body) : {}
287
- }
288
-
289
- /** Record fs.read / fs.write POSTs observed on the sidebar API. */
290
- function recordSidebarFs(url, init) {
291
- if (url.pathname !== '/sidebar/api/fs.read' && url.pathname !== '/sidebar/api/fs.write') return
292
- if (methodOf(init) !== 'POST') return
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
- recordSidebarFs(url, init)
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
- // ── auto-open (enabled by default) ────────────────────────────────────
337
- function findTabIn(state, tabId) {
338
- const leaves = (node) => (node.kind === 'leaf' ? [node] : (node.children ?? []).flatMap(leaves))
339
- for (const node of [state?.splits, state?.bottomSplits]) {
340
- if (node === undefined || node === null) continue
341
- for (const leaf of leaves(node)) {
342
- if ((leaf.tabs ?? []).some((tab) => tab.type === tabId)) return true
343
- }
344
- }
345
- return false
346
- }
347
-
348
- /** Current sidebar snapshot, or null when the service is not ready. */
349
- function sidebarSnapshot(service) {
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 service.getSnapshot?.()
397
+ return window.sessionStorage.getItem(key) === '1'
352
398
  } catch {
353
- return null
399
+ return false
354
400
  }
355
401
  }
356
-
357
- /** The user disabled auto-open for this tab in the sidebar settings. */
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
- return Boolean(window.localStorage.getItem(AUTO_OPEN_KEY + sessionId))
405
+ window.sessionStorage.setItem(AUTO_OPEN_KEY + 'loaded', '1')
367
406
  } catch {
368
- return true
407
+ // ignore
369
408
  }
370
409
  }
371
-
372
- /** Persist the auto-opened marker for this session. */
373
- function markAutoOpened(sessionId) {
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
- window.localStorage.setItem(AUTO_OPEN_KEY + sessionId, '1')
376
- } catch {
377
- // ignore
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
- /** Open the tab once per session unless disabled in the plugin settings. */
382
- function tryAutoOpen(service, tabId) {
383
- const snapshot = sidebarSnapshot(service)
384
- if (snapshot === undefined || snapshot === null || snapshot.sessionId === undefined || snapshot.state === undefined)
385
- return
386
- const sessionId = snapshot.sessionId
387
- if (isAutoOpenDisabled(snapshot, tabId)) return
388
- if (isAutoOpenMarked(sessionId)) return
389
- if (findTabIn(snapshot.state, tabId)) {
390
- markAutoOpened(sessionId)
391
- return
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
- try {
394
- service.openTab({ type: tabId, title: strings.title(), path: '' })
395
- markAutoOpened(sessionId)
396
- } catch (error) {
397
- console.error('[dsh-file-activity] auto-open failed:', error)
447
+ attempt()
448
+ return () => {
449
+ if (autoOpenTimer !== 0) {
450
+ window.clearTimeout(autoOpenTimer)
451
+ autoOpenTimer = 0
452
+ }
398
453
  }
399
454
  }
400
455
 
401
- function installAutoOpen(ctx, tabId) {
402
- const service = ctx.betterSidebar
403
- tryAutoOpen(service, tabId)
404
- let off = () => {}
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
- off = service.subscribeState?.(() => tryAutoOpen(service, tabId)) ?? off
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
- // service may lack subscribeState on older versions
499
+ // observation only: a missing document must not break activation
409
500
  }
410
- return off
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
- // ── themed stylesheet (injected once per activation) ──────────────────
718
- // Mirrors the better-sidebar explorer surface: tight 2px 6px 8px body,
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 (better-sidebar's TextEditor for html/code/markdown, the image
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
- 想要的蓝色高亮)。页签选中态回归宿主(dsh-better-sidebar)默认样式。 */
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
- // ── row rendering helpers (recent list & stats tree) ──────────────────
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
- // ── view component ────────────────────────────────────────────────────
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({ ctx, store, scope, visible, dataStore }) {
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
- const sessionId = scope?.sessionId ?? ''
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
- const openPreview = (path) => dataStore.set({ preview: { abs: path, name: basenameOf(path) } })
1146
- const closePreview = () => dataStore.set({ preview: null })
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
- data.preview
1157
- ? createElement(FloatingPreview, {
1158
- ctx,
1159
- store,
1160
- scope,
1161
- preview: data.preview,
1162
- onClose: closePreview,
1163
- })
1164
- : null,
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
- // ── floating preview window (reuses the sidebar's native viewer) ──────
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 `.dfa-fp` class; anything else counts as "outside"
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
- const viewer = service?.matchFileViewer?.(path)
1284
- if (!viewer) {
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
- }, [path, sessionId, scope])
1557
+ }, [target.abs, target.sessionId])
1307
1558
  return load
1308
1559
  }
1309
-
1310
- /** Preview window body: loading note / error panel / viewer mount. */
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
- if (load.viewer.id === 'pdf') {
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 createElement(load.viewer.component, {
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?.target)) onClose()
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
- function FloatingPreview({ ctx, store, scope, preview, onClose }) {
1438
- const sessionId = scope?.sessionId ?? ''
1439
- const path = preview.abs
1440
- const title = preview.name
1441
- const service = ctx.betterSidebar
1442
- const load = usePreviewLoader(service, path, sessionId, scope)
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
- return renderFloatingWindow(load, ctx, store, scope, path, title, sessionId, onClose)
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. better-sidebar's built-in PdfView fetches
1449
- * `/sidebar/file` internally (it ignores any injected `mediaUrl` prop),
1450
- * and that route refuses files outside the session working directory — so
1451
- * a recorded /tmp PDF would never load. This tiny view embeds the bytes
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
- // ── plugin body ───────────────────────────────────────────────────────
1478
- /**
1479
- * The stylesheet is pure static CSS and must NOT depend on the
1480
- * betterSidebar service: inject it first, unconditionally. If it lived
1481
- * behind the `service === undefined` early return, an HMR rebuild or
1482
- * service reload could leave the already-rendered tab WITHOUT its
1483
- * stylesheet — the raw white-text list you see when the CSS is gone.
1484
- * Each fiber owns its own <style> element and the disposer removes
1485
- * only that element, so a rebuild always keeps at least one copy.
1486
- */
1487
- function injectStyles(ctx) {
1488
- ctx.effect(() => {
1489
- if (typeof document === 'undefined' || document === null || typeof document.head === 'undefined') return () => {}
1490
- const style = document.createElement('style')
1491
- style.setAttribute('data-dsh-file-activity', 'styles')
1492
- style.textContent = STYLES
1493
- document.head.appendChild(style)
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
- /** Mount probe: report client activation to the host state (synthetic
1501
- * session id, invisible in the UI — confirms the client half actually
1502
- * loaded after a page refresh). */
1503
- function mountProbe() {
1504
- void fetch('/file-activity/api/record', {
1505
- method: 'POST',
1506
- headers: { 'content-type': 'application/json' },
1507
- body: JSON.stringify({ sessionId: '__probe__', path: 'mounted', op: 'read' }),
1508
- }).catch(() => {})
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
- /** Register the tab (enabled by default in the Side card settings). */
1512
- function registerTab(ctx, dataStore) {
1513
- const service = ctx.betterSidebar
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
- service.registerTab({
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
- icon: (size) => icon.clock(size),
1520
- order: 15,
1521
- single: true,
1522
- settings: {
1523
- pluginToggles: [
1524
- {
1525
- key: 'autoOpen',
1526
- title: () => (isZh() ? '会话开始时自动打开' : 'Auto-open on session start'),
1527
- desc: () =>
1528
- isZh()
1529
- ? '每个会话首次打开时自动显示本页(可在侧边栏设置中关闭)'
1530
- : 'Opens this tab once per session by default (turn off here)',
1531
- type: 'switch',
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 registration',
1884
+ 'dsh-file-activity: tab type',
1538
1885
  )
1539
1886
  }
1540
-
1541
- exports.inject = ['betterSidebar']
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 injectStyles).
1930
+ // Stylesheet first, unconditionally (HMR pitfall — see the header note).
1545
1931
  injectStyles(ctx)
1546
- const service = ctx.betterSidebar
1547
- if (service === undefined) {
1548
- // 依赖缺失提示(issue #72 同类问题):dsh-file-activity 的 client 端
1549
- // 依赖 dsh-better-sidebar 提供侧边栏扩展点,未安装时静默返回会让用户
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
- mountProbe()
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, TAB_ID), 'dsh-file-activity: auto-open')
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).