@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
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Office preview: renderers for Word / PPT / Excel. Word, PPT and editable
3
+ * .xlsx files are served as self-contained HTML by the host-side OfficeCLI
4
+ * binary and embedded in an iframe (read-only; `officecli view <file> html`
5
+ * renders original styling — fills, date/currency formats, merges — faithfully).
6
+ * Legacy xls/ods stay read-only HTML tables decoded with SheetJS. Streamed tabs
7
+ * carry the /filemgr/raw route URL in `content`; the bytes are fetched once and
8
+ * handed to the renderer. Any render error degrades to the download hint.
9
+ * @module dsh-filemgr/client/preview/office
10
+ */
11
+
12
+ import { useEffect, useRef, useState, useCallback, type ReactElement } from 'react'
13
+ import type { PreviewTabState } from '../store.ts'
14
+ import { isExcelEditablePath } from '../fileType.ts'
15
+ import { t } from '../locales.ts'
16
+ import previewCss from '../styles/preview.module.css'
17
+
18
+ /** Sheet preview guard: decode at most this many rows/cols of the used range. */
19
+ const SHEET_MAX_ROWS = 500
20
+ const SHEET_MAX_COLS = 60
21
+
22
+ const viewerStyle: React.CSSProperties = {
23
+ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden',
24
+ }
25
+
26
+
27
+ /** Fetch the raw-route bytes once per url (null url = no bytes yet). */
28
+ function useBytes(url: string | null): { bytes: Uint8Array | null; error: string | null } {
29
+ const [bytes, setBytes] = useState<Uint8Array | null>(null)
30
+ const [error, setError] = useState<string | null>(null)
31
+ useEffect(() => {
32
+ let cancelled = false
33
+ setBytes(null)
34
+ setError(null)
35
+ if (url === null) {
36
+ return undefined
37
+ }
38
+ void fetchBytes(url)
39
+ .then((data) => {
40
+ if (!cancelled) setBytes(data)
41
+ })
42
+ .catch((cause: unknown) => {
43
+ if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause))
44
+ })
45
+ return () => { cancelled = true }
46
+ }, [url])
47
+ return { bytes, error }
48
+ }
49
+
50
+ /** GET the bytes behind one raw-route URL. */
51
+ async function fetchBytes(url: string): Promise<Uint8Array> {
52
+ const response = await fetch(url)
53
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
54
+ return new Uint8Array(await response.arrayBuffer())
55
+ }
56
+
57
+ /**
58
+ * Dispatch an office tab to its format renderer. `content` is the streamed
59
+ * raw-route URL; the bytes arrive before the renderer mounts, so each child
60
+ * sees a complete file.
61
+ */
62
+ export function OfficeViewer({ tab, onInsertSheetReference }: {
63
+ tab: PreviewTabState
64
+ /** Insert a 文件!Sheet!Range reference chip into the composer. */
65
+ onInsertSheetReference: (path: string, sheetName: string, range: string) => boolean
66
+ }): ReactElement {
67
+ const { bytes, error } = useBytes(tab.content)
68
+ if (error !== null) return <OfficeError message={error} />
69
+ if (bytes === null) return <div className={previewCss.placeholder}>{t('preview.loading')}</div>
70
+ return (
71
+ <div style={viewerStyle}>
72
+ {(tab.contentType === 'word' || tab.contentType === 'ppt' ||
73
+ // Editable .xlsx previews also go through OfficeCLI for faithful,
74
+ // original-styling rendering (fills, date/currency formats, merges);
75
+ // .xls/.ods are not OpenXML so they fall back to the JS SheetViewer.
76
+ (tab.contentType === 'excel' && isExcelEditablePath(tab.path))) && <HtmlOfficeViewer tab={tab} onInsertSheetReference={onInsertSheetReference} />}
77
+ {tab.contentType === 'excel' && !isExcelEditablePath(tab.path) && <SheetViewer bytes={bytes} />}
78
+ </div>
79
+ )
80
+ }
81
+
82
+ /** Render-error fallback: explain the failure and hint at the download path. */
83
+ function OfficeError({ message }: { message: string }): ReactElement {
84
+ return (
85
+ <div className={previewCss.placeholder}>
86
+ <div className={previewCss.placeholderTitle}>{t('preview.unsupported')}</div>
87
+ <div className={previewCss.placeholderError}>{message}</div>
88
+ <div className={previewCss.placeholderMeta}>{t('preview.downloadHint')}</div>
89
+ </div>
90
+ )
91
+ }
92
+
93
+ /** Word/PPT/Excel (.xlsx) preview via OfficeCLI (server-side rendered HTML, embedded in iframe). */
94
+ function HtmlOfficeViewer({ tab, onInsertSheetReference }: {
95
+ tab: PreviewTabState
96
+ onInsertSheetReference: (path: string, sheetName: string, range: string) => boolean
97
+ }): ReactElement {
98
+ const [bust, setBust] = useState(0)
99
+ const [iframeSrc, setIframeSrc] = useState<string | null>(null)
100
+ const [error, setError] = useState<string | null>(null)
101
+ const loadedPath = useRef<string>('')
102
+ // Live Excel cell selection reported by the injected iframe script.
103
+ const [pick, setPick] = useState<{ sheet: string; range: string; x: number; y: number } | null>(null)
104
+
105
+ // Extract workspace root from the raw-route URL (tab.content) synchronously.
106
+ // The raw route uses /filemgr/raw?root=<workspace>&path=<relative>,
107
+ // and officecli needs the absolute path, so we pass both root+path
108
+ // to /filemgr/office-preview for server-side resolution.
109
+ let root = ''
110
+ if (tab.content !== null) {
111
+ try {
112
+ const url = new URL(tab.content, window.location.origin)
113
+ const r = url.searchParams.get('root')
114
+ if (r !== null) root = r
115
+ } catch { /* keep existing root */ }
116
+ }
117
+
118
+ // Build preview URL with query params (same root+path convention as /filemgr/raw)
119
+ function previewUrl(cacheBust: number, root: string, path: string): string {
120
+ const params = new URLSearchParams({ root, path })
121
+ if (cacheBust !== 0) params.set('t', String(cacheBust))
122
+ return `/filemgr/office-preview?${params.toString()}`
123
+ }
124
+
125
+ const src = previewUrl(bust, root, tab.path)
126
+
127
+ // (Re)load the rendered HTML
128
+ useEffect(() => {
129
+ let cancelled = false
130
+ async function load(): Promise<void> {
131
+ setError(null)
132
+ try {
133
+ const response = await fetch(src)
134
+ if (cancelled) return
135
+ if (!response.ok) {
136
+ const body = (await response.json().catch(() => null)) as { error?: { error?: string } } | null
137
+ setError(body?.error?.error ?? `HTTP ${response.status}`)
138
+ return
139
+ }
140
+ const html = await response.text()
141
+ if (cancelled) return
142
+ const blob = new Blob([html], { type: 'text/html' })
143
+ setIframeSrc(URL.createObjectURL(blob))
144
+ loadedPath.current = tab.path
145
+ } catch (cause) {
146
+ if (!cancelled) setError('无法连接预览服务')
147
+ }
148
+ }
149
+ void load()
150
+ return () => {
151
+ cancelled = true
152
+ }
153
+ }, [src, tab.path])
154
+
155
+ // Auto-reload: poll mtime for changes
156
+ useEffect(() => {
157
+ let cancelled = false
158
+ let timer: number | undefined
159
+ let lastMtime: number | null = null
160
+ async function tick(): Promise<void> {
161
+ try {
162
+ const probeUrl = previewUrl(0, root, tab.path) + '&probe=1'
163
+ const response = await fetch(probeUrl)
164
+ if (response.ok) {
165
+ const json = (await response.json()) as { mtime?: number }
166
+ if (json.mtime !== undefined && json.mtime !== lastMtime) {
167
+ if (lastMtime !== null) setBust((b) => b + 1)
168
+ lastMtime = json.mtime
169
+ }
170
+ }
171
+ } catch { /* keep polling */ }
172
+ if (!cancelled) timer = window.setTimeout(() => { void tick() }, 3000)
173
+ }
174
+ void tick()
175
+ return () => {
176
+ cancelled = true
177
+ if (timer !== undefined) window.clearTimeout(timer)
178
+ }
179
+ }, [tab.path])
180
+
181
+ // Follow the host light/dark theme: the preview iframe is an isolated blob
182
+ // document that cannot see body[data-ds-dark-theme], so push the theme into
183
+ // it via postMessage whenever it changes (and once on iframe load).
184
+ const iframeRef = useRef<HTMLIFrameElement>(null)
185
+ const sendTheme = useCallback((dark: boolean): void => {
186
+ iframeRef.current?.contentWindow?.postMessage(
187
+ { source: 'dsh-filemgr', type: 'dsh-theme', dark },
188
+ '*',
189
+ )
190
+ }, [])
191
+ useEffect(() => {
192
+ const push = (): void => {
193
+ sendTheme(document.body.hasAttribute('data-ds-dark-theme'))
194
+ }
195
+ // Sync once the iframe mounts (postMessage buffers until it loads).
196
+ push()
197
+ const observer = new MutationObserver(push)
198
+ observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
199
+ return () => observer.disconnect()
200
+ }, [sendTheme, iframeSrc])
201
+
202
+ // Excel selection: the injected iframe script posts a live cell range up.
203
+ // sheet/range identify the pick; x/y are iframe-viewport coords used to anchor
204
+ // the floating button. An empty payload (range null) clears the pick.
205
+ useEffect(() => {
206
+ const onMessage = (event: MessageEvent): void => {
207
+ const data = event.data
208
+ if (data === null || typeof data !== 'object' || data.source !== 'dsh-filemgr' || data.type !== 'dsh-sheet-select') return
209
+ if (data.range === null || data.range === undefined) {
210
+ setPick(null)
211
+ return
212
+ }
213
+ setPick({ sheet: data.sheet, range: data.range, x: data.x, y: data.y })
214
+ }
215
+ window.addEventListener('message', onMessage)
216
+ return () => window.removeEventListener('message', onMessage)
217
+ }, [])
218
+
219
+ if (error !== null) {
220
+ return <OfficeError message={`OfficeCLI 渲染失败: ${error}`} />
221
+ }
222
+
223
+ return (
224
+ <div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
225
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 8px', borderBottom: '1px solid rgba(128,128,128,.3)', flex: '0 0 auto' }}>
226
+ <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12 }} title={tab.path}>{tab.path}</span>
227
+ <button type="button" onClick={() => setBust((b) => b + 1)} style={{ fontSize: 12 }}>{t('preview.refresh')}</button>
228
+ </div>
229
+ {iframeSrc !== null ? (
230
+ <div style={{ position: 'relative', flex: 1, minHeight: 0, display: 'flex' }}>
231
+ <iframe
232
+ ref={iframeRef}
233
+ key={`${loadedPath.current}:${bust}`}
234
+ src={iframeSrc}
235
+ title={tab.path}
236
+ onLoad={() => sendTheme(document.body.hasAttribute('data-ds-dark-theme'))}
237
+ style={{ flex: 1, width: '100%', border: 'none', minHeight: 0 }}
238
+ />
239
+ {pick !== null && (
240
+ <button
241
+ type="button"
242
+ onClick={() => {
243
+ if (onInsertSheetReference(tab.path, pick.sheet, pick.range)) setPick(null)
244
+ }}
245
+ style={{
246
+ position: 'absolute',
247
+ left: Math.max(0, pick.x),
248
+ top: Math.max(0, pick.y),
249
+ zIndex: 20,
250
+ fontSize: 12,
251
+ padding: '4px 10px',
252
+ borderRadius: 6,
253
+ border: '1px solid rgba(128,128,128,.4)',
254
+ background: 'var(--dsw-app-color-default-bg, #fff)',
255
+ color: 'var(--dsw-app-color-text-1, #333)',
256
+ boxShadow: '0 2px 8px rgba(0,0,0,.15)',
257
+ cursor: 'pointer',
258
+ }}
259
+ >
260
+ {t('preview.addSheetSelection')}
261
+ </button>
262
+ )}
263
+ </div>
264
+ ) : (
265
+ <div style={{ padding: 16, fontSize: 12, color: 'rgba(128,128,128,.8)' }}>{t('preview.loading')}</div>
266
+ )}
267
+ </div>
268
+ )
269
+ }
270
+
271
+ /** Excel preview via SheetJS: the first sheet decoded to an HTML table. */
272
+ function SheetViewer({ bytes }: { bytes: Uint8Array }): ReactElement {
273
+ const [rows, setRows] = useState<string[][] | null>(null)
274
+ const [error, setError] = useState<string | null>(null)
275
+ useEffect(() => {
276
+ let cancelled = false
277
+ setRows(null)
278
+ setError(null)
279
+ void (async () => {
280
+ try {
281
+ const XLSX = await import('xlsx')
282
+ if (cancelled) return
283
+ const workbook = XLSX.read(bytes, { type: 'array' })
284
+ const sheetName = workbook.SheetNames[0]
285
+ const sheet = sheetName !== undefined ? workbook.Sheets[sheetName] : undefined
286
+ if (sheet === undefined) throw new Error('no sheets in workbook')
287
+ const range = XLSX.utils.decode_range(sheet['!ref'] ?? 'A1:A1')
288
+ const maxRow = Math.min(range.e.r, range.s.r + SHEET_MAX_ROWS - 1)
289
+ const maxCol = Math.min(range.e.c, range.s.c + SHEET_MAX_COLS - 1)
290
+ const data = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
291
+ header: 1,
292
+ range: { s: range.s, e: { r: maxRow, c: maxCol } },
293
+ raw: false,
294
+ defval: '',
295
+ }) as string[][]
296
+ if (!cancelled) setRows(data)
297
+ } catch (cause) {
298
+ if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause))
299
+ }
300
+ })()
301
+ return () => { cancelled = true }
302
+ }, [bytes])
303
+ if (error !== null) return <OfficeError message={error} />
304
+ if (rows === null) return <div className={previewCss.placeholder}>{t('preview.loading')}</div>
305
+ return (
306
+ <div className={previewCss.csvViewer}>
307
+ <table className={previewCss.csvTable}>
308
+ <tbody>
309
+ {rows.map((row, rowIndex) => (
310
+ <tr key={rowIndex}>
311
+ {row.map((cell, cellIndex) => (
312
+ rowIndex === 0 ? <th key={cellIndex}>{cell}</th> : <td key={cellIndex}>{cell}</td>
313
+ ))}
314
+ </tr>
315
+ ))}
316
+ </tbody>
317
+ </table>
318
+ </div>
319
+ )
320
+ }
321
+
@@ -164,3 +164,52 @@ export function appendFileReferenceChip(
164
164
  { start: snap.draft.length, end: snap.draft.length, draftRev: snap.draftRev },
165
165
  )
