@lijian-ui/dsh-file-manager 0.2.2 → 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 +27354 -355
- 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 +0 -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/picker/FilePickerModal.d.ts +8 -6
- package/lib/types/client/picker/FilePickerModal.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 +23 -10
- 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/DockItem.tsx +1 -1
- 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 +45 -17
- 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 -507
- package/src/client/picker/file-picker.ts +86 -86
- package/src/client/preview/PreviewPanel.tsx +8 -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 +62 -8
- 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 +18 -11
- package/src/client/styles/scm.module.css +380 -380
- package/src/client/styles/tokens.module.css +376 -367
- 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/client/styles/chip.module.css +0 -38
|
@@ -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
|
+
|
package/src/client/reference.ts
CHANGED
|
@@ -29,10 +29,10 @@ export interface ConversationService {
|
|
|
29
29
|
export interface FileReference {
|
|
30
30
|
/** Workspace-relative path (what the agent will read). */
|
|
31
31
|
path: string
|
|
32
|
-
/** First line of the referenced range (1-based, inclusive). */
|
|
33
|
-
startLine
|
|
34
|
-
/** Last line of the referenced range (1-based, inclusive). */
|
|
35
|
-
endLine
|
|
32
|
+
/** First line of the referenced range (1-based, inclusive). Omit for whole-file references. */
|
|
33
|
+
startLine?: number
|
|
34
|
+
/** Last line of the referenced range (1-based, inclusive). Omit for whole-file references. */
|
|
35
|
+
endLine?: number
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/** Basename of a `/`-separated relative path ('' when empty). */
|
|
@@ -56,11 +56,13 @@ function formatRefLabel(ref: FileReference): string {
|
|
|
56
56
|
const basename = basenameOf(ref.path)
|
|
57
57
|
const dirname = dirnameOf(ref.path)
|
|
58
58
|
const name = dirname === '' ? basename : `${basename} - ${dirname}`
|
|
59
|
+
if (ref.startLine === undefined || ref.endLine === undefined) return name
|
|
59
60
|
return ref.endLine > ref.startLine ? `${name} ${ref.startLine}-${ref.endLine}` : `${name} ${ref.startLine}`
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
/** The `@path:lines` model form (what the codec serializes to on submit). */
|
|
63
|
-
export function formatFileRefToken(ref:
|
|
63
|
+
/** The `@path` / `@path:lines` model form (what the codec serializes to on submit). */
|
|
64
|
+
export function formatFileRefToken(ref: FileReference): string {
|
|
65
|
+
if (ref.startLine === undefined || ref.endLine === undefined) return `@${ref.path}`
|
|
64
66
|
const range = ref.endLine > ref.startLine ? `${ref.startLine}-${ref.endLine}` : `${ref.startLine}`
|
|
65
67
|
return `@${ref.path}:${range}`
|
|
66
68
|
}
|
|
@@ -71,6 +73,8 @@ interface ConversationReference {
|
|
|
71
73
|
ref: string
|
|
72
74
|
label: string
|
|
73
75
|
clipboardText: string
|
|
76
|
+
/** Official chip appearance: renders the reference icon over the `@` glyph. */
|
|
77
|
+
appearance: 'file'
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
/** The DRAFT-REV+CAS span the conversation machine expects for chip inserts. */
|
|
@@ -86,7 +90,7 @@ export function appendToDraft(ctx: ClientContext, sessionId: SessionId | undefin
|
|
|
86
90
|
if (sessionId === undefined) return false
|
|
87
91
|
const actx = ctx.sessions.scope(sessionId)
|
|
88
92
|
if (actx === undefined) return false
|
|
89
|
-
const conversation = (ctx as unknown as {
|
|
93
|
+
const conversation = (ctx as unknown as { get(name: string): unknown }).get('conversation') as ConversationService | undefined
|
|
90
94
|
if (conversation === undefined) return false
|
|
91
95
|
const input = conversation.input.for(actx as unknown as ClientContext)
|
|
92
96
|
const draft = input.state.getSnapshot().draft
|
|
@@ -122,6 +126,7 @@ export function insertFileReference(
|
|
|
122
126
|
ref: formatFileRefToken(ref),
|
|
123
127
|
label: formatRefLabel(ref),
|
|
124
128
|
clipboardText: formatFileRefToken(ref),
|
|
129
|
+
appearance: 'file',
|
|
125
130
|
}
|
|
126
131
|
return (actx as unknown as {
|
|
127
132
|
bail(target: unknown, event: string, payload: unknown): boolean
|
|
@@ -148,7 +153,7 @@ export function appendFileReferenceChip(
|
|
|
148
153
|
if (sessionId === undefined) return false
|
|
149
154
|
const actx = ctx.sessions.scope(sessionId)
|
|
150
155
|
if (actx === undefined) return false
|
|
151
|
-
const conversation = (ctx as unknown as {
|
|
156
|
+
const conversation = (ctx as unknown as { get(name: string): unknown }).get('conversation') as ConversationService | undefined
|
|
152
157
|
if (conversation === undefined) return false
|
|
153
158
|
const shell = conversation.input.for(actx as unknown as ClientContext)
|
|
154
159
|
const snap = shell.state.getSnapshot()
|
|
@@ -159,3 +164,52 @@ export function appendFileReferenceChip(
|
|
|
159
164
|
{ start: snap.draft.length, end: snap.draft.length, draftRev: snap.draftRev },
|
|
160
165
|
)
|
|
161
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
|
+
}
|