@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.
- package/LICENSE +38 -38
- package/README.i18n.yaml +6 -6
- package/README.md +66 -66
- package/README.zh.md +66 -66
- package/cordis.patch.yml +13 -13
- package/lib/client.js +27305 -270
- package/lib/client.js.map +1 -1
- package/lib/index.js +474 -42
- package/lib/tsconfig.client.tsbuildinfo +1 -1
- package/lib/tsconfig.host.tsbuildinfo +1 -1
- package/lib/types/client/file-source.d.ts +10 -0
- package/lib/types/client/file-source.d.ts.map +1 -1
- package/lib/types/client/fileType.d.ts +19 -4
- package/lib/types/client/fileType.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +3 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/mount.d.ts +1 -1
- package/lib/types/client/mount.d.ts.map +1 -1
- package/lib/types/client/preview/PreviewPanel.d.ts +2 -1
- package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -1
- package/lib/types/client/preview/PreviewToolbar.d.ts +3 -1
- package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -1
- package/lib/types/client/preview/content.d.ts +3 -1
- package/lib/types/client/preview/content.d.ts.map +1 -1
- package/lib/types/client/preview/office.d.ts +23 -0
- package/lib/types/client/preview/office.d.ts.map +1 -0
- package/lib/types/client/reference.d.ts +17 -0
- package/lib/types/client/reference.d.ts.map +1 -1
- package/lib/types/client/store.d.ts.map +1 -1
- package/lib/types/host/fs-service.d.ts.map +1 -1
- package/lib/types/host/office-preview.d.ts +13 -0
- package/lib/types/host/office-preview.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +19 -4
- package/src/client/FileManagerSettingsCard.tsx +117 -117
- package/src/client/PluginSettingsCard.tsx +337 -337
- package/src/client/chat/file-ref.ts +144 -144
- package/src/client/chat/mermaid-chat.tsx +102 -102
- package/src/client/chat/placeholder-hint.tsx +46 -46
- package/src/client/components/ExplorerPanel.tsx +534 -534
- package/src/client/components/FileIcon.tsx +46 -46
- package/src/client/components/ScmPanel.tsx +480 -480
- package/src/client/components/a11y.ts +20 -20
- package/src/client/components/icons.tsx +274 -274
- package/src/client/components/overlay.tsx +225 -225
- package/src/client/drag/DragFileInlay.tsx +89 -89
- package/src/client/drag/file-drag.ts +67 -67
- package/src/client/drag.ts +154 -154
- package/src/client/file-source.ts +34 -0
- package/src/client/fileType.ts +32 -5
- package/src/client/hooks/useResizableSplit.ts +91 -91
- package/src/client/hooks/useStore.ts +15 -15
- package/src/client/index.ts +42 -3
- package/src/client/layout.ts +523 -523
- package/src/client/locales.ts +6 -0
- package/src/client/maximize.ts +34 -34
- package/src/client/mount.tsx +2 -1
- package/src/client/persist.ts +194 -194
- package/src/client/picker/FilePickerModal.tsx +526 -526
- package/src/client/picker/file-picker.ts +86 -86
- package/src/client/preview/PreviewPanel.tsx +7 -1
- package/src/client/preview/PreviewTabs.tsx +159 -159
- package/src/client/preview/PreviewToolbar.tsx +3 -2
- package/src/client/preview/content.tsx +7 -2
- package/src/client/preview/markdown.ts +351 -351
- package/src/client/preview/mermaid.ts +274 -274
- package/src/client/preview/office.tsx +321 -0
- package/src/client/reference.ts +49 -0
- package/src/client/settings-card.module.css +316 -316
- package/src/client/settings-form.ts +451 -451
- package/src/client/store.ts +17 -12
- package/src/client/styles/drag.module.css +30 -30
- package/src/client/styles/explorer.module.css +371 -371
- package/src/client/styles/picker.module.css +344 -344
- package/src/client/styles/preview.module.css +3 -1
- package/src/client/styles/scm.module.css +380 -380
- package/src/client/styles/tokens.module.css +376 -371
- package/src/core/types.ts +162 -162
- package/src/host/fs-service.ts +7 -1
- package/src/host/gate.ts +73 -73
- package/src/host/git-runner.ts +116 -116
- package/src/host/git-service.ts +395 -395
- package/src/host/loopback.ts +63 -63
- package/src/host/office-preview.ts +399 -0
- package/src/host/poll-guard.ts +109 -109
- package/src/host/routes.ts +72 -1
- package/src/index.ts +3 -1
- package/src/mount-once.ts +48 -48
- package/lib/types/client/chat/AttachedFilesDock.d.ts +0 -51
- package/lib/types/client/chat/AttachedFilesDock.d.ts.map +0 -1
- package/lib/types/client/floating.d.ts +0 -27
- package/lib/types/client/floating.d.ts.map +0 -1
- package/lib/types/client/mention.d.ts +0 -44
- package/lib/types/client/mention.d.ts.map +0 -1
package/src/host/loopback.ts
CHANGED
|
@@ -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
|
+
}
|