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.
@@ -0,0 +1,111 @@
1
+ 'use strict'
2
+ // ── preview data access (routes, viewer choice, loading) ────────────────
3
+ //
4
+ // The floating window used to mount the third-party sidebar's built-in viewer,
5
+ // picked by the third-party sidebar matchFileViewer(path), and fed it the bytes its
6
+ // fetchStrategy asked for. That service is gone (issue #187 batch 2), so this
7
+ // module does the reading itself through the plugin's own routes:
8
+ //
9
+ // /file-activity/file?sessionId&path → raw bytes (image / pdf)
10
+ // /file-activity/file?sessionId&path&as=text → fs.read-shaped JSON text
11
+ //
12
+ // The route matters: file activity records files the agent touched ANYWHERE
13
+ // (scratch files in /tmp, sibling repos, ~/.dsh), while the host's file
14
+ // provider is fenced to the session workspace. The plugin route authorizes
15
+ // exactly the paths this session recorded.
16
+ /** Image suffixes the browser renders natively. */
17
+ const IMAGE_EXT = /^(svg|png|jpe?g|gif|webp|avif|bmp|ico)$/i
18
+ /** Suffixes rendered as Markdown. */
19
+ const MARKDOWN_EXT = /^(md|markdown|mdx)$/i
20
+ /** Suffixes rendered as a sandboxed HTML document (issue #266 C). */
21
+ const HTML_EXT = /^(html?|xhtml)$/i
22
+ /** Lower-case suffix of a path ('' when it has none). extOf (rows.ts) works on
23
+ * a file NAME, so the basename is sliced off first. */
24
+ function extOfPath(path) {
25
+ return extOf(path.slice(path.lastIndexOf('/') + 1))
26
+ }
27
+ /** Official UI atoms come from the host's staticModules (zero install). */
28
+ function uiAtoms() {
29
+ try {
30
+ return require('@deepseek-ai/dsh-client-ui-primitives')
31
+ } catch {
32
+ return {}
33
+ }
34
+ }
35
+ /** Resolve a possibly-relative path against the session cwd. */
36
+ function resolvePath(path, cwd) {
37
+ if (typeof path !== 'string' || path === '') return path
38
+ if (path.startsWith('/')) return path
39
+ if (typeof cwd === 'string' && cwd !== '') return `${cwd.replace(/\/+$/, '')}/${path}`
40
+ return path
41
+ }
42
+ /** Whether the fs.read API response carries a text content payload. */
43
+ function isFsReadOk(json) {
44
+ return json !== null && typeof json === 'object' && json.ok === true && typeof json.value?.content === 'string'
45
+ }
46
+ /** Error load state from an fs.read API response (or a generic message).
47
+ * Raw system errors are translated to friendly, locale-aware messages
48
+ * (issue #68): deleted files and workspace-fenced paths must never surface
49
+ * ENOENT / "is outside workspace" verbatim. */
50
+ function fsReadError(json, viewer) {
51
+ const raw = json?.error?.message ?? ''
52
+ let message
53
+ if (raw === '') message = strings.previewFailed()
54
+ else if (/ENOENT|no such file|does not exist|cannot resolve/i.test(raw)) message = strings.fileMissing()
55
+ else if (/outside workspace/i.test(raw)) message = strings.fileOutside()
56
+ else message = raw
57
+ return { status: 'error', viewer, message }
58
+ }
59
+ /** Plugin text route (fs.read-shaped JSON), or null on any failure. */
60
+ async function fetchTextContent(sessionId, path) {
61
+ try {
62
+ const response = await fetch(textUrlOf(sessionId, path))
63
+ return await response.json()
64
+ } catch {
65
+ return null
66
+ }
67
+ }
68
+ /**
69
+ * Load the recorded file's text through the plugin's own route, falling back
70
+ * to the host sidebar route when the plugin refuses the path. Whatever the
71
+ * host viewer used to do with its own fetchStrategy, this window does the
72
+ * reading itself now — the routes are the same ones the previous
73
+ * implementation used for its fsRead strategy.
74
+ */
75
+ async function loadFsReadContent(viewer, path, scope, sessionId) {
76
+ const cwd = scope === null || scope === undefined ? '' : scope.cwd
77
+ const target = resolvePath(path, cwd ?? '')
78
+ const own = await fetchTextContent(sessionId, target)
79
+ if (isFsReadOk(own)) return { status: 'ready', viewer, content: own.value.content }
80
+ try {
81
+ const response = await fetch('/sidebar/api/fs.read', {
82
+ method: 'POST',
83
+ headers: { 'content-type': 'application/json' },
84
+ body: JSON.stringify({ sessionId, path: target }),
85
+ })
86
+ const json = await response.json()
87
+ if (isFsReadOk(json)) return { status: 'ready', viewer, content: json.value.content }
88
+ return fsReadError(json, viewer)
89
+ } catch {
90
+ return fsReadError(own, viewer)
91
+ }
92
+ }
93
+ /** Which renderer a recorded path gets. */
94
+ function viewerOf(path) {
95
+ const ext = extOfPath(path)
96
+ if (IMAGE_EXT.test(ext)) return 'image'
97
+ if (ext === 'pdf') return 'pdf'
98
+ if (HTML_EXT.test(ext)) return 'html'
99
+ if (MARKDOWN_EXT.test(ext)) return 'markdown'
100
+ return 'text'
101
+ }
102
+ /** Fetch the bytes the chosen renderer needs (media URL, or text content). */
103
+ async function fetchPreviewLoad(target) {
104
+ const viewer = { id: viewerOf(target.abs) }
105
+ // image / pdf / html are served as BYTES by the plugin's own media route;
106
+ // html goes through an iframe (see HtmlBody), not through the text path.
107
+ if (viewer.id === 'image' || viewer.id === 'pdf' || viewer.id === 'html') {
108
+ return { status: 'ready', viewer, mediaUrl: mediaUrlOf(target.sessionId, target.abs) }
109
+ }
110
+ return loadFsReadContent(viewer, target.abs, null, target.sessionId)
111
+ }
@@ -0,0 +1,276 @@
1
+ 'use strict'
2
+ // ── floating preview window (own renderer, shell.overlay seat) ─────────
3
+ //
4
+ // History: this window used to mount the third-party sidebar's built-in
5
+ // viewer and let it fetch its own bytes. That service is gone (issue #187
6
+ // batch 2), so the window renders the recorded file itself:
7
+ //
8
+ // image (svg/png/jpeg/gif/webp/avif/bmp/ico) → <img src> on the plugin media route
9
+ // pdf → <iframe src> on the same route
10
+ // markdown → host MarkdownText atom
11
+ // anything else → host CodeBlock atom, else <pre>
12
+ //
13
+ // The overlay lives in the root-scoped 'shell.overlay' list seat (the host's
14
+ // own floating layer: above every column, click-through unless the entry opts
15
+ // into pointer events) and renders nothing at all while no preview is open.
16
+ // Data access (routes, viewer choice, loading) lives in preview-data.ts.
17
+ /** Milliseconds after which an error-state preview closes itself (issue #76):
18
+ * a shell that failed to load any content is useless, so it must not linger
19
+ * over the main UI until the user finds the × button. */
20
+ const AUTO_CLOSE_MS = 2500
21
+ /** Whether a pointerdown target lies inside the floating window. The window
22
+ * surface carries the .dfa-fp class; anything else counts as "outside"
23
+ * and dismisses the preview (issue #76 — click anywhere outside closes). */
24
+ function isInsideFloating(target) {
25
+ if (!target || typeof target.closest !== 'function') return false
26
+ return target.closest('.dfa-fp') !== null
27
+ }
28
+ /** Click behavior for the window surface: in the error state ANY click
29
+ * closes the shell (there is no content to interact with), otherwise the
30
+ * click is swallowed so the viewer's own interactions keep working. */
31
+ function previewClickAction(load, event) {
32
+ if (load.status === 'error') return 'close'
33
+ if (event && event.stopPropagation) event.stopPropagation()
34
+ return 'stop'
35
+ }
36
+ /** Close the floating preview when the tab goes hidden (switching tabs /
37
+ * operating the main UI), so it never lingers over the interface. */
38
+ function closePreviewOnHidden(visible, dataStore) {
39
+ if (!visible) dataStore.set({ preview: null })
40
+ }
41
+ function usePreviewLoader(target) {
42
+ const [load, setLoad] = useState({ status: 'loading', viewer: null })
43
+ useEffect(() => {
44
+ let cancelled = false
45
+ setLoad({ status: 'loading', viewer: { id: viewerOf(target.abs) } })
46
+ fetchPreviewLoad(target)
47
+ .then((next) => {
48
+ if (!cancelled) setLoad(next)
49
+ })
50
+ .catch((error) => {
51
+ if (!cancelled)
52
+ setLoad({
53
+ status: 'error',
54
+ viewer: null,
55
+ message: error instanceof Error ? error.message : String(error),
56
+ })
57
+ })
58
+ return () => {
59
+ cancelled = true
60
+ }
61
+ }, [target.abs, target.sessionId])
62
+ return load
63
+ }
64
+ /** Text body: the host's own Markdown / code atoms, with a plain <pre> fallback. */
65
+ function TextBody({ load, path }) {
66
+ const text = load.content ?? ''
67
+ const ui = uiAtoms()
68
+ if (load.viewer?.id === 'markdown' && typeof ui.MarkdownText === 'function') {
69
+ return createElement('div', { className: 'dfa-fp-md' }, createElement(ui.MarkdownText, { text }))
70
+ }
71
+ if (typeof ui.CodeBlock === 'function') {
72
+ return createElement(
73
+ 'div',
74
+ { className: 'dfa-fp-code' },
75
+ createElement(ui.CodeBlock, { code: text, language: extOfPath(path) }),
76
+ )
77
+ }
78
+ return createElement('pre', { className: 'dfa-fp-pre' }, text)
79
+ }
80
+ /** Image body: the plugin's media route renders the recorded bytes. */
81
+ function ImageBody({ load, title }) {
82
+ return createElement('img', { className: 'dfa-fp-img', src: load.mediaUrl, alt: title })
83
+ }
84
+ /**
85
+ * HTML body: a SANDBOXED iframe over the plugin's media route (issue #266 C —
86
+ * the README promised a sandboxed iframe while the code rendered the markup as
87
+ * a code block).
88
+ *
89
+ * Two independent sandbox layers, deliberately without `allow-same-origin`:
90
+ * - the iframe's `sandbox` attribute gives the document an opaque origin, so
91
+ * a previewed page cannot read the host's storage, cookies or same-origin
92
+ * APIs, and cannot reach the parent document;
93
+ * - the route answers text/html with a response-level
94
+ * `Content-Security-Policy: sandbox …` header, so the SAME protection holds
95
+ * when the URL is opened directly instead of inside this iframe.
96
+ * Scripts/forms stay enabled so interactive documents (charts, demos) work;
97
+ * navigation of the top window is not granted.
98
+ */
99
+ function HtmlBody({ load, title }) {
100
+ return createElement(
101
+ 'div',
102
+ { className: 'dfa-fp-html' },
103
+ createElement('iframe', {
104
+ className: 'dfa-fp-html-frame',
105
+ src: load.mediaUrl,
106
+ sandbox: 'allow-scripts allow-forms',
107
+ title,
108
+ }),
109
+ )
110
+ }
111
+ /** Preview window body: loading note / error panel / renderer mount. */
112
+ function renderPreviewBody(load, ctx, store, scope, path, title, sessionId) {
113
+ if (load.status === 'loading') {
114
+ return createElement('div', { className: 'dfa-fp-note' }, strings.loading())
115
+ }
116
+ if (load.status === 'error') {
117
+ return createElement(
118
+ 'div',
119
+ { className: 'dfa-fp-err' },
120
+ strings.previewFailed(),
121
+ load.message
122
+ ? createElement('div', { style: { marginTop: '6px', fontSize: '11px', opacity: 0.85 } }, load.message)
123
+ : null,
124
+ createElement('div', { style: { marginTop: '6px', fontSize: '11px', opacity: 0.7 } }, strings.autoCloseHint()),
125
+ )
126
+ }
127
+ const props = { load, path, title, sessionId }
128
+ if (load.viewer?.id === 'image') return ImageBody(props)
129
+ if (load.viewer?.id === 'html') return HtmlBody(props)
130
+ if (load.viewer?.id === 'pdf') {
131
+ const url = mediaUrlOf(sessionId, path)
132
+ return createElement(PdfPreview, { src: url, download: `${url}&download=1`, title })
133
+ }
134
+ return TextBody(props)
135
+ }
136
+ /**
137
+ * Dismissal affordances (issue #76 — the preview must never linger):
138
+ * 1. pointerdown anywhere OUTSIDE the window closes it (capture phase, so
139
+ * it fires even if another element sits above the scrim);
140
+ * 2. the scrim's own onClick (kept as a fallback);
141
+ * 3. Escape;
142
+ * 4. the × button;
143
+ * 5. an error state closes itself after AUTO_CLOSE_MS (no content to show).
144
+ */
145
+ function usePreviewDismiss(load, onClose) {
146
+ useEffect(() => {
147
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return () => {}
148
+ const handler = (event) => {
149
+ if (!isInsideFloating(event && event.target)) onClose()
150
+ }
151
+ document.addEventListener('pointerdown', handler, true)
152
+ return () => document.removeEventListener('pointerdown', handler, true)
153
+ }, [onClose])
154
+ useEffect(() => {
155
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return () => {}
156
+ const handler = (event) => {
157
+ if (event && event.key === 'Escape') onClose()
158
+ }
159
+ document.addEventListener('keydown', handler)
160
+ return () => document.removeEventListener('keydown', handler)
161
+ }, [onClose])
162
+ useEffect(() => {
163
+ if (load.status !== 'error') return undefined
164
+ if (typeof window === 'undefined' || typeof window.setTimeout !== 'function') return undefined
165
+ const timer = window.setTimeout(onClose, AUTO_CLOSE_MS)
166
+ return () => window.clearTimeout(timer)
167
+ }, [load.status, onClose])
168
+ }
169
+ /** The floating window element: head (title + hint + close) and body. */
170
+ function renderFloatingWindow(load, ctx, store, scope, path, title, sessionId, onClose) {
171
+ return createElement(
172
+ 'div',
173
+ { className: 'dfa-fp-overlay', onClick: onClose },
174
+ createElement(
175
+ 'div',
176
+ {
177
+ className: 'dfa-fp',
178
+ 'data-dfa-preview': '1',
179
+ onClick: (event) => {
180
+ if (previewClickAction(load, event) === 'close') onClose()
181
+ },
182
+ },
183
+ createElement(
184
+ 'div',
185
+ { className: 'dfa-fp-head' },
186
+ createElement('span', { className: 'dfa-fp-title' }, title),
187
+ createElement('span', { className: 'dfa-fp-hint' }, strings.clickOutsideToClose()),
188
+ createElement(
189
+ 'span',
190
+ { className: 'dfa-fp-actions' },
191
+ createElement(
192
+ 'button',
193
+ {
194
+ className: 'dfa-iconbtn',
195
+ title: strings.closePreview(),
196
+ 'aria-label': strings.closePreview(),
197
+ onClick: () => onClose(),
198
+ },
199
+ icon.close(15),
200
+ ),
201
+ ),
202
+ ),
203
+ createElement(
204
+ 'div',
205
+ { className: 'dfa-fp-body' },
206
+ renderPreviewBody(load, ctx, store, scope, path, title, sessionId),
207
+ ),
208
+ ),
209
+ )
210
+ }
211
+ /**
212
+ * The floating preview, mounted by the root-scoped 'shell.overlay' list seat
213
+ * (the host's own floating layer: above every column, click-through unless the
214
+ * entry opts into pointer events). It renders nothing at all while no preview
215
+ * is open, so the layer stays invisible.
216
+ */
217
+ function FloatingPreviewOverlay() {
218
+ const store = sharedStore()
219
+ const state = useSyncExternalStore(
220
+ store === null ? () => () => {} : store.subscribe,
221
+ store === null ? () => null : store.getSnapshot,
222
+ )
223
+ const preview = state === null || state === undefined ? null : state.preview
224
+ const sessionId = preview === null ? '' : preview.sessionId
225
+ // Hooks must run unconditionally: the loader is fed an empty target while
226
+ // the window is closed (it then never resolves a load, and nothing renders).
227
+ const target = preview ?? { abs: '', name: '', sessionId: '' }
228
+ const load = usePreviewLoader(target)
229
+ const onClose = () => {
230
+ if (store !== null) store.set({ preview: null })
231
+ }
232
+ usePreviewDismiss(load, onClose)
233
+ if (preview === null) return null
234
+ return renderFloatingWindow(load, undefined, null, null, preview.abs, preview.name, sessionId, onClose)
235
+ }
236
+ /** Register the overlay seat (list slot: additive, never replaces host UI). */
237
+ function registerPreviewOverlay(ctx, dataStore) {
238
+ void dataStore
239
+ ctx.effect(
240
+ () =>
241
+ ctx.slots.inject('shell.overlay', () =>
242
+ ctx.slots.register(
243
+ { name: 'shell.overlay', id: PREVIEW_ID, order: 100, label: () => strings.title() },
244
+ FloatingPreviewOverlay,
245
+ ),
246
+ ),
247
+ 'dsh-file-activity: preview overlay',
248
+ )
249
+ }
250
+ /**
251
+ * Lightweight PDF preview. The recorded file often lives outside the session
252
+ * workspace, and the host's own PDF route is fenced to it — so the bytes come
253
+ * from the plugin's media route inside a native browser PDF frame, with a
254
+ * download fallback in its toolbar.
255
+ */
256
+ function PdfPreview({ src, download, title }) {
257
+ return createElement(
258
+ 'div',
259
+ { className: 'dfa-pdf' },
260
+ createElement(
261
+ 'div',
262
+ { className: 'dfa-pdf-toolbar' },
263
+ createElement(
264
+ 'a',
265
+ {
266
+ className: 'dfa-pdf-download',
267
+ href: download,
268
+ download: true,
269
+ title: strings.downloadToView(),
270
+ },
271
+ strings.downloadToView(),
272
+ ),
273
+ ),
274
+ createElement('iframe', { className: 'dfa-pdf-frame', src, title }),
275
+ )
276
+ }
@@ -1,3 +1,4 @@
1
+ 'use strict'
1
2
  // ── row rendering helpers (recent list & stats tree) ──────────────────
