@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.
Files changed (101) 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 +27354 -355
  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 +0 -1
  16. package/lib/types/client/index.d.ts.map +1 -1
  17. package/lib/types/client/locales.d.ts +3 -0
  18. package/lib/types/client/locales.d.ts.map +1 -1
  19. package/lib/types/client/mount.d.ts +1 -1
  20. package/lib/types/client/mount.d.ts.map +1 -1
  21. package/lib/types/client/picker/FilePickerModal.d.ts +8 -6
  22. package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -1
  23. package/lib/types/client/preview/PreviewPanel.d.ts +2 -1
  24. package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -1
  25. package/lib/types/client/preview/PreviewToolbar.d.ts +3 -1
  26. package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -1
  27. package/lib/types/client/preview/content.d.ts +3 -1
  28. package/lib/types/client/preview/content.d.ts.map +1 -1
  29. package/lib/types/client/preview/office.d.ts +23 -0
  30. package/lib/types/client/preview/office.d.ts.map +1 -0
  31. package/lib/types/client/reference.d.ts +23 -10
  32. package/lib/types/client/reference.d.ts.map +1 -1
  33. package/lib/types/client/store.d.ts.map +1 -1
  34. package/lib/types/host/fs-service.d.ts.map +1 -1
  35. package/lib/types/host/office-preview.d.ts +13 -0
  36. package/lib/types/host/office-preview.d.ts.map +1 -0
  37. package/lib/types/host/routes.d.ts.map +1 -1
  38. package/lib/types/index.d.ts.map +1 -1
  39. package/package.json +19 -4
  40. package/src/client/DockItem.tsx +1 -1
  41. package/src/client/FileManagerSettingsCard.tsx +117 -117
  42. package/src/client/PluginSettingsCard.tsx +337 -337
  43. package/src/client/chat/file-ref.ts +144 -144
  44. package/src/client/chat/mermaid-chat.tsx +102 -102
  45. package/src/client/chat/placeholder-hint.tsx +46 -46
  46. package/src/client/components/ExplorerPanel.tsx +534 -534
  47. package/src/client/components/FileIcon.tsx +46 -46
  48. package/src/client/components/ScmPanel.tsx +480 -480
  49. package/src/client/components/a11y.ts +20 -20
  50. package/src/client/components/icons.tsx +274 -274
  51. package/src/client/components/overlay.tsx +225 -225
  52. package/src/client/drag/DragFileInlay.tsx +89 -89
  53. package/src/client/drag/file-drag.ts +67 -67
  54. package/src/client/drag.ts +154 -154
  55. package/src/client/file-source.ts +34 -0
  56. package/src/client/fileType.ts +32 -5
  57. package/src/client/hooks/useResizableSplit.ts +91 -91
  58. package/src/client/hooks/useStore.ts +15 -15
  59. package/src/client/index.ts +45 -17
  60. package/src/client/layout.ts +523 -523
  61. package/src/client/locales.ts +6 -0
  62. package/src/client/maximize.ts +34 -34
  63. package/src/client/mount.tsx +2 -1
  64. package/src/client/persist.ts +194 -194
  65. package/src/client/picker/FilePickerModal.tsx +526 -507
  66. package/src/client/picker/file-picker.ts +86 -86
  67. package/src/client/preview/PreviewPanel.tsx +8 -1
  68. package/src/client/preview/PreviewTabs.tsx +159 -159
  69. package/src/client/preview/PreviewToolbar.tsx +3 -2
  70. package/src/client/preview/content.tsx +7 -2
  71. package/src/client/preview/markdown.ts +351 -351
  72. package/src/client/preview/mermaid.ts +274 -274
  73. package/src/client/preview/office.tsx +321 -0
  74. package/src/client/reference.ts +62 -8
  75. package/src/client/settings-card.module.css +316 -316
  76. package/src/client/settings-form.ts +451 -451
  77. package/src/client/store.ts +17 -12
  78. package/src/client/styles/drag.module.css +30 -30
  79. package/src/client/styles/explorer.module.css +371 -371
  80. package/src/client/styles/picker.module.css +344 -344
  81. package/src/client/styles/preview.module.css +18 -11
  82. package/src/client/styles/scm.module.css +380 -380
  83. package/src/client/styles/tokens.module.css +376 -367
  84. package/src/core/types.ts +162 -162
  85. package/src/host/fs-service.ts +7 -1
  86. package/src/host/gate.ts +73 -73
  87. package/src/host/git-runner.ts +116 -116
  88. package/src/host/git-service.ts +395 -395
  89. package/src/host/loopback.ts +63 -63
  90. package/src/host/office-preview.ts +399 -0
  91. package/src/host/poll-guard.ts +109 -109
  92. package/src/host/routes.ts +72 -1
  93. package/src/index.ts +3 -1
  94. package/src/mount-once.ts +48 -48
  95. package/lib/types/client/chat/AttachedFilesDock.d.ts +0 -51
  96. package/lib/types/client/chat/AttachedFilesDock.d.ts.map +0 -1
  97. package/lib/types/client/floating.d.ts +0 -27
  98. package/lib/types/client/floating.d.ts.map +0 -1
  99. package/lib/types/client/mention.d.ts +0 -44
  100. package/lib/types/client/mention.d.ts.map +0 -1
  101. 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
+
@@ -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: number
34
- /** Last line of the referenced range (1-based, inclusive). */
35
- endLine: number
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: { path: string; startLine: number; endLine: number }): string {
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 { conversation?: ConversationService }).conversation
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 { conversation?: ConversationService }).conversation
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
+ }