@lijian-ui/dsh-file-manager 0.2.3 → 0.2.4

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.
Files changed (96) hide show
  1. package/LICENSE +38 -38
  2. package/README.i18n.yaml +6 -6
  3. package/README.md +66 -66
  4. package/README.zh.md +66 -66
  5. package/cordis.patch.yml +13 -13
  6. package/lib/client.js +27305 -270
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +474 -42
  9. package/lib/tsconfig.client.tsbuildinfo +1 -1
  10. package/lib/tsconfig.host.tsbuildinfo +1 -1
  11. package/lib/types/client/file-source.d.ts +10 -0
  12. package/lib/types/client/file-source.d.ts.map +1 -1
  13. package/lib/types/client/fileType.d.ts +19 -4
  14. package/lib/types/client/fileType.d.ts.map +1 -1
  15. package/lib/types/client/index.d.ts.map +1 -1
  16. package/lib/types/client/locales.d.ts +3 -0
  17. package/lib/types/client/locales.d.ts.map +1 -1
  18. package/lib/types/client/mount.d.ts +1 -1
  19. package/lib/types/client/mount.d.ts.map +1 -1
  20. package/lib/types/client/preview/PreviewPanel.d.ts +2 -1
  21. package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -1
  22. package/lib/types/client/preview/PreviewToolbar.d.ts +3 -1
  23. package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -1
  24. package/lib/types/client/preview/content.d.ts +3 -1
  25. package/lib/types/client/preview/content.d.ts.map +1 -1
  26. package/lib/types/client/preview/office.d.ts +23 -0
  27. package/lib/types/client/preview/office.d.ts.map +1 -0
  28. package/lib/types/client/reference.d.ts +17 -0
  29. package/lib/types/client/reference.d.ts.map +1 -1
  30. package/lib/types/client/store.d.ts.map +1 -1
  31. package/lib/types/host/fs-service.d.ts.map +1 -1
  32. package/lib/types/host/office-preview.d.ts +13 -0
  33. package/lib/types/host/office-preview.d.ts.map +1 -0
  34. package/lib/types/host/routes.d.ts.map +1 -1
  35. package/lib/types/index.d.ts.map +1 -1
  36. package/package.json +19 -4
  37. package/src/client/FileManagerSettingsCard.tsx +117 -117
  38. package/src/client/PluginSettingsCard.tsx +337 -337
  39. package/src/client/chat/file-ref.ts +144 -144
  40. package/src/client/chat/mermaid-chat.tsx +102 -102
  41. package/src/client/chat/placeholder-hint.tsx +46 -46
  42. package/src/client/components/ExplorerPanel.tsx +534 -534
  43. package/src/client/components/FileIcon.tsx +46 -46
  44. package/src/client/components/ScmPanel.tsx +480 -480
  45. package/src/client/components/a11y.ts +20 -20
  46. package/src/client/components/icons.tsx +274 -274
  47. package/src/client/components/overlay.tsx +225 -225
  48. package/src/client/drag/DragFileInlay.tsx +89 -89
  49. package/src/client/drag/file-drag.ts +67 -67
  50. package/src/client/drag.ts +154 -154
  51. package/src/client/file-source.ts +34 -0
  52. package/src/client/fileType.ts +32 -5
  53. package/src/client/hooks/useResizableSplit.ts +91 -91
  54. package/src/client/hooks/useStore.ts +15 -15
  55. package/src/client/index.ts +42 -3
  56. package/src/client/layout.ts +523 -523
  57. package/src/client/locales.ts +6 -0
  58. package/src/client/maximize.ts +34 -34
  59. package/src/client/mount.tsx +2 -1
  60. package/src/client/persist.ts +194 -194
  61. package/src/client/picker/FilePickerModal.tsx +526 -526
  62. package/src/client/picker/file-picker.ts +86 -86
  63. package/src/client/preview/PreviewPanel.tsx +7 -1
  64. package/src/client/preview/PreviewTabs.tsx +159 -159
  65. package/src/client/preview/PreviewToolbar.tsx +3 -2
  66. package/src/client/preview/content.tsx +7 -2
  67. package/src/client/preview/markdown.ts +351 -351
  68. package/src/client/preview/mermaid.ts +274 -274
  69. package/src/client/preview/office.tsx +321 -0
  70. package/src/client/reference.ts +49 -0
  71. package/src/client/settings-card.module.css +316 -316
  72. package/src/client/settings-form.ts +451 -451
  73. package/src/client/store.ts +17 -12
  74. package/src/client/styles/drag.module.css +30 -30
  75. package/src/client/styles/explorer.module.css +371 -371
  76. package/src/client/styles/picker.module.css +344 -344
  77. package/src/client/styles/preview.module.css +3 -1
  78. package/src/client/styles/scm.module.css +380 -380
  79. package/src/client/styles/tokens.module.css +376 -371
  80. package/src/core/types.ts +162 -162
  81. package/src/host/fs-service.ts +7 -1
  82. package/src/host/gate.ts +73 -73
  83. package/src/host/git-runner.ts +116 -116
  84. package/src/host/git-service.ts +395 -395
  85. package/src/host/loopback.ts +63 -63
  86. package/src/host/office-preview.ts +399 -0
  87. package/src/host/poll-guard.ts +109 -109
  88. package/src/host/routes.ts +72 -1
  89. package/src/index.ts +3 -1
  90. package/src/mount-once.ts +48 -48
  91. package/lib/types/client/chat/AttachedFilesDock.d.ts +0 -51
  92. package/lib/types/client/chat/AttachedFilesDock.d.ts.map +0 -1
  93. package/lib/types/client/floating.d.ts +0 -27
  94. package/lib/types/client/floating.d.ts.map +0 -1
  95. package/lib/types/client/mention.d.ts +0 -44
  96. package/lib/types/client/mention.d.ts.map +0 -1