2
3
  const opClass = (op) =>
3
4
  op === 'create'
@@ -15,7 +16,6 @@ const opLabel = (op) =>
15
16
  : op === 'delete'
16
17
  ? strings.delete()
17
18
  : strings.read()
18
-
19
19
  /** Tooltip for a stats file row: absolute path + created / last-seen times. */
20
20
  const fileTitle = (abs, firstSeen, lastSeen) => {
21
21
  const times = []
@@ -23,7 +23,6 @@ const fileTitle = (abs, firstSeen, lastSeen) => {
23
23
  if (typeof lastSeen === 'number') times.push(`${strings.lastSeen()} ${formatTime(lastSeen)}`)
24
24
  return times.length > 0 ? `${abs}\n${times.join(' · ')}` : abs
25
25
  }
26
-
27
26
  /** Count pills for a file/dir node — only actions that actually happened are
28
27
  * shown (a zero count renders no pill; all-zero nodes render no pill group,
29
28
  * keeping untouched files visually quiet). */
@@ -42,7 +41,6 @@ const countPills = (node) => {
42
41
  if (pills.length === 0) return null
43
42
  return createElement('span', { className: 'dfa-counts', style: { paddingLeft: '6px' } }, ...pills)
44
43
  }
45
-
46
44
  /** Extension of a file name (lowercase, no leading dot); '' when none.
47
45
  * Dotfiles map to their whole name ('.gitignore' → 'gitignore') so the
48
46
  * badge table can cover them; 'notes.' still yields ''. */
@@ -52,14 +50,12 @@ const extOf = (name) => {
52
50
  if (dot === 0) return name.slice(1).toLowerCase()
53
51
  return ''
54
52
  }
55
-
56
53
  /** Extension-less but common build files → their badge key. */
57
54
  const NAME_BADGES = {
58
55
  makefile: 'makefile',
59
56
  dockerfile: 'dockerfile',
60
57
  'cmakelists.txt': 'cmake',
61
58
  }
62
-
63
59
  /** Badge key for a file name: basename match first, then extension. */
64
60
  const badgeKeyOf = (name) => {
65
61
  const base = name.toLowerCase()
@@ -67,7 +63,6 @@ const badgeKeyOf = (name) => {
67
63
  if (named !== undefined) return named
68
64
  return extOf(name)
69
65
  }
70
-
71
66
  /** A stats-tree file row: icon + name + count pills + relative time. */
72
67
  const fileRow = (file, depth, onOpen) =>
73
68
  createElement(
@@ -84,7 +79,6 @@ const fileRow = (file, depth, onOpen) =>
84
79
  countPills(file),
85
80
  file.lastSeen ? createElement('span', { className: 'dfa-time' }, formatRelative(file.lastSeen)) : null,
86
81
  )
87
-
88
82
  /** One stats-tree node: file rows render inline, dirs toggle collapse. */
89
83
  function renderTreeNode(node, depth, collapsedDirs, onToggleDir, onOpen) {
90
84
  if (node.type === 'file') return fileRow(node, depth, onOpen)
@@ -110,7 +104,6 @@ function renderTreeNode(node, depth, collapsedDirs, onToggleDir, onOpen) {
110
104
  : node.children.map((child) => renderTreeNode(child, depth + 1, collapsedDirs, onToggleDir, onOpen)),
111
105
  )
112
106
  }
113
-
114
107
  /** A recent-list row: op badge + basename + relative time. */
115
108
  const recentEntry = (entry, onOpen) =>
116
109
  createElement(
@@ -125,7 +118,6 @@ const recentEntry = (entry, onOpen) =>
125
118
  createElement('span', { className: 'dfa-row-name' }, basenameOf(entry.path)),
126
119
  createElement('span', { className: 'dfa-time' }, formatRelative(entry.time)),
127
120
  )
128
-
129
121
  /** Toggle a key in a Set (directory collapse state). */
130
122
  function toggleInSet(set, key) {
131
123
  const next = new Set(set)
@@ -133,7 +125,6 @@ function toggleInSet(set, key) {
133
125
  else next.add(key)
134
126
  return next
135
127
  }
136
-
137
128
  /** Clear the current session's records host-side and reset its bucket. */
138
129
  function clearSessionData(dataStore, sessionId) {
139
130
  if (!window.confirm(strings.clearConfirm())) return
@@ -146,7 +137,6 @@ function clearSessionData(dataStore, sessionId) {
146
137
  },
147
138
  })
148
139
  }
149
-
150
140
  /** Manual refresh: fetch stats + the authoritative cwd for this session. */
151
141
  function refreshSessionData(dataStore, sessionId, setCwd, setError) {
152
142
  if (sessionId === '') return
@@ -0,0 +1,71 @@
1
+ 'use strict'
2
+ // ── settings tab (replaces 迁移前的 settings.pluginToggles) ────
3
+ //
4
+ // The host renders no per-plugin toggle UI for third-party tabs, so this
5
+ // plugin contributes its own tab to the Web Settings → Plugins section
6
+ // ('settings.plugins.tab': a list seat declared by the settings section). The
7
+ // section only exists while it is mounted, so the seat is declared with
8
+ // ctx.slots.inject — the declarative form that survives HMR and late mounts
9
+ // (a plain register on a not-yet-mounted seat would silently contribute
10
+ // nothing).
11
+ /** Auto-open preference (plugin-owned; 迁移前的插件偏好已不存在). */
12
+ function autoOpenEnabled() {
13
+ try {
14
+ return window.localStorage.getItem(AUTO_OPEN_PREF_KEY) !== '0'
15
+ } catch {
16
+ return true
17
+ }
18
+ }
19
+ /** Persist the auto-open preference. */
20
+ function setAutoOpenEnabled(enabled) {
21
+ try {
22
+ window.localStorage.setItem(AUTO_OPEN_PREF_KEY, enabled ? '1' : '0')
23
+ } catch {
24
+ // storage unavailable (private mode): the in-memory toggle still applies
25
+ }
26
+ }
27
+ /** One labeled switch row. */
28
+ function settingsRow(label, hint, checked, onChange) {
29
+ return createElement(
30
+ 'label',
31
+ { className: 'dfa-set-row' },
32
+ createElement(
33
+ 'span',
34
+ { className: 'dfa-set-text' },
35
+ createElement('span', { className: 'dfa-set-label' }, label),
36
+ createElement('span', { className: 'dfa-set-hint' }, hint),
37
+ ),
38
+ createElement('input', {
39
+ type: 'checkbox',
40
+ className: 'dfa-set-switch',
41
+ checked,
42
+ onChange: (event) => onChange(event?.target?.checked === true),
43
+ }),
44
+ )
45
+ }
46
+ /** Settings panel body: the auto-open switch plus its explanatory hint. */
47
+ function FileActivitySettings() {
48
+ const [enabled, setEnabled] = useState(autoOpenEnabled)
49
+ return createElement(
50
+ 'div',
51
+ { className: 'dfa-set', 'data-dfa-settings': '1' },
52
+ createElement('div', { className: 'dfa-set-title' }, strings.title()),
53
+ settingsRow(strings.autoOpenLabel(), strings.autoOpenHint(), enabled, (next) => {
54
+ setAutoOpenEnabled(next)
55
+ setEnabled(next)
56
+ }),
57
+ )
58
+ }
59
+ /** Register the settings tab into the settings section's list seat. */
60
+ function registerSettingsTab(ctx) {
61
+ ctx.effect(
62
+ () =>
63
+ ctx.slots.inject('settings.plugins.tab', () =>
64
+ ctx.slots.register(
65
+ { name: 'settings.plugins.tab', id: TAB_ID, order: 60, label: () => strings.title() },
66
+ FileActivitySettings,
67
+ ),
68
+ ),
69
+ 'dsh-file-activity: settings tab',
70
+ )
71
+ }
@@ -1,3 +1,4 @@
1
+ 'use strict'
1
2
  // ── tiny external store ───────────────────────────────────────────────
2
3
  function createStore(initial) {
3
4
  let state = initial
@@ -14,3 +15,21 @@ function createStore(initial) {
14
15
  },
15
16
  }
16
17
  }
18
+ // ── shared store slot ─────────────────────────────────────────────────
19
+ //
20
+ // The tab body renders inside a session-scoped seat while the floating preview
21
+ // renders inside the root-scoped 'shell.overlay' seat, so the store the two
22
+ // halves share cannot travel as a prop from one to the other. One module-level
23
+ // reference is enough: the overlay seat is mounted for the plugin's whole
24
+ // lifetime and only ever reads the CURRENT store, and a rebuild replaces the
25
+ // reference before the new overlay renders.
26
+ /** The store the overlay seat reads; set once per activation. */
27
+ let sharedDataStore = null
28
+ /** Publish the activation's store for the root-scoped overlay seat. */
29
+ function registerSharedStore(store) {
30
+ sharedDataStore = store
31
+ }
32
+ /** The activation's store, or null before apply() ran. */
33
+ function sharedStore() {
34
+ return sharedDataStore
35
+ }
@@ -1,7 +1,27 @@
1
+ 'use strict'
1
2
  // ── themed stylesheet (injected once per activation) ──────────────────
2
- // Mirrors the better-sidebar explorer surface: tight 2px 6px 8px body,
3
+ // Mirrors the host explorer surface: tight 2px 6px 8px body,
3
4
  // 30px rows, box-sizing border-box indentation, folder rows use the
4
5
  // strong type face to read as directories, files stay regular.
6
+ /**
7
+ * Inject this activation's stylesheet into the document head exactly once per
8
+ * fiber. Static CSS only: it must not sit behind any service check, or an HMR
9
+ * rebuild / service reload can leave an already-rendered tab unstyled. The
10
+ * disposer removes only this fiber's own <style> element, so a rebuild always
11
+ * keeps at least one copy.
12
+ */
13
+ function injectStyles(ctx) {
14
+ ctx.effect(() => {
15
+ if (typeof document === 'undefined' || document === null || typeof document.head === 'undefined') return () => {}
16
+ const style = document.createElement('style')
17
+ style.setAttribute('data-dsh-file-activity', 'styles')
18
+ style.textContent = STYLES
19
+ document.head.appendChild(style)
20
+ return () => {
21
+ if (style.parentNode) style.parentNode.removeChild(style)
22
+ }
23
+ }, 'dsh-file-activity: styles')
24
+ }
5
25
  const STYLES = `
6
26
  .dfa { display:flex; flex-direction:column; height:100%; overflow-y:auto; overflow-x:hidden;
7
27
  padding:2px 6px 8px; gap:2px; font:var(--dsw-font-s-14); color:var(--dsw-alias-label-primary); }
@@ -65,7 +85,7 @@ const STYLES = `
65
85
  .dfa-fp-hint { flex:none; font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-tertiary); opacity:0.8; }
