dsh-explorer 0.0.0-stage → 0.1.0
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 +22 -0
- package/README.md +106 -2
- package/cordis.patch.yml +5 -0
- package/icon.svg +7 -0
- package/lib/client.js +2814 -0
- package/lib/index.js +747 -0
- package/package.json +92 -4
- package/src/client/ExplorerContextMenu.tsx +159 -0
- package/src/client/ExplorerHeader.tsx +75 -0
- package/src/client/ExplorerOptionsModal.tsx +243 -0
- package/src/client/ExplorerRow.tsx +240 -0
- package/src/client/ExplorerSearch.tsx +42 -0
- package/src/client/ExplorerView.tsx +1139 -0
- package/src/client/api.ts +121 -0
- package/src/client/conversation-draft.ts +635 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/drag-drop.ts +209 -0
- package/src/client/explorer.module.css +628 -0
- package/src/client/index.tsx +160 -0
- package/src/client/options.ts +59 -0
- package/src/client/vscode-theme.tsx +250 -0
- package/src/config.ts +11 -0
- package/src/fs-operations.ts +116 -0
- package/src/fs-tree.ts +155 -0
- package/src/fs-watch.ts +179 -0
- package/src/git-status.ts +161 -0
- package/src/index.ts +165 -0
- package/src/path-security.ts +22 -0
- package/src/session-path.ts +35 -0
- package/src/wire.ts +63 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
export interface DraftCaret {
|
|
2
|
+
start: number
|
|
3
|
+
end: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
let lastKnownSessionId = ''
|
|
7
|
+
let trackedCaret: { start: number; end: number; draft?: string; timestamp: number } | null = null
|
|
8
|
+
|
|
9
|
+
export function setLastKnownSessionId(id?: string): void {
|
|
10
|
+
if (id) lastKnownSessionId = id
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function safeService(ctx: any, name: string): any {
|
|
14
|
+
try {
|
|
15
|
+
return ctx?.get ? ctx.get(name) : ctx?.reflect?.get ? ctx.reflect.get(name, false) : ctx?.[name]
|
|
16
|
+
} catch {
|
|
17
|
+
try {
|
|
18
|
+
return ctx?.reflect?.get?.(name, false)
|
|
19
|
+
} catch {
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveActiveSessionId(ctx: any, explicitId?: string): string {
|
|
26
|
+
if (explicitId) {
|
|
27
|
+
lastKnownSessionId = explicitId
|
|
28
|
+
return explicitId
|
|
29
|
+
}
|
|
30
|
+
if (lastKnownSessionId) return lastKnownSessionId
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const sessions = safeService(ctx, 'sessions')
|
|
34
|
+
if (sessions) {
|
|
35
|
+
const snapshot = sessions.list?.getSnapshot?.()
|
|
36
|
+
if (snapshot?.byId) {
|
|
37
|
+
for (const [id, row] of Object.entries<any>(snapshot.byId)) {
|
|
38
|
+
if ((row?.retainedBy?.mainView ?? 0) > 0) {
|
|
39
|
+
lastKnownSessionId = id
|
|
40
|
+
return id
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (snapshot.activeId) {
|
|
44
|
+
lastKnownSessionId = snapshot.activeId
|
|
45
|
+
return snapshot.activeId
|
|
46
|
+
}
|
|
47
|
+
if (Array.isArray(snapshot.ids) && snapshot.ids.length > 0) {
|
|
48
|
+
const id = snapshot.ids[snapshot.ids.length - 1]
|
|
49
|
+
lastKnownSessionId = id
|
|
50
|
+
return id
|
|
51
|
+
}
|
|
52
|
+
const keys = Object.keys(snapshot.byId)
|
|
53
|
+
if (keys.length > 0) {
|
|
54
|
+
const id = keys[keys.length - 1]
|
|
55
|
+
lastKnownSessionId = id
|
|
56
|
+
return id
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (sessions.active?.id) {
|
|
60
|
+
lastKnownSessionId = sessions.active.id
|
|
61
|
+
return sessions.active.id
|
|
62
|
+
}
|
|
63
|
+
if (sessions.current?.id) {
|
|
64
|
+
lastKnownSessionId = sessions.current.id
|
|
65
|
+
return sessions.current.id
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// Ignore
|
|
70
|
+
}
|
|
71
|
+
return ''
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Locate DSH's native Lexical composer element.
|
|
76
|
+
* DSH marks the composer contenteditable with [data-composer-input="true"].
|
|
77
|
+
*/
|
|
78
|
+
export function findComposerElement(): HTMLElement | null {
|
|
79
|
+
if (typeof document === 'undefined') return null
|
|
80
|
+
|
|
81
|
+
// 1. Direct DSH Lexical composer attribute
|
|
82
|
+
const composerInput =
|
|
83
|
+
document.querySelector<HTMLElement>('[data-composer-input="true"]') ??
|
|
84
|
+
document.querySelector<HTMLElement>('[data-composer-input]')
|
|
85
|
+
if (composerInput) return composerInput
|
|
86
|
+
|
|
87
|
+
// 2. Lexical composer inside conversation slot
|
|
88
|
+
const slot =
|
|
89
|
+
document.querySelector('[data-slot="conversation"]') ??
|
|
90
|
+
document.querySelector('[data-slot*="conversation"]') ??
|
|
91
|
+
document.body
|
|
92
|
+
const ce =
|
|
93
|
+
slot.querySelector<HTMLElement>('[contenteditable="true"][role="textbox"]') ??
|
|
94
|
+
slot.querySelector<HTMLElement>('[contenteditable="true"]')
|
|
95
|
+
if (ce) return ce
|
|
96
|
+
|
|
97
|
+
// 3. Fallback textarea
|
|
98
|
+
return (
|
|
99
|
+
document.querySelector<HTMLTextAreaElement>('textarea[data-phase]') ??
|
|
100
|
+
document.querySelector<HTMLTextAreaElement>('textarea') ??
|
|
101
|
+
document.querySelector<HTMLElement>('[contenteditable="true"]') ??
|
|
102
|
+
null
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Continuously track user selection/caret in composer.
|
|
108
|
+
*/
|
|
109
|
+
export function recordLiveCaret(): void {
|
|
110
|
+
if (typeof document === 'undefined') return
|
|
111
|
+
const el = findComposerElement()
|
|
112
|
+
if (!el) return
|
|
113
|
+
|
|
114
|
+
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
|
|
115
|
+
const ta = el as HTMLTextAreaElement
|
|
116
|
+
const start = ta.selectionStart
|
|
117
|
+
const end = ta.selectionEnd
|
|
118
|
+
if (typeof start === 'number' && typeof end === 'number' && Number.isFinite(start) && Number.isFinite(end)) {
|
|
119
|
+
trackedCaret = {
|
|
120
|
+
start: Math.max(0, Math.min(start, ta.value.length)),
|
|
121
|
+
end: Math.max(start, Math.min(end, ta.value.length)),
|
|
122
|
+
draft: ta.value,
|
|
123
|
+
timestamp: Date.now(),
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} else if (el.isContentEditable) {
|
|
127
|
+
const sel = window.getSelection()
|
|
128
|
+
if (sel && sel.rangeCount > 0 && el.contains(sel.anchorNode)) {
|
|
129
|
+
const range = sel.getRangeAt(0)
|
|
130
|
+
const preRange = range.cloneRange()
|
|
131
|
+
preRange.selectNodeContents(el)
|
|
132
|
+
preRange.setEnd(range.startContainer, range.startOffset)
|
|
133
|
+
const start = preRange.toString().length
|
|
134
|
+
const length = range.toString().length
|
|
135
|
+
trackedCaret = {
|
|
136
|
+
start,
|
|
137
|
+
end: start + length,
|
|
138
|
+
draft: el.innerText ?? el.textContent ?? '',
|
|
139
|
+
timestamp: Date.now(),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let caretTrackerAttached = false
|
|
146
|
+
export function setupCaretTracker(): () => void {
|
|
147
|
+
if (caretTrackerAttached || typeof document === 'undefined') return () => {}
|
|
148
|
+
caretTrackerAttached = true
|
|
149
|
+
|
|
150
|
+
const handleSelection = () => recordLiveCaret()
|
|
151
|
+
document.addEventListener('selectionchange', handleSelection, { passive: true })
|
|
152
|
+
document.addEventListener('keyup', handleSelection, { passive: true })
|
|
153
|
+
document.addEventListener('mouseup', handleSelection, { passive: true })
|
|
154
|
+
document.addEventListener('input', handleSelection, { passive: true })
|
|
155
|
+
document.addEventListener('focusin', handleSelection, { passive: true })
|
|
156
|
+
|
|
157
|
+
return () => {
|
|
158
|
+
document.removeEventListener('selectionchange', handleSelection)
|
|
159
|
+
document.removeEventListener('keyup', handleSelection)
|
|
160
|
+
document.removeEventListener('mouseup', handleSelection)
|
|
161
|
+
document.removeEventListener('input', handleSelection)
|
|
162
|
+
document.removeEventListener('focusin', handleSelection)
|
|
163
|
+
caretTrackerAttached = false
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Snap caret so it never cuts inside a word token (e.g. 'per|lu' -> 'perlu|').
|
|
169
|
+
*/
|
|
170
|
+
export function snapCaretToWordBoundary(draft: string, caret: DraftCaret | null): DraftCaret | null {
|
|
171
|
+
if (!caret || draft.length === 0) return caret
|
|
172
|
+
let { start, end } = caret
|
|
173
|
+
|
|
174
|
+
const isWordChar = (ch: string | undefined): boolean => {
|
|
175
|
+
return Boolean(ch && !/[\s\r\n\t]/.test(ch))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (start === end && start > 0 && start < draft.length) {
|
|
179
|
+
const prevChar = draft[start - 1]
|
|
180
|
+
const nextChar = draft[start]
|
|
181
|
+
if (isWordChar(prevChar) && isWordChar(nextChar)) {
|
|
182
|
+
while (start < draft.length && isWordChar(draft[start])) {
|
|
183
|
+
start++
|
|
184
|
+
}
|
|
185
|
+
end = start
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { start, end }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function probeComposerCaret(draft: string, targetPoint?: { x: number; y: number }): DraftCaret | null {
|
|
193
|
+
const el = findComposerElement()
|
|
194
|
+
|
|
195
|
+
// 1. Direct read from focused/active textarea
|
|
196
|
+
if (el && (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT')) {
|
|
197
|
+
const ta = el as HTMLTextAreaElement
|
|
198
|
+
if (!ta.disabled && !ta.readOnly) {
|
|
199
|
+
const start = ta.selectionStart
|
|
200
|
+
const end = ta.selectionEnd
|
|
201
|
+
if (
|
|
202
|
+
typeof start === 'number' &&
|
|
203
|
+
typeof end === 'number' &&
|
|
204
|
+
Number.isFinite(start) &&
|
|
205
|
+
Number.isFinite(end) &&
|
|
206
|
+
(document.activeElement === ta || ta.value === draft)
|
|
207
|
+
) {
|
|
208
|
+
const clampedStart = Math.max(0, Math.min(start, draft.length))
|
|
209
|
+
const clampedEnd = Math.max(clampedStart, Math.min(end, draft.length))
|
|
210
|
+
trackedCaret = { start: clampedStart, end: clampedEnd, draft, timestamp: Date.now() }
|
|
211
|
+
return { start: clampedStart, end: clampedEnd }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 2. Fallback to continuously tracked live caret
|
|
217
|
+
if (trackedCaret) {
|
|
218
|
+
const clampedStart = Math.max(0, Math.min(trackedCaret.start, draft.length))
|
|
219
|
+
const clampedEnd = Math.max(clampedStart, Math.min(trackedCaret.end, draft.length))
|
|
220
|
+
return { start: clampedStart, end: clampedEnd }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return null
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function placeComposerCaret(expectedDraft: string, caretIndex: number): void {
|
|
227
|
+
let remaining = 3
|
|
228
|
+
const check = () => {
|
|
229
|
+
remaining -= 1
|
|
230
|
+
const el = findComposerElement()
|
|
231
|
+
if (!el) return
|
|
232
|
+
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
|
|
233
|
+
const textarea = el as HTMLTextAreaElement
|
|
234
|
+
if (textarea.disabled || textarea.readOnly) return
|
|
235
|
+
const clamped = Math.max(0, Math.min(caretIndex, textarea.value.length))
|
|
236
|
+
textarea.focus()
|
|
237
|
+
textarea.setSelectionRange(clamped, clamped)
|
|
238
|
+
trackedCaret = { start: clamped, end: clamped, draft: textarea.value, timestamp: Date.now() }
|
|
239
|
+
return
|
|
240
|
+
} else if (el.isContentEditable) {
|
|
241
|
+
el.focus()
|
|
242
|
+
trackedCaret = { start: caretIndex, end: caretIndex, draft: expectedDraft, timestamp: Date.now() }
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
if (remaining > 0) {
|
|
246
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(check)
|
|
247
|
+
else setTimeout(check, 16)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(check)
|
|
251
|
+
else setTimeout(check, 16)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Splice text into draft with strict whitespace and word-boundary safety.
|
|
256
|
+
*/
|
|
257
|
+
export function spliceInsert(
|
|
258
|
+
draft: string,
|
|
259
|
+
text: string,
|
|
260
|
+
rawCaret: DraftCaret | null,
|
|
261
|
+
): { draft: string; caretAfter: number } {
|
|
262
|
+
const caret = snapCaretToWordBoundary(draft, rawCaret)
|
|
263
|
+
if (!caret || draft === '') {
|
|
264
|
+
const next = draft.trim() === '' ? text : `${draft}${/\s$/.test(draft) ? '' : ' '}${text}`
|
|
265
|
+
return { draft: next, caretAfter: next.length }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const prefix = draft.slice(0, caret.start)
|
|
269
|
+
const suffix = draft.slice(caret.end)
|
|
270
|
+
|
|
271
|
+
// Enforce single space separation on left
|
|
272
|
+
const left = prefix === '' || /\s$/.test(prefix) ? '' : ' '
|
|
273
|
+
|
|
274
|
+
// Enforce single space separation on right
|
|
275
|
+
const right = suffix === '' || /^\s/.test(suffix) || /\s$/.test(text) ? '' : ' '
|
|
276
|
+
|
|
277
|
+
const next = `${prefix}${left}${text}${right}${suffix}`
|
|
278
|
+
const caretAfter = prefix.length + left.length + text.length
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
draft: next,
|
|
282
|
+
caretAfter,
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function mentionForPath(relativePath: string, isDir: boolean): string {
|
|
287
|
+
const norm = relativePath.replace(/[\\/]+$/, '').replace(/\\/g, '/')
|
|
288
|
+
const target = isDir ? `${norm}/` : norm
|
|
289
|
+
return /\s/.test(target) ? `@"${target}"` : `@${target}`
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function baseName(path: string): string {
|
|
293
|
+
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
|
294
|
+
return at === -1 ? path : path.slice(at + 1)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Main insertion function for DSH chat composer.
|
|
299
|
+
* Inserts native structured ReferenceChip nodes with strict single-execution
|
|
300
|
+
* protection so mentions never duplicate, never merge, and always format cleanly.
|
|
301
|
+
*/
|
|
302
|
+
export function insertMentionToComposer(
|
|
303
|
+
ctx: any,
|
|
304
|
+
sessionId: string,
|
|
305
|
+
relativePath: string,
|
|
306
|
+
isDir: boolean,
|
|
307
|
+
targetPoint?: { x: number; y: number },
|
|
308
|
+
): boolean {
|
|
309
|
+
try {
|
|
310
|
+
const mention = mentionForPath(relativePath, isDir)
|
|
311
|
+
const name = baseName(relativePath)
|
|
312
|
+
const label = isDir ? `${name}/` : name
|
|
313
|
+
const appearance = isDir ? 'folder' : 'file'
|
|
314
|
+
|
|
315
|
+
// If drop coordinate provided, set Lexical selection to the exact drop point first
|
|
316
|
+
if (targetPoint && typeof document !== 'undefined') {
|
|
317
|
+
const el = findComposerElement()
|
|
318
|
+
if (el) {
|
|
319
|
+
let range: Range | null = null
|
|
320
|
+
if (typeof (document as any).caretRangeFromPoint === 'function') {
|
|
321
|
+
range = (document as any).caretRangeFromPoint(targetPoint.x, targetPoint.y)
|
|
322
|
+
} else if (typeof (document as any).caretPositionFromPoint === 'function') {
|
|
323
|
+
const pos = (document as any).caretPositionFromPoint(targetPoint.x, targetPoint.y)
|
|
324
|
+
if (pos && pos.offsetNode) {
|
|
325
|
+
range = document.createRange()
|
|
326
|
+
range.setStart(pos.offsetNode, pos.offset)
|
|
327
|
+
range.collapse(true)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (range && el.contains(range.startContainer)) {
|
|
331
|
+
const sel = window.getSelection()
|
|
332
|
+
sel?.removeAllRanges()
|
|
333
|
+
sel?.addRange(range)
|
|
334
|
+
el.focus({ preventScroll: true })
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const sessions = safeService(ctx, 'sessions')
|
|
340
|
+
const targetSessionId = resolveActiveSessionId(ctx, sessionId)
|
|
341
|
+
const actx = targetSessionId && sessions?.scope ? sessions.scope(targetSessionId) : undefined
|
|
342
|
+
const conversation = safeService(ctx, 'conversation')
|
|
343
|
+
|
|
344
|
+
// 1. Primary path: Native DSH Lexical structured reference chip (supports both file & folder)
|
|
345
|
+
if (conversation && actx) {
|
|
346
|
+
const shell = conversation.input?.for(actx)
|
|
347
|
+
if (shell) {
|
|
348
|
+
// Method A: Direct native batch reference insertion on draftEditor
|
|
349
|
+
// This is DSH's internal native routine which automatically guarantees single-space separation
|
|
350
|
+
// on both sides without manual cursor manipulation or span races.
|
|
351
|
+
if (shell.draftEditor && typeof shell.draftEditor.insertFileReferences === 'function') {
|
|
352
|
+
try {
|
|
353
|
+
const revBefore = shell.rev ?? shell.state?.getSnapshot?.()?.draftRev ?? 0
|
|
354
|
+
const applied = shell.draftEditor.insertFileReferences([
|
|
355
|
+
{
|
|
356
|
+
source: 'reference',
|
|
357
|
+
ref: mention,
|
|
358
|
+
label,
|
|
359
|
+
appearance,
|
|
360
|
+
clipboardText: mention,
|
|
361
|
+
},
|
|
362
|
+
])
|
|
363
|
+
const revAfter = shell.rev ?? shell.state?.getSnapshot?.()?.draftRev ?? 0
|
|
364
|
+
if (applied || revAfter !== revBefore) {
|
|
365
|
+
shell.focus?.()
|
|
366
|
+
return true
|
|
367
|
+
}
|
|
368
|
+
} catch (err) {
|
|
369
|
+
console.warn('[dsh-explorer] draftEditor.insertFileReferences failed, trying actx.bail:', err)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Method B: Event-driven reference chip insertion with detectText-aligned whitespace check
|
|
374
|
+
let span = shell.actions?.captureInsertion?.() ?? shell.captureInsertion?.()
|
|
375
|
+
if (span && span.draftRev !== undefined) {
|
|
376
|
+
const detectText = shell.projection?.detectText ?? ''
|
|
377
|
+
let charBefore = span.start > 0 ? (detectText[span.start - 1] ?? '') : ''
|
|
378
|
+
|
|
379
|
+
if (!charBefore) {
|
|
380
|
+
try {
|
|
381
|
+
const sel = window.getSelection()
|
|
382
|
+
if (sel && sel.anchorNode) {
|
|
383
|
+
const text = sel.anchorNode.textContent ?? ''
|
|
384
|
+
const offset = sel.anchorOffset
|
|
385
|
+
if (offset > 0) charBefore = text[offset - 1] ?? ''
|
|
386
|
+
}
|
|
387
|
+
} catch {}
|
|
388
|
+
}
|
|
389
|
+
if (!charBefore) {
|
|
390
|
+
const draftText = shell.state?.getSnapshot?.()?.draft ?? ''
|
|
391
|
+
if (span.start > 0 && span.start <= draftText.length) {
|
|
392
|
+
charBefore = draftText[span.start - 1] ?? ''
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// If preceded by a non-whitespace character, insert a single space first so the chip doesn't merge
|
|
397
|
+
if (charBefore && !/\s/.test(charBefore)) {
|
|
398
|
+
if (typeof shell.actions?.insertText === 'function') {
|
|
399
|
+
try { shell.actions.insertText(' ', span) } catch {}
|
|
400
|
+
} else if (typeof shell.insertText === 'function') {
|
|
401
|
+
try { shell.insertText(' ', span) } catch {}
|
|
402
|
+
}
|
|
403
|
+
const updated = shell.actions?.captureInsertion?.() ?? shell.captureInsertion?.()
|
|
404
|
+
if (updated && updated.draftRev !== undefined) {
|
|
405
|
+
span = updated
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const revBefore = shell.rev ?? shell.state?.getSnapshot?.()?.draftRev ?? 0
|
|
410
|
+
let applied = false
|
|
411
|
+
|
|
412
|
+
// Try actx.bail first (Cordis bail returns the listener's return value!)
|
|
413
|
+
try {
|
|
414
|
+
if (typeof actx.bail === 'function') {
|
|
415
|
+
applied = Boolean(
|
|
416
|
+
actx.bail(actx, 'slash/input-insert-reference', {
|
|
417
|
+
reference: {
|
|
418
|
+
source: 'reference',
|
|
419
|
+
ref: mention,
|
|
420
|
+
label,
|
|
421
|
+
appearance,
|
|
422
|
+
clipboardText: mention,
|
|
423
|
+
},
|
|
424
|
+
span,
|
|
425
|
+
})
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
} catch {}
|
|
429
|
+
|
|
430
|
+
if (!applied) {
|
|
431
|
+
try {
|
|
432
|
+
actx.emit?.('slash/input-insert-reference', {
|
|
433
|
+
reference: {
|
|
434
|
+
source: 'reference',
|
|
435
|
+
ref: mention,
|
|
436
|
+
label,
|
|
437
|
+
appearance,
|
|
438
|
+
clipboardText: mention,
|
|
439
|
+
},
|
|
440
|
+
span,
|
|
441
|
+
})
|
|
442
|
+
} catch {}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const revAfter = shell.rev ?? shell.state?.getSnapshot?.()?.draftRev ?? 0
|
|
446
|
+
|
|
447
|
+
// If chip insertion applied or rev bumped, RETURN IMMEDIATELY (no fallthrough duplicate!)
|
|
448
|
+
if (applied || revAfter !== revBefore) {
|
|
449
|
+
shell.focus?.()
|
|
450
|
+
return true
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// If chip node insertion not handled, insert plain reference text via Lexical replaceText
|
|
454
|
+
let leadSpace = ''
|
|
455
|
+
if (charBefore && !/\s/.test(charBefore)) leadSpace = ' '
|
|
456
|
+
const textToInsert = `${leadSpace}${mention} `
|
|
457
|
+
if (typeof shell.actions?.insertText === 'function') {
|
|
458
|
+
try {
|
|
459
|
+
if (shell.actions.insertText(textToInsert, span)) {
|
|
460
|
+
shell.focus?.()
|
|
461
|
+
return true
|
|
462
|
+
}
|
|
463
|
+
} catch {}
|
|
464
|
+
}
|
|
465
|
+
if (typeof shell.insertText === 'function') {
|
|
466
|
+
try {
|
|
467
|
+
if (shell.insertText(textToInsert, span)) {
|
|
468
|
+
shell.focus?.()
|
|
469
|
+
return true
|
|
470
|
+
}
|
|
471
|
+
} catch {}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// 2. DOM fallback on Lexical contenteditable
|
|
478
|
+
const el = findComposerElement()
|
|
479
|
+
if (el) {
|
|
480
|
+
if (el.isContentEditable) {
|
|
481
|
+
el.focus()
|
|
482
|
+
let needLeadingSpace = false
|
|
483
|
+
try {
|
|
484
|
+
const sel = window.getSelection()
|
|
485
|
+
if (sel && sel.rangeCount > 0) {
|
|
486
|
+
const r = sel.getRangeAt(0)
|
|
487
|
+
if (r.startContainer.nodeType === Node.TEXT_NODE) {
|
|
488
|
+
const text = r.startContainer.textContent ?? ''
|
|
489
|
+
if (r.startOffset > 0 && !/\s/.test(text[r.startOffset - 1])) {
|
|
490
|
+
needLeadingSpace = true
|
|
491
|
+
}
|
|
492
|
+
} else if (r.startContainer.previousSibling) {
|
|
493
|
+
const text = r.startContainer.previousSibling.textContent ?? ''
|
|
494
|
+
if (text && !/\s/.test(text.slice(-1))) {
|
|
495
|
+
needLeadingSpace = true
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
} catch {}
|
|
500
|
+
|
|
501
|
+
const textToInsert = `${needLeadingSpace ? ' ' : ''}${mention} `
|
|
502
|
+
|
|
503
|
+
try {
|
|
504
|
+
if (document.execCommand('insertText', false, textToInsert)) {
|
|
505
|
+
recordLiveCaret()
|
|
506
|
+
return true
|
|
507
|
+
}
|
|
508
|
+
} catch {}
|
|
509
|
+
|
|
510
|
+
try {
|
|
511
|
+
const sel = window.getSelection()
|
|
512
|
+
if (sel && sel.rangeCount > 0) {
|
|
513
|
+
const range = sel.getRangeAt(0)
|
|
514
|
+
range.deleteContents()
|
|
515
|
+
const textNode = document.createTextNode(textToInsert)
|
|
516
|
+
range.insertNode(textNode)
|
|
517
|
+
range.collapse(false)
|
|
518
|
+
sel.removeAllRanges()
|
|
519
|
+
sel.addRange(range)
|
|
520
|
+
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
521
|
+
recordLiveCaret()
|
|
522
|
+
return true
|
|
523
|
+
}
|
|
524
|
+
} catch {}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
|
|
528
|
+
const textarea = el as HTMLTextAreaElement
|
|
529
|
+
const current = textarea.value
|
|
530
|
+
const rawCaret = probeComposerCaret(current, targetPoint) ?? {
|
|
531
|
+
start: textarea.selectionStart ?? current.length,
|
|
532
|
+
end: textarea.selectionEnd ?? current.length,
|
|
533
|
+
}
|
|
534
|
+
const { draft: next, caretAfter } = spliceInsert(current, `${mention} `, rawCaret)
|
|
535
|
+
textarea.value = next
|
|
536
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
|
537
|
+
placeComposerCaret(next, caretAfter)
|
|
538
|
+
return true
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// 3. Last-resort fallback: setDraft
|
|
543
|
+
if (conversation && actx) {
|
|
544
|
+
const shell = conversation.input?.for(actx)
|
|
545
|
+
if (shell?.setDraft) {
|
|
546
|
+
const currentDraft = shell.state?.getSnapshot?.()?.draft ?? ''
|
|
547
|
+
const rawCaret = probeComposerCaret(currentDraft, targetPoint)
|
|
548
|
+
const { draft: next, caretAfter } = spliceInsert(currentDraft, `${mention} `, rawCaret)
|
|
549
|
+
shell.setDraft(next)
|
|
550
|
+
placeComposerCaret(next, caretAfter)
|
|
551
|
+
return true
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return false
|
|
556
|
+
} catch (err) {
|
|
557
|
+
console.warn('[dsh-explorer] insertMentionToComposer failed:', err)
|
|
558
|
+
return false
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export function insertSnippetToComposer(
|
|
563
|
+
ctx: any,
|
|
564
|
+
sessionId: string,
|
|
565
|
+
relativePath: string,
|
|
566
|
+
content: string,
|
|
567
|
+
targetPoint?: { x: number; y: number },
|
|
568
|
+
): boolean {
|
|
569
|
+
try {
|
|
570
|
+
const ext = relativePath.slice(relativePath.lastIndexOf('.') + 1) || 'text'
|
|
571
|
+
const block = `\n\`\`\`${ext} title="${relativePath}"\n${content}\n\`\`\`\n`
|
|
572
|
+
|
|
573
|
+
const targetSessionId = resolveActiveSessionId(ctx, sessionId)
|
|
574
|
+
const sessions = safeService(ctx, 'sessions')
|
|
575
|
+
const actx = targetSessionId && sessions?.scope ? sessions.scope(targetSessionId) : undefined
|
|
576
|
+
const conversation = safeService(ctx, 'conversation')
|
|
577
|
+
|
|
578
|
+
if (conversation && actx) {
|
|
579
|
+
const shell = conversation.input?.for(actx)
|
|
580
|
+
if (shell) {
|
|
581
|
+
const span = shell.actions?.captureInsertion?.() ?? shell.captureInsertion?.()
|
|
582
|
+
if (span && span.draftRev !== undefined) {
|
|
583
|
+
if (typeof shell.actions?.insertText === 'function') {
|
|
584
|
+
try {
|
|
585
|
+
if (shell.actions.insertText(block, span)) {
|
|
586
|
+
shell.focus?.()
|
|
587
|
+
return true
|
|
588
|
+
}
|
|
589
|
+
} catch {}
|
|
590
|
+
}
|
|
591
|
+
if (typeof shell.insertText === 'function') {
|
|
592
|
+
try {
|
|
593
|
+
if (shell.insertText(block, span)) {
|
|
594
|
+
shell.focus?.()
|
|
595
|
+
return true
|
|
596
|
+
}
|
|
597
|
+
} catch {}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const el = findComposerElement()
|
|
604
|
+
if (el) {
|
|
605
|
+
if (el.isContentEditable) {
|
|
606
|
+
el.focus()
|
|
607
|
+
try {
|
|
608
|
+
if (document.execCommand('insertText', false, block)) {
|
|
609
|
+
recordLiveCaret()
|
|
610
|
+
return true
|
|
611
|
+
}
|
|
612
|
+
} catch {}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
|
|
616
|
+
const textarea = el as HTMLTextAreaElement
|
|
617
|
+
const current = textarea.value
|
|
618
|
+
const rawCaret = probeComposerCaret(current, targetPoint) ?? {
|
|
619
|
+
start: textarea.selectionStart ?? current.length,
|
|
620
|
+
end: textarea.selectionEnd ?? current.length,
|
|
621
|
+
}
|
|
622
|
+
const { draft: next, caretAfter } = spliceInsert(current, block, rawCaret)
|
|
623
|
+
textarea.value = next
|
|
624
|
+
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
|
625
|
+
placeComposerCaret(next, caretAfter)
|
|
626
|
+
return true
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return false
|
|
631
|
+
} catch (err) {
|
|
632
|
+
console.warn('[dsh-explorer] insertSnippetToComposer failed:', err)
|
|
633
|
+
return false
|
|
634
|
+
}
|
|
635
|
+
}
|