@@ -1,63 +1,63 @@
1
- // Generated by scripts/sync-shared.mjs from shared/host/loopback.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
- /**
3
- * Loopback trust fence shared by the host route families: socket address,
4
- * Host header, and browser same-origin markers. Packages receive this file as
5
- * a generated copy via scripts/sync-shared.mjs; edit the shared source and
6
- * re-run the sync instead of editing a copy.
7
- *
8
- * Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
9
- * remote-web-ui gate), localhost hostnames, plus the browser same-origin
10
- * markers (sec-fetch-site and Origin) for the request-level fence.
11
- * @module dsh-web-ui-shared/host/loopback
12
- */
13
-
14
- import type { IncomingMessage } from 'node:http'
15
-
16
- /** IPv4 127/8 predicate (four decimal octets, first == 127). */
17
- export function isIPv4Loopback(v4: string): boolean {
18
- const parts = v4.split('.')
19
- return parts.length === 4
20
- && parts[0] === '127'
21
- && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
22
- }
23
-
24
- /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
25
- export function isLoopbackAddress(address: string | undefined): boolean {
26
- if (address === undefined) return false
27
- const normalized = address.toLowerCase()
28
- if (normalized === '::1') return true
29
- if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
30
- return isIPv4Loopback(normalized)
31
- }
32
-
33
- /** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
34
- export function isLoopbackHostname(hostname: string): boolean {
35
- if (hostname === 'localhost' || hostname === '[::1]') return true
36
- return isIPv4Loopback(hostname)
37
- }
38
-
39
- /**
40
- * Request-level trust fence: a loopback socket address AND a loopback Host
41
- * header, plus browser same-origin markers. The socket address is
42
- * authoritative; X-Forwarded-For is never trusted.
43
- */
44
- export function isLoopbackRequest(request: IncomingMessage): boolean {
45
- if (!isLoopbackAddress(request.socket.remoteAddress)) return false
46
- const host = request.headers.host
47
- if (typeof host !== 'string') return false
48
- let hostUrl: URL
49
- try {
50
- hostUrl = new URL('http://' + host)
51
- } catch {
52
- return false
53
- }
54
- if (!isLoopbackHostname(hostUrl.hostname)) return false
55
- if (request.headers['sec-fetch-site'] === 'cross-site') return false
56
- const origin = request.headers.origin
57
- if (origin === undefined) return true
58
- try {
59
- return new URL(origin).host === hostUrl.host
60
- } catch {
61
- return false
62
- }
63
- }
1
+ // Generated by scripts/sync-shared.mjs from shared/host/loopback.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Loopback trust fence shared by the host route families: socket address,
4
+ * Host header, and browser same-origin markers. Packages receive this file as
5
+ * a generated copy via scripts/sync-shared.mjs; edit the shared source and
6
+ * re-run the sync instead of editing a copy.
7
+ *
8
+ * Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
9
+ * remote-web-ui gate), localhost hostnames, plus the browser same-origin
10
+ * markers (sec-fetch-site and Origin) for the request-level fence.
11
+ * @module dsh-web-ui-shared/host/loopback
12
+ */
13
+
14
+ import type { IncomingMessage } from 'node:http'
15
+
16
+ /** IPv4 127/8 predicate (four decimal octets, first == 127). */
17
+ export function isIPv4Loopback(v4: string): boolean {
18
+ const parts = v4.split('.')
19
+ return parts.length === 4
20
+ && parts[0] === '127'
21
+ && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
22
+ }
23
+
24
+ /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
25
+ export function isLoopbackAddress(address: string | undefined): boolean {
26
+ if (address === undefined) return false
27
+ const normalized = address.toLowerCase()
28
+ if (normalized === '::1') return true
29
+ if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
30
+ return isIPv4Loopback(normalized)
31
+ }
32
+
33
+ /** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
34
+ export function isLoopbackHostname(hostname: string): boolean {
35
+ if (hostname === 'localhost' || hostname === '[::1]') return true
36
+ return isIPv4Loopback(hostname)
37
+ }
38
+
39
+ /**
40
+ * Request-level trust fence: a loopback socket address AND a loopback Host
41
+ * header, plus browser same-origin markers. The socket address is
42
+ * authoritative; X-Forwarded-For is never trusted.
43
+ */
44
+ export function isLoopbackRequest(request: IncomingMessage): boolean {
45
+ if (!isLoopbackAddress(request.socket.remoteAddress)) return false
46
+ const host = request.headers.host
47
+ if (typeof host !== 'string') return false
48
+ let hostUrl: URL
49
+ try {
50
+ hostUrl = new URL('http://' + host)
51
+ } catch {
52
+ return false
53
+ }
54
+ if (!isLoopbackHostname(hostUrl.hostname)) return false
55
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
56
+ const origin = request.headers.origin
57
+ if (origin === undefined) return true
58
+ try {
59
+ return new URL(origin).host === hostUrl.host
60
+ } catch {
61
+ return false
62
+ }
63
+ }
@@ -0,0 +1,399 @@
1
+ /**
2
+ * /filemgr/office-preview: render Office documents (docx/pptx) to HTML via
3
+ * the installed officecli binary (server-side rendering with Microsoft Open XML SDK).
4
+ * The result is a self-contained HTML with base64 images, suitable for iframe embedding.
5
+ *
6
+ * This provides higher fidelity rendering than pure JS client-side libraries for
7
+ * complex PPTX documents (charts, 3D models, animations, advanced formatting).
8
+ * @module dsh-filemgr/host/office-preview
9
+ */
10
+
11
+ import type { IncomingMessage, ServerResponse } from 'node:http'
12
+ import { stat } from 'node:fs/promises'
13
+ import { existsSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+ import type { Context } from '@deepseek-ai/cordis'
17
+ import type { WebServer } from '@deepseek-ai/dsh-host-webserver'
18
+ import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
19
+ import { isLoopbackRequest } from './loopback.ts'
20
+ import type { PanelError } from '../core/types.ts'
21
+
22
+ const OK = (value: unknown): string => JSON.stringify({ ok: true, value })
23
+ const FAIL = (error: PanelError): string => JSON.stringify({ ok: false, error })
24
+
25
+ /**
26
+ * Check if officecli is available.
27
+ * In production (packaged) the binary is bundled via electron-builder extraResources
28
+ * and injected into PATH by dsh-process.ts addOfficecliPath().
29
+ * In development we check the vendor/officecli/ directory first, then fall back to PATH.
30
+ * Cached to avoid repeated probing.
31
+ */
32
+ let officecliAvailable: boolean | null = null
33
+ let officecliPath: string | null = null
34
+
35
+ function resolveBundledOfficecli(): string | null {
36
+ const isWin = process.platform === 'win32'
37
+ const binName = isWin ? 'officecli.exe' : 'officecli'
38
+ const os = process.platform === 'win32' ? 'win' : process.platform === 'darwin' ? 'mac' : 'linux'
39
+ const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
40
+
41
+ // Check vendor/officecli/<os>-<arch>/ (development mode)
42
+ // lib/index.js is at extensions/dsh-file-manager/lib/index.js → project root is ../../..
43
+ try {
44
+ const dirname = fileURLToPath(new URL('.', import.meta.url))
45
+ const vendorPath = join(dirname, '..', '..', '..', 'vendor', 'officecli', `${os}-${arch}`, binName)
46
+ if (existsSync(vendorPath)) return vendorPath
47
+ } catch {
48
+ // import.meta.url not available (rare edge case)
49
+ }
50
+
51
+ return null
52
+ }
53
+
54
+ async function probeOfficecli(ctx: Context): Promise<boolean> {
55
+ if (officecliAvailable !== null) return officecliAvailable
56
+
57
+ // 1. Check bundled path first (works in both dev and production)
58
+ const bundled = resolveBundledOfficecli()
59
+ if (bundled !== null) {
60
+ officecliPath = bundled
61
+ officecliAvailable = true
62
+ ctx.logger.info(`[dsh-filemgr] officecli found at ${bundled}, OfficeCLI preview enabled`)
63
+ return true
64
+ }
65
+
66
+ // 2. Fall back to PATH
67
+ const subprocess = ctx.get('subprocess') as SubprocessRuntime
68
+ try {
69
+ const handle = subprocess.spawn({
70
+ argv: ['officecli', '--version'],
71
+ cwd: process.cwd(),
72
+ stdio: {
73
+ stdin: 'ignore',
74
+ stdout: { maxBytes: 1024 },
75
+ stderr: { maxBytes: 1024 },
76
+ },
77
+ graceMs: 5000,
78
+ })
79
+ const outcome = await handle.done
80
+ officecliAvailable = outcome.exitCode === 0
81
+ if (officecliAvailable) {
82
+ officecliPath = 'officecli'
83
+ ctx.logger.info('[dsh-filemgr] officecli found on PATH, OfficeCLI preview enabled')
84
+ } else {
85
+ ctx.logger.warn('[dsh-filemgr] officecli not available, OfficeCLI preview disabled')
86
+ }
87
+ } catch {
88
+ officecliAvailable = false
89
+ ctx.logger.warn('[dsh-filemgr] officecli probe failed, OfficeCLI preview disabled')
90
+ }
91
+ return officecliAvailable
92
+ }
93
+
94
+ /** Write JSON response. */
95
+ function json(res: ServerResponse, status: number, body: string): void {
96
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-cache' })
97
+ res.end(body)
98
+ }
99
+
100
+ /** Write HTML response. */
101
+ function html(res: ServerResponse, body: string): void {
102
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
103
+ res.end(body)
104
+ }
105
+
106
+ /**
107
+ * Inject a bootstrap that turns OfficeCLI's full-document HTML into a single-
108
+ * slide browse view that KEEPS the thumbnail sidebar. OfficeCLI ships a
109
+ * fullscreen engine (`enterFullscreen()` + ←/→/space + thumbnail jumps) but its
110
+ * `.fullscreen` CSS hides the sidebar (pure presentation mode) — so we:
111
+ * 1. auto-enter fullscreen after first paint (single page at a time), and
112
+ * 2. override the fullscreen CSS to restore the sidebar and shrink `.main`
113
+ * so the thumbnail column stays clickable for page jumps.
114
+ */
115
+ /**
116
+ * Injected frame/patch add-ons on top of the raw OfficeCLI HTML.
117
+ *
118
+ * OfficeCLI colours its chrome (body, sidebar, borders, counter) with hard-coded
119
+ * dark values and does not follow the dsh light/dark theme. The preview is
120
+ * rendered inside an isolated blob iframe, so the outer document's
121
+ * `body[data-ds-dark-theme]` token is NOT visible here — the host sends the
122
+ * current theme via postMessage and we react by toggling a data attribute that
123
+ * this stylesheet consumes.
124
+ *
125
+ * The slide artwork itself (`.slide`, `.thumb-slide`, `--slide-*`) is authored
126
+ * by the deck and intentionally left untouched.
127
+ */
128
+ function injectSlideShowMode(htmlContent: string): string {
129
+ const patch = `
130
+ <style data-dsh-officecli-fullscreen>
131
+ /* Restore the thumbnail sidebar in single-slide (fullscreen) mode and give the
132
+ slide stage a wider initial viewport by narrowing the sidebar. */
133
+ body.fullscreen .sidebar { display: flex; width: 140px; min-width: 140px; }
134
+ body.fullscreen .main { width: calc(100vw - 140px); }
135
+ body.fullscreen .slide-container { width: 100%; }
136
+ /* Scale the single slide down so it fits within the stage without being clipped.
137
+ Using zoom (not transform) because zoom changes both rendering AND layout size,
138
+ so the parent container adapts naturally. transform:scale() only changes visual
139
+ size but keeps the original layout footprint, causing overflow clipping. */
140
+ body.fullscreen .slide {
141
+ zoom: 0.85;
142
+ }
143
+
144
+ /* Light-theme chrome (host sends theme via postMessage; default stays dark). */
145
+ body[data-dsh-theme="light"] { background: #f7f7f8; }
146
+ body[data-dsh-theme="light"] .sidebar {
147
+ background: #eef0f3;
148
+ border-right: 1px solid rgba(0,0,0,0.09);
149
+ }
150
+ body[data-dsh-theme="light"] .sidebar-title,
151
+ body[data-dsh-theme="light"] .thumb-num,
152
+ body[data-dsh-theme="light"] .sidebar-toggle { color: #5f6368; }
153
+ body[data-dsh-theme="light"] .thumb:hover { border-color: #b9bdc6; }
154
+ body[data-dsh-theme="light"] .page-counter { background: rgba(0,0,0,0.55); color: #f1f3f4; }
155
+ </style>
156
+ <script data-dsh-officecli-fullscreen>
157
+ (function () {
158
+ // --- single-slide browsing mode (F) ---
159
+ function boot() {
160
+ // Only decks carry the fullscreen machinery; skip docs/sheets.
161
+ if (!document.querySelector('.main .slide-container')) return;
162
+ if (document.body.classList.contains('fullscreen')) return;
163
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'F', code: 'KeyF', bubbles: true }));
164
+ }
165
+ function onReady(fn) {
166
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true });
167
+ else fn();
168
+ }
169
+ onReady(boot);
170
+
171
+ // --- follow the host light/dark theme via postMessage (blob iframe isolation) ---
172
+ window.addEventListener('message', function (event) {
173
+ if (!event.data || event.data.source !== 'dsh-filemgr' || event.data.type !== 'dsh-theme') return;
174
+ document.body.setAttribute('data-dsh-theme', event.data.dark ? 'dark' : 'light');
175
+ });
176
+ })();
177
+ </script>
178
+ <style data-dsh-officecli-sheet>
179
+ .dsh-sheet-selected { outline: 1px solid #2e7df6 !important; outline-offset: -1px; background: rgba(46, 125, 246, 0.12) !important; }
180
+ </style>
181
+ <script data-dsh-officecli-sheet-select>
182
+ (function () {
183
+ // Excel selection -> host reference. OfficeCLI annotates every cell with
184
+ // data-path="/<sheet>/<A1>" (column headers use col[A], row headers row[1]).
185
+ // Some renderers set user-select:none, so a native text selection cannot span
186
+ // cells; instead we track a pointer drag ourselves (cell under pointerdown ->
187
+ // cell under the pointer), highlight the bounding rectangle, and postMessage
188
+ // the sheet + A1 range up. The host turns that into a "引用到对话" chip
189
+ // (相对路径!Sheet!Range). Docs/slides have no .sheet-content, so this block
190
+ // is inert for Word/PPT.
191
+ var MAX_HIGHLIGHT_CELLS = 4000;
192
+ function isSheetDoc() { return !!document.querySelector('.sheet-content'); }
193
+ function colToNum(letters) {
194
+ var n = 0;
195
+ for (var i = 0; i < letters.length; i++) n = n * 26 + (letters.charCodeAt(i) - 64);
196
+ return n;
197
+ }
198
+ function numToCol(n) {
199
+ var s = '';
200
+ while (n > 0) { var r = (n - 1) % 26; s = String.fromCharCode(65 + r) + s; n = (n - r - 1) / 26 | 0; }
201
+ return s;
202
+ }
203
+ function parsePath(el) {
204
+ var p = el && el.getAttribute && el.getAttribute('data-path'); if (!p) return null;
205
+ var parts = p.split('/'); // ['', sheet, ref]
206
+ if (parts.length < 3) return null;
207
+ var m = /^([A-Z]+)([0-9]+)$/.exec(parts[2]); if (!m) return null;
208
+ return { sheet: parts[1], col: colToNum(m[1]), row: +m[2] };
209
+ }
210
+ // Walk from a DOM node up to the nearest data cell (or null).
211
+ function pickCell(node) {
212
+ var el = node;
213
+ while (el && el !== document) {
214
+ if (el.nodeType === 1 && el.hasAttribute('data-path')) {
215
+ var p = parsePath(el); if (p) return p;
216
+ }
217
+ el = el.parentElement;
218
+ }
219
+ return null;
220
+ }
221
+ function clearHighlight() {
222
+ var els = document.querySelectorAll('.dsh-sheet-selected');
223
+ for (var i = 0; i < els.length; i++) els[i].classList.remove('dsh-sheet-selected');
224
+ }
225
+ function applyHighlight(r) {
226
+ clearHighlight();
227
+ if (!r) return;
228
+ if ((r.c2 - r.c1 + 1) * (r.r2 - r.r1 + 1) > MAX_HIGHLIGHT_CELLS) return; // skip huge ranges
229
+ var els = document.querySelectorAll('td[data-path], th[data-path]');
230
+ for (var i = 0; i < els.length; i++) {
231
+ var p = parsePath(els[i]);
232
+ if (p && p.sheet === r.sheet && p.row >= r.r1 && p.row <= r.r2 && p.col >= r.c1 && p.col <= r.c2) {
233
+ els[i].classList.add('dsh-sheet-selected');
234
+ }
235
+ }
236
+ }
237
+ function send(payload) {
238
+ parent.postMessage(Object.assign({ source: 'dsh-filemgr', type: 'dsh-sheet-select' }, payload), '*');
239
+ }
240
+ function update(from, to) {
241
+ var refs = [from, to].filter(Boolean);
242
+ if (!refs.length) { send({ sheet: null, range: null, x: 0, y: 0 }); applyHighlight(null); return; }
243
+ if (refs[0].sheet !== (refs[1] ? refs[1].sheet : refs[0].sheet)) { send({ sheet: null, range: null, x: 0, y: 0 }); applyHighlight(null); return; }
244
+ var sheet = refs[0].sheet;
245
+ var c1 = 1e9, c2 = 0, r1 = 1e9, r2 = 0;
246
+ refs.forEach(function (p) { c1 = Math.min(c1, p.col); c2 = Math.max(c2, p.col); r1 = Math.min(r1, p.row); r2 = Math.max(r2, p.row); });
247
+ var corner = numToCol(c1) + r1, corner2 = numToCol(c2) + r2;
248
+ var range = corner === corner2 ? corner : corner + ':' + corner2;
249
+ var anchorEl = to ? findCellEl(sheet, refs[1].col, refs[1].row) : findCellEl(sheet, from.col, from.row);
250
+ var rect = anchorEl ? anchorEl.getBoundingClientRect() : null;
251
+ applyHighlight({ sheet: sheet, c1: c1, c2: c2, r1: r1, r2: r2 });
252
+ send({ sheet: sheet, range: range, x: rect ? rect.left : 0, y: rect ? rect.bottom + 6 : 0 });
253
+ }
254
+ function findCellEl(sheet, col, row) {
255
+ var els = document.querySelectorAll('td[data-path], th[data-path]');
256
+ for (var i = 0; i < els.length; i++) {
257
+ var p = parsePath(els[i]);
258
+ if (p && p.sheet === sheet && p.col === col && p.row === row) return els[i];
259
+ }
260
+ return null;
261
+ }
262
+ var origin = null, dragging = false;
263
+ document.addEventListener('pointerdown', function (e) {
264
+ if (!isSheetDoc()) return;
265
+ var p = pickCell(e.target);
266
+ if (!p) return; // corner / header / outside — leave selection as-is
267
+ dragging = true; origin = p; update(p, p);
268
+ }, true);
269
+ document.addEventListener('pointermove', function (e) {
270
+ if (!dragging || !origin) return;
271
+ var cur = pickCell(document.elementFromPoint(e.clientX, e.clientY));
272
+ if (cur) update(origin, cur);
273
+ }, true);
274
+ window.addEventListener('pointerup', function () {
275
+ if (!dragging) return;
276
+ dragging = false; origin = null;
277
+ }, true);
278
+ })();
279
+ </script>
280
+ </body>`
281
+ return htmlContent.replace('</body>', patch)
282
+ }
283
+
284
+ /** Forbidden (non-loopback request). */
285
+ function writeForbidden(res: ServerResponse): void {
286
+ json(res, 403, FAIL({ code: 'internal', message: 'forbidden: loopback-only' }))
287
+ }
288
+
289
+ /**
290
+ * Handle one GET /filemgr/office-preview request.
291
+ * Query params: root=workspace/root&path=relative/to/root/file.pptx, probe=1 for mtime check.
292
+ * Uses the same root+path convention as /filemgr/raw.
293
+ */
294
+ async function handleOfficePreview(
295
+ ctx: Context,
296
+ req: IncomingMessage,
297
+ res: ServerResponse,
298
+ ): Promise<void> {
299
+ if (!isLoopbackRequest(req)) {
300
+ writeForbidden(res)
301
+ return
302
+ }
303
+
304
+ const url = new URL(req.url ?? '/', 'http://x')
305
+ const root = url.searchParams.get('root')
306
+ const path = url.searchParams.get('path')
307
+
308
+ if (root === null || root === '' || path === null || path === '') {
309
+ json(res, 400, FAIL({ code: 'internal', message: 'missing root or path parameter' }))
310
+ return
311
+ }
312
+
313
+ // Resolve absolute path: root + path
314
+ const absPath = join(root, path)
315
+
316
+ // Cheap mtime probe for auto-reload (client polling)
317
+ if (url.searchParams.get('probe') === '1') {
318
+ try {
319
+ const info = await stat(absPath)
320
+ json(res, 200, OK({ mtime: info.mtimeMs }))
321
+ } catch {
322
+ json(res, 404, FAIL({ code: 'not-found', message: 'file not found' }))
323
+ }
324
+ return
325
+ }
326
+
327
+ // Check if officecli is available
328
+ const available = await probeOfficecli(ctx)
329
+ if (!available || officecliPath === null) {
330
+ json(res, 501, FAIL({ code: 'internal', message: 'officecli not installed or not on PATH' }))
331
+ return
332
+ }
333
+
334
+ const subprocess = ctx.get('subprocess') as SubprocessRuntime
335
+ let timeoutMs = 60000 // 60s timeout for rendering
336
+
337
+ const controller = new AbortController()
338
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
339
+
340
+ try {
341
+ // officecli view <file> html → outputs raw HTML to stdout
342
+ const handle = subprocess.spawn({
343
+ argv: [officecliPath, 'view', absPath, 'html'],
344
+ cwd: process.cwd(),
345
+ stdio: {
346
+ stdin: 'ignore',
347
+ stdout: { maxBytes: 10 * 1024 * 1024 }, // 10MB max HTML
348
+ stderr: { maxBytes: 1 << 20 },
349
+ },
350
+ graceMs: 5000,
351
+ signal: controller.signal,
352
+ })
353
+
354
+ const outcome = await handle.done
355
+ clearTimeout(timer)
356
+
357
+ if (controller.signal.aborted) {
358
+ json(res, 504, FAIL({ code: 'internal', message: 'officecli rendering timed out after 60s' }))
359
+ return
360
+ }
361
+
362
+ // collected.stdout/stderr are SubprocessOutputReader — read from offset 0.
363
+ const readStdout = (): string =>
364
+ (handle as { collected?: { stdout?: { readFrom: (n: number) => { text: string } } } }).collected?.stdout?.readFrom(0).text ?? ''
365
+ const readStderr = (): string =>
366
+ (handle as { collected?: { stderr?: { readFrom: (n: number) => { text: string } } } }).collected?.stderr?.readFrom(0).text ?? ''
367
+
368
+ if (outcome.exitCode !== 0) {
369
+ const stderr = readStderr()
370
+ const stdout = readStdout()
371
+ const error = `officecli exited with code ${String(outcome.exitCode)}\n${stderr}\n${stdout}`.slice(0, 1000)
372
+ json(res, 422, FAIL({ code: 'read-failed', message: error.trim() }))
373
+ return
374
+ }
375
+
376
+ const htmlContent = readStdout()
377
+ if (htmlContent === '') {
378
+ json(res, 422, FAIL({ code: 'read-failed', message: 'officecli returned empty output' }))
379
+ return
380
+ }
381
+ html(res, injectSlideShowMode(htmlContent))
382
+ } catch (error) {
383
+ clearTimeout(timer)
384
+ const msg = error instanceof Error ? error.message : String(error)
385
+ json(res, 500, FAIL({ code: 'internal', message: `officecli invocation failed: ${msg}` }))
386
+ }
387
+ }
388
+
389
+ /** Register the /filemgr/office-preview route on the web server. */
390
+ export function registerOfficePreviewRoute(ctx: Context): () => void {
391
+ // Probe officecli availability eagerly on route registration
392
+ void probeOfficecli(ctx)
393
+
394
+ return ctx.webServer.register({
395
+ kind: 'prefix',
396
+ path: '/filemgr/office-preview',
397
+ handler: (req, res) => handleOfficePreview(ctx, req, res),
398
+ })
399
+ }