66
86
  .dfa-fp-actions { display:flex; align-items:center; gap:2px; flex:none; }
67
87
  /* issue #111: the preview body is a FLEX COLUMN so the mounted viewer
68
- component (better-sidebar's TextEditor for html/code/markdown, the image
88
+ component (the host's TextEditor for html/code/markdown, the image
69
89
  wrap, and the pdf frame) actually fills the window. Their roots size via
70
90
  flex:1 (html iframe .editorHtml, .editorCm, .editorMd, .editorImageWrap,
71
91
  .dfa-pdf), which is IGNORED in a block context — so the HTML iframe, which
@@ -86,5 +106,31 @@ const STYLES = `
86
106
  @keyframes dfa-row-in { from { opacity:0; transform:translateY(1px); } to { opacity:1; transform:none; } }
87
107
  /* issue #60: 移除 #25 的侧边栏页签选中态品牌蓝覆盖([class*="tab"][class*=
88
108
  "tabActive"] 全局子串选择器误伤宿主对话/工作区 tab 选中态,出现用户不
89
- 想要的蓝色高亮)。页签选中态回归宿主(dsh-better-sidebar)默认样式。 */
109
+ 想要的蓝色高亮)。页签选中态回归宿主原生样式(issue #187 批 2:原生
110
+ dockkit 页签自己负责选中态,本插件不再覆盖)。 */
111
+ /* issue #187 批 2:页签 chip 由本插件的 title 席位渲染(原生 chip 无图标
112
+ API),因此标题连同图标一起画在这里。 */
113
+ .dfa-chip { display:inline-flex; align-items:center; gap:4px; min-width:0; }
114
+ /* 扩展点注册失败的显式提示(绝不静默失效)。 */
115
+ .dfa-degraded { margin:6px 2px; padding:6px 8px; border-radius:6px;
116
+ border:1px solid var(--dsw-alias-state-error-primary); background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); }
117
+ .dfa-degraded-title { font:var(--dsw-font-xs-13); color:var(--dsw-alias-state-error-primary); }
118
+ .dfa-degraded-hint { margin-top:2px; font:var(--dsw-font-xxs-12); opacity:.8; }
119
+ /* 设置页(settings.plugins.tab):开关行。 */
120
+ .dfa-set { display:flex; flex-direction:column; gap:10px; padding:10px 2px; }
121
+ .dfa-set-title { font:var(--dsw-font-sm-14); }
122
+ .dfa-set-row { display:flex; align-items:center; justify-content:space-between; gap:12px; cursor:pointer; }
123
+ .dfa-set-text { display:flex; flex-direction:column; gap:2px; min-width:0; }
124
+ .dfa-set-label { font:var(--dsw-font-xs-13); }
125
+ .dfa-set-hint { font:var(--dsw-font-xxs-12); opacity:.75; }
126
+ .dfa-set-switch { flex:none; width:16px; height:16px; accent-color:var(--dsw-alias-accent); cursor:pointer; }
127
+ /* 浮窗正文:Markdown / 代码 / 纯文本 / 图片四种渲染体。 */
128
+ .dfa-fp-md { height:100%; overflow:auto; padding:2px 4px; }
129
+ .dfa-fp-code { height:100%; overflow:auto; }
130
+ .dfa-fp-pre { margin:0; padding:8px 10px; overflow:auto; height:100%; font:var(--dsw-font-xxs-12);
131
+ white-space:pre-wrap; word-break:break-word; }
132
+ .dfa-fp-img { display:block; margin:auto; max-width:100%; max-height:100%; object-fit:contain; }
133
+ /* HTML 预览(issue #266 C):沙箱 iframe 撑满浮窗主体并内部滚动(与 PDF 一致)。 */
134
+ .dfa-fp-html { display:flex; flex-direction:column; width:100%; height:100%; }
135
+ .dfa-fp-html-frame { flex:1; min-height:0; width:100%; border:none; border-radius:6px; background:#fff; }
90
136
  `
@@ -1,3 +1,4 @@
1
+ 'use strict'
1
2
  // ── directory tree construction ───────────────────────────────────────
2
3
  /**
3
4
  * Collapse chain directories: a directory whose only child is another
@@ -18,7 +19,6 @@ function compressChains(node, isRoot) {
18
19
  node.compressed = true
19
20
  }
20
21
  }
21
-
22
22
  /**
23
23
  * Sort a directory node: directories first (alphabetically), then files
24
24
  * (by total activity, then name); recurse into directories.
@@ -35,7 +35,6 @@ function sortNode(node) {
35
35
  if (child.type === 'dir') sortNode(child)
36
36
  }
37
37
  }
38
-
39
38
  /**
40
39
  * Build a nested directory tree from per-file counts, keyed by the file's
41
40
  * absolute path. Every directory node aggregates its subtree counters and