166
166
  }
167
+
168
+ /** A live Excel cell selection captured from a read-only preview. */
169
+ export interface ExcelReference {
170
+ /** Worksheet name (from the preview's sheet tabs / cell data-path). */
171
+ sheetName: string
172
+ /** A1 range, e.g. "B3" or "B3:F7". */
173
+ range: string
174
+ }
175
+
176
+ /** The model text a sheet chip serializes to (`相对路径!Sheet名!Range`). */
177
+ export function formatSheetRefToken(path: string, ref: ExcelReference): string {
178
+ return `${path}!${ref.sheetName}!${ref.range}`
179
+ }
180
+
181
+ /**
182
+ * Append a sheet-selection chip at the draft end (the preview Excel "引用到对话"
183
+ * action). The `sheet` source's codec (file-source.ts) serializes the
184
+ * `相对路径!Sheet名!Range` token back verbatim — no click / copy behavior.
185
+ *
186
+ * @returns true when the bail was accepted.
187
+ */
188
+ export function appendSheetChip(
189
+ ctx: ClientContext,
190
+ sessionId: SessionId | undefined,
191
+ path: string,
192
+ ref: ExcelReference,
193
+ ): boolean {
194
+ if (sessionId === undefined) return false
195
+ const actx = ctx.sessions.scope(sessionId)
196
+ if (actx === undefined) return false
197
+ const conversation = (ctx as unknown as { get(name: string): unknown }).get('conversation') as ConversationService | undefined
198
+ if (conversation === undefined) return false
199
+ const shell = conversation.input.for(actx as unknown as ClientContext)
200
+ const snap = shell.state.getSnapshot()
201
+ const token = formatSheetRefToken(path, ref)
202
+ const reference: ConversationReference = {
203
+ source: 'sheet',
204
+ ref: token,
205
+ label: token,
206
+ clipboardText: token,
207
+ appearance: 'file',
208
+ }
209
+ return (actx as unknown as {
210
+ bail(target: unknown, event: string, payload: unknown): boolean
211
+ }).bail(actx, 'slash/input-insert-reference', {
212
+ reference,
213
+ span: { start: snap.draft.length, end: snap.draft.length, draftRev: snap.draftRev },
214
+ })
215
+ }