dsh-plugin-workbench 0.0.11 → 0.0.13
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/CHANGELOG.md +27 -0
- package/README.md +1 -0
- package/lib/client.js +346 -41
- package/lib/client.js.map +1 -1
- package/lib/index.js +35 -1
- package/package.json +1 -1
- package/src/client/FileExplorer.tsx +6 -3
- package/src/client/FilePreview.tsx +106 -6
- package/src/client/composer.ts +57 -14
- package/src/client/composerMentions.ts +166 -0
- package/src/client/index.ts +5 -2
- package/src/client/locales.ts +2 -2
- package/src/client/mentions.ts +44 -11
- package/src/dsh.d.ts +12 -0
- package/src/index.ts +37 -1
package/lib/index.js
CHANGED
|
@@ -9,7 +9,9 @@ const name = "dsh-plugin-workbench";
|
|
|
9
9
|
const inject = [
|
|
10
10
|
"fs",
|
|
11
11
|
"connection",
|
|
12
|
-
"webServer"
|
|
12
|
+
"webServer",
|
|
13
|
+
"systemPrompt",
|
|
14
|
+
"agents"
|
|
13
15
|
];
|
|
14
16
|
/** Loopback-only logical RPC channel. */
|
|
15
17
|
const CHANNEL = "/dsh-plugin-files";
|
|
@@ -229,11 +231,43 @@ function ensureLayoutPatch() {
|
|
|
229
231
|
}
|
|
230
232
|
}
|
|
231
233
|
/**
|
|
234
|
+
* Stable system-prompt section teaching the model the workbench `@.\` mention
|
|
235
|
+
* grammar. The core already contributes a generic "paths prefixed with @ are
|
|
236
|
+
* files referenced by the user" section (dsh-file-reference-local, order 99);
|
|
237
|
+
* this one documents the actual syntax the GUI inserts (the `.\` workspace
|
|
238
|
+
* marker, quoted form for paths with spaces) so the model resolves mentions
|
|
239
|
+
* against the session workspace root instead of treating `@.\…` as noise.
|
|
240
|
+
* Static text only — the string is identical on every assembly (KV-cache safe).
|
|
241
|
+
*/
|
|
242
|
+
const FILE_MENTION_PROMPT = "Workspace file mentions written by the file panel use the form `@.<relative-path>` (a `.` or `./` prefix marks a path relative to the current session workspace root; both `\\` and `/` separators are accepted, and paths with spaces use the quoted form `@\"<relative-path>\"`, for example `@\"\\.\\my plan.md\"`). Treat any such mention as a file the user wants you to read or edit: resolve the path against the workspace root and act on it with the file tools (read, glob, grep, edit, write); never claim to have inspected a file you did not actually open.";
|
|
243
|
+
/**
|
|
244
|
+
* File-mention grammar taught to the model (see the FILE_MENTION_PROMPT doc).
|
|
245
|
+
* One section per agent fiber: existing agents at apply time plus every agent
|
|
246
|
+
* created later (agent/created). The core already contributes a generic "paths
|
|
247
|
+
* prefixed with @ are files" section (dsh-file-reference-local, order 99);
|
|
248
|
+
* order 100 puts this more specific grammar right after it.
|
|
249
|
+
*/
|
|
250
|
+
function installMentionPrompt(ctx) {
|
|
251
|
+
const teach = (agent) => {
|
|
252
|
+
agent.ctx.systemPrompt.section({
|
|
253
|
+
name: "dsh-plugin-workbench:file-mention",
|
|
254
|
+
order: 100,
|
|
255
|
+
text: FILE_MENTION_PROMPT
|
|
256
|
+
});
|
|
257
|
+
};
|
|
258
|
+
for (const agent of ctx.agents.list()) teach(agent);
|
|
259
|
+
ctx.on("agent/created", (payload) => {
|
|
260
|
+
const { agent } = payload;
|
|
261
|
+
teach(agent);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
232
265
|
* One filesystem-backed RPC endpoint pair. Reads never mutate; `signal`
|
|
233
266
|
* cancels the underlying fs call (or aborts between steps).
|
|
234
267
|
*/
|
|
235
268
|
function apply(ctx) {
|
|
236
269
|
ensureLayoutPatch();
|
|
270
|
+
installMentionPrompt(ctx);
|
|
237
271
|
const watchState = {
|
|
238
272
|
dirs: /* @__PURE__ */ new Map(),
|
|
239
273
|
files: /* @__PURE__ */ new Map(),
|
package/package.json
CHANGED
|
@@ -10,7 +10,7 @@ import { FileIcon } from './fileIcons'
|
|
|
10
10
|
import type { FilesKey } from './locales'
|
|
11
11
|
import { cancelCut, clearClipboard, closeFilesUnder, copyToClipboard, expandPreview, openFile, popUndo, pushUndo, retargetFile, setCwd, toggleTheme, useClipboard, useTabsState } from './store'
|
|
12
12
|
import type { ClipboardItem, ClipboardMode, UndoEntry } from './store'
|
|
13
|
-
import { DRAG_TYPE, insertIntoComposer, relPathOf } from './composer'
|
|
13
|
+
import { DRAG_TYPE, composerMention, insertIntoComposer, relPathOf } from './composer'
|
|
14
14
|
|
|
15
15
|
export interface FsListEntry {
|
|
16
16
|
name: string
|
|
@@ -950,11 +950,14 @@ export function FileExplorer({
|
|
|
950
950
|
setSelected(all)
|
|
951
951
|
}, [children, expanded, root])
|
|
952
952
|
|
|
953
|
-
/**
|
|
953
|
+
/**
|
|
954
|
+
* Insert an `@.\<relative-workspace-path>` mention into the composer (the
|
|
955
|
+
* `.\` prefix marks the path as workspace-relative; see composerMention).
|
|
956
|
+
*/
|
|
954
957
|
const onMention = useCallback((path: string) => {
|
|
955
958
|
const mention = relPathOf(path, cwd)
|
|
956
959
|
if (mention.length === 0) return
|
|
957
|
-
insertIntoComposer(
|
|
960
|
+
insertIntoComposer(`${composerMention(mention)} `)
|
|
958
961
|
}, [cwd])
|
|
959
962
|
|
|
960
963
|
// Explorer-style keyboard: Ctrl/Cmd+C/X copy-cut, Ctrl/Cmd+V paste,
|
|
@@ -47,6 +47,22 @@ const PREVIEW_MIN = 240
|
|
|
47
47
|
const CHAT_MIN = 240
|
|
48
48
|
const PREVIEW_TOO_LARGE_LABEL = '512KB'
|
|
49
49
|
|
|
50
|
+
/** Persisted split width (px) — survives reloads so a drag is never lost. */
|
|
51
|
+
const PREVIEW_WIDTH_KEY = 'dsh-plugin-workbench:preview-width'
|
|
52
|
+
|
|
53
|
+
/** Read the persisted preview width; `null` means "use the default 55%". */
|
|
54
|
+
function storedPreviewWidth(): number | null {
|
|
55
|
+
try {
|
|
56
|
+
if (typeof window === 'undefined') return null
|
|
57
|
+
const raw = window.localStorage.getItem(PREVIEW_WIDTH_KEY)
|
|
58
|
+
if (raw === null) return null
|
|
59
|
+
const px = Number(raw)
|
|
60
|
+
return Number.isFinite(px) && px > 0 ? px : null
|
|
61
|
+
} catch {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
50
66
|
/** Same-origin raw-bytes route registered by the host half (see src/index.ts). */
|
|
51
67
|
const RAW_PREFIX = '/dsh-plugin-files/raw'
|
|
52
68
|
|
|
@@ -108,6 +124,23 @@ function clamp(value: number, min: number, max: number): number {
|
|
|
108
124
|
return Math.min(max, Math.max(min, value))
|
|
109
125
|
}
|
|
110
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Nearest ancestor that actually takes part in layout. The slot system wraps
|
|
129
|
+
* each slot's content in a `display: contents` element: children still join
|
|
130
|
+
* the OUTER flex row, but the wrapper itself reports a 0×0 bounding rect.
|
|
131
|
+
* Measuring that (the old `handle.parentElement`) made `max` collapse to
|
|
132
|
+
* `PREVIEW_MIN` on the first move — the pane jumped to its minimum width and
|
|
133
|
+
* could never be dragged back out. Skip every `display: contents` layer.
|
|
134
|
+
*/
|
|
135
|
+
function laidOutParent(el: HTMLElement | null): HTMLElement | null {
|
|
136
|
+
let node = el?.parentElement ?? null
|
|
137
|
+
while (node !== null) {
|
|
138
|
+
if (getComputedStyle(node).display !== 'contents') return node
|
|
139
|
+
node = node.parentElement
|
|
140
|
+
}
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
|
|
111
144
|
/** Parent directory of a path ('C:/a/b.md' → 'C:/a'; '' when there is none). */
|
|
112
145
|
function dirnameOf(path: string): string {
|
|
113
146
|
const idx = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
|
@@ -163,6 +196,10 @@ export function FilePreview({ t, readFile, writeFile, watchFiles }: FilePreviewP
|
|
|
163
196
|
const highlightRef = useRef<HTMLPreElement>(null)
|
|
164
197
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
165
198
|
const gutterRef = useRef<HTMLDivElement>(null)
|
|
199
|
+
// Live drag listeners, kept in refs so a new drag can ALWAYS clear any
|
|
200
|
+
// leftovers (a lost pointerup must not leak a stale onMove into the page).
|
|
201
|
+
const dragMoveRef = useRef<(ev: PointerEvent) => void>(() => undefined)
|
|
202
|
+
const dragUpRef = useRef<() => void>(() => undefined)
|
|
166
203
|
|
|
167
204
|
const refresh = useCallback(() => bump((v) => v + 1), [])
|
|
168
205
|
|
|
@@ -391,26 +428,89 @@ export function FilePreview({ t, readFile, writeFile, watchFiles }: FilePreviewP
|
|
|
391
428
|
}
|
|
392
429
|
}, [isOpen])
|
|
393
430
|
|
|
431
|
+
// Apply the persisted width once the pane gets its first real layout: the
|
|
432
|
+
// saved px may exceed the CURRENT center column (window resized since the
|
|
433
|
+
// last drag), so clamp it against a live measurement before applying —
|
|
434
|
+
// otherwise a stale wide value would squeeze the chat seat to nothing.
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
const saved = storedPreviewWidth()
|
|
437
|
+
if (saved === null) return
|
|
438
|
+
const preview = previewRef.current
|
|
439
|
+
const center = laidOutParent(preview)
|
|
440
|
+
if (preview === null || preview === undefined || center === null || center === undefined) return
|
|
441
|
+
const centerWidth = center.getBoundingClientRect().width
|
|
442
|
+
const max = Math.max(PREVIEW_MIN, centerWidth - CHAT_MIN)
|
|
443
|
+
setPreviewWidth(clamp(saved, PREVIEW_MIN, max))
|
|
444
|
+
}, [isOpen])
|
|
445
|
+
|
|
446
|
+
// Unmount during a drag (slot removed mid-drag): drop the window listeners
|
|
447
|
+
// so no stale move handler survives to rewrite widths in the next mount.
|
|
448
|
+
useEffect(() => () => {
|
|
449
|
+
window.removeEventListener('pointermove', dragMoveRef.current)
|
|
450
|
+
window.removeEventListener('pointerup', dragUpRef.current)
|
|
451
|
+
window.removeEventListener('pointercancel', dragUpRef.current)
|
|
452
|
+
}, [])
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Start a split-width drag. Robustness notes (the "pane suddenly shrinks
|
|
456
|
+
* and freezes" bug class):
|
|
457
|
+
*
|
|
458
|
+
* - Pointer capture reroutes every later pointer event to the handle, so
|
|
459
|
+
* `pointerup` fires EVEN when the mouse is released outside the window.
|
|
460
|
+
* Without it the up event is lost, `onUp` never runs, and the leftover
|
|
461
|
+
* `onMove` keeps rewriting the width from its stale baseline on every
|
|
462
|
+
* mouse move anywhere on the page — that is the "cannot drag / cannot
|
|
463
|
+
* restore" state.
|
|
464
|
+
* - `pointercancel` is cleaned up too (browser steals the pointer, e.g. a
|
|
465
|
+
* tablet palm or an OS gesture).
|
|
466
|
+
* - Pointerdown defensively removes any previous listeners first, so even a
|
|
467
|
+
* capture-less leftover cannot survive into a second drag.
|
|
468
|
+
* - The max is re-measured every move: the chat minimum is relative to the
|
|
469
|
+
* CURRENT center column, which can change while the drag is in flight.
|
|
470
|
+
*/
|
|
394
471
|
const onHandleDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
|
|
395
472
|
e.preventDefault()
|
|
396
473
|
const handle = handleRef.current
|
|
397
474
|
const preview = previewRef.current
|
|
398
475
|
if (handle === null || preview === null) return
|
|
399
|
-
|
|
476
|
+
// NOT handle.parentElement: the slot wrapper is `display: contents` and
|
|
477
|
+
// measures 0×0 — the flex container to clamp against is one level up (see
|
|
478
|
+
// laidOutParent). Measuring the wrapper made every drag snap to
|
|
479
|
+
// PREVIEW_MIN and then stick (the reported "sudden shrink / can't drag").
|
|
480
|
+
const center = laidOutParent(handle)
|
|
400
481
|
if (center === null) return
|
|
401
482
|
const startX = e.clientX
|
|
402
483
|
const startWidth = preview.getBoundingClientRect().width
|
|
403
|
-
const centerWidth = center.getBoundingClientRect().width
|
|
404
|
-
const max = Math.max(PREVIEW_MIN, centerWidth - CHAT_MIN)
|
|
405
484
|
const onMove = (ev: PointerEvent) => {
|
|
406
|
-
|
|
485
|
+
const centerWidth = center.getBoundingClientRect().width
|
|
486
|
+
const max = Math.max(PREVIEW_MIN, centerWidth - CHAT_MIN)
|
|
487
|
+
const width = clamp(startWidth + ev.clientX - startX, PREVIEW_MIN, max)
|
|
488
|
+
setPreviewWidth(width)
|
|
489
|
+
try {
|
|
490
|
+
window.localStorage.setItem(PREVIEW_WIDTH_KEY, String(width))
|
|
491
|
+
} catch {
|
|
492
|
+
// storage unavailable — the width just won't persist across reloads
|
|
493
|
+
}
|
|
407
494
|
}
|
|
408
495
|
const onUp = () => {
|
|
409
|
-
window.removeEventListener('pointermove',
|
|
410
|
-
window.removeEventListener('pointerup',
|
|
496
|
+
window.removeEventListener('pointermove', dragMoveRef.current)
|
|
497
|
+
window.removeEventListener('pointerup', dragUpRef.current)
|
|
498
|
+
window.removeEventListener('pointercancel', dragUpRef.current)
|
|
411
499
|
}
|
|
500
|
+
// Defensive reset of any previous drag (see the doc comment above).
|
|
501
|
+
window.removeEventListener('pointermove', dragMoveRef.current)
|
|
502
|
+
window.removeEventListener('pointerup', dragUpRef.current)
|
|
503
|
+
window.removeEventListener('pointercancel', dragUpRef.current)
|
|
504
|
+
dragMoveRef.current = onMove
|
|
505
|
+
dragUpRef.current = onUp
|
|
412
506
|
window.addEventListener('pointermove', onMove)
|
|
413
507
|
window.addEventListener('pointerup', onUp)
|
|
508
|
+
window.addEventListener('pointercancel', onUp)
|
|
509
|
+
try {
|
|
510
|
+
handle.setPointerCapture(e.pointerId)
|
|
511
|
+
} catch {
|
|
512
|
+
// Pointer capture unsupported — the window listeners still cover the drag.
|
|
513
|
+
}
|
|
414
514
|
}, [])
|
|
415
515
|
|
|
416
516
|
const onSave = useCallback(async () => {
|
package/src/client/composer.ts
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Composer integration for the workbench file column.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Three gestures land text in the chat composer without touching the core:
|
|
5
5
|
*
|
|
6
6
|
* 1. Drag & drop — dragging one or more tree rows and dropping ANYWHERE
|
|
7
7
|
* outside the file column (the chat, the composer, the message list)
|
|
8
|
-
* inserts the dragged paths into the composer
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* inserts the dragged paths into the composer as `@.\` mentions (falling
|
|
9
|
+
* back to the absolute path when the file sits outside the workspace).
|
|
10
|
+
* Dropping INSIDE the file column still performs the tree's own move
|
|
11
|
+
* operation — the tree's drop handler runs first and stops propagation, so
|
|
12
|
+
* this document-level listener never sees those drops.
|
|
12
13
|
*
|
|
13
|
-
* 2. Context-menu "@引用" — inserts
|
|
14
|
-
* composer caret
|
|
14
|
+
* 2. Context-menu "@引用" — inserts `@.\<relative-workspace-path>` at the
|
|
15
|
+
* composer caret (the `.\` prefix marks the path as workspace-relative; a
|
|
16
|
+
* path containing whitespace uses the quoted `@"\.\path with space"` form).
|
|
17
|
+
*
|
|
18
|
+
* 3. @-mention resolution — turns a mention token (with or without the `.\`
|
|
19
|
+
* prefix, quoted or plain) into an absolute path against the session cwd;
|
|
20
|
+
* used by the message linkifier and the composer overlay to open the file
|
|
21
|
+
* in the workbench preview.
|
|
15
22
|
*
|
|
16
23
|
* The composer is a controlled React textarea, so the value is updated
|
|
17
24
|
* through the native `value` setter + a bubbling `input` event (the standard
|
|
@@ -41,11 +48,23 @@ function onDocumentDrop(e: DragEvent): void {
|
|
|
41
48
|
if (dt === null || !dt.types.includes(DRAG_TYPE)) return
|
|
42
49
|
const paths = readDraggedPaths(dt)
|
|
43
50
|
if (paths.length === 0) return
|
|
44
|
-
if (!insertIntoComposer(paths.join('\n'))) return
|
|
51
|
+
if (!insertIntoComposer(paths.map(dragMentionText).join('\n'))) return
|
|
45
52
|
e.preventDefault()
|
|
46
53
|
e.stopPropagation()
|
|
47
54
|
}
|
|
48
55
|
|
|
56
|
+
/**
|
|
57
|
+
* One dropped path as chat text: an `@.\` mention when it lives under the
|
|
58
|
+
* workspace, the absolute path otherwise (or when no cwd is known yet).
|
|
59
|
+
*/
|
|
60
|
+
function dragMentionText(path: string): string {
|
|
61
|
+
const { cwd } = getTabsState()
|
|
62
|
+
if (cwd === undefined) return path
|
|
63
|
+
const rel = relPathOf(path, cwd)
|
|
64
|
+
if (rel.length === 0 || rel === path) return path
|
|
65
|
+
return composerMention(rel)
|
|
66
|
+
}
|
|
67
|
+
|
|
49
68
|
/** Read the JSON paths from the custom type; falls back to raw text. */
|
|
50
69
|
export function readDraggedPaths(dt: DataTransfer | null): string[] {
|
|
51
70
|
if (dt === null) return []
|
|
@@ -118,17 +137,41 @@ export function relPathOf(path: string, cwd: string | undefined): string {
|
|
|
118
137
|
}
|
|
119
138
|
|
|
120
139
|
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
140
|
+
* Format a workspace-relative path as an `@` mention for the composer: the
|
|
141
|
+
* `.\` (or `./`) prefix marks the path as relative to the workspace root, and
|
|
142
|
+
* a path containing whitespace uses the quoted `@"..."` form so it stays one
|
|
143
|
+
* token in the draft (and one link in the rendered message).
|
|
144
|
+
*/
|
|
145
|
+
export function composerMention(rel: string): string {
|
|
146
|
+
const sep = rel.includes('\\') ? '\\' : '/'
|
|
147
|
+
const raw = `@.${sep}${rel}`
|
|
148
|
+
if (/[\s"]/.test(raw)) return `@"${raw}"`
|
|
149
|
+
return raw
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Strip quote wrapping and a leading `.\` / `./` workspace marker. */
|
|
153
|
+
function normalizeMention(mention: string): string {
|
|
154
|
+
let inner = mention
|
|
155
|
+
if (inner.startsWith('"') && inner.endsWith('"') && inner.length >= 2) inner = inner.slice(1, -1)
|
|
156
|
+
if (inner.startsWith('.\\') || inner.startsWith('./')) inner = inner.slice(2)
|
|
157
|
+
return inner
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Resolve an @-mention path (workspace-relative with or without the `.\`
|
|
162
|
+
* marker, or absolute) against the current workspace cwd; returns the absolute
|
|
163
|
+
* OS path, or undefined when the mention cannot be resolved (no cwd and the
|
|
164
|
+
* path is not absolute).
|
|
124
165
|
*/
|
|
125
166
|
export function resolveMentionPath(mention: string): string | undefined {
|
|
126
|
-
const
|
|
167
|
+
const inner = normalizeMention(mention)
|
|
168
|
+
if (inner.length === 0) return undefined
|
|
169
|
+
const absolute = /^[A-Za-z]:[\\/]/.test(inner) || inner.startsWith('/') || inner.startsWith('\\')
|
|
127
170
|
const { cwd } = getTabsState()
|
|
128
|
-
if (absolute) return
|
|
171
|
+
if (absolute) return inner
|
|
129
172
|
if (cwd === undefined) return undefined
|
|
130
173
|
const sep = cwd.includes('\\') ? '\\' : '/'
|
|
131
|
-
return cwd.endsWith('\\') || cwd.endsWith('/') ? cwd +
|
|
174
|
+
return cwd.endsWith('\\') || cwd.endsWith('/') ? cwd + inner : cwd + sep + inner
|
|
132
175
|
}
|
|
133
176
|
|
|
134
177
|
/** Open an @-mention's file in the workbench preview (used by the linkifier). */
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composer @-mention hyperlink enhancement.
|
|
3
|
+
*
|
|
4
|
+
* The composer's visible text lives in a core-rendered backdrop (`textarea`
|
|
5
|
+
* text is transparent) that React re-renders on every keystroke, so plugin
|
|
6
|
+
* code must not mutate it. Instead this module renders its OWN overlay — an
|
|
7
|
+
* absolutely-positioned copy of the draft, in the same font/padding/wrap
|
|
8
|
+
* metrics as the textarea (copied from the core `.input,.mirror,.backdrop`
|
|
9
|
+
* rule), with every `@.\`-style mention drawn in the link color and
|
|
10
|
+
* underlined. The overlay's plain text is fully transparent, so the visible
|
|
11
|
+
* glyphs still come from the core backdrop; mention spans paint on top at the
|
|
12
|
+
* identical position (same metrics => same layout), which shows the mention as
|
|
13
|
+
* a real hyperlink inside the chat input.
|
|
14
|
+
*
|
|
15
|
+
* The overlay is appended directly to the core `.grow` container (a sibling
|
|
16
|
+
* of the backdrop/mirror) — React never manages nodes it did not create, and
|
|
17
|
+
* the overlay is `position:absolute;inset:0` so it always tracks the input
|
|
18
|
+
* box, including scroll inside `[data-input-scroll]`. It re-syncs whenever the
|
|
19
|
+
* core `[data-input-mirror]` text changes (a React-written text node, so it
|
|
20
|
+
* updates for typing AND programmatic inserts such as the core `@` menu or
|
|
21
|
+
* this plugin's own insertIntoComposer).
|
|
22
|
+
*
|
|
23
|
+
* Interaction: Ctrl/Cmd+click inside the composer textarea opens the mention
|
|
24
|
+
* under the caret in the workbench preview (the visible link is a decoration;
|
|
25
|
+
* the real input gains the click), mirroring the rendered-message linkifier.
|
|
26
|
+
*/
|
|
27
|
+
import { openMention } from './composer'
|
|
28
|
+
import { findMentions } from './mentions'
|
|
29
|
+
|
|
30
|
+
/** One-time install guard (the client bundle re-applies on HMR). */
|
|
31
|
+
let installed = false
|
|
32
|
+
|
|
33
|
+
/** Style-tag guard (the bundle may re-apply on HMR). */
|
|
34
|
+
let styleInstalled = false
|
|
35
|
+
|
|
36
|
+
const OVERLAY_CSS = [
|
|
37
|
+
// Layout metrics must match the core rule for `.input,.mirror,.backdrop`
|
|
38
|
+
// exactly, so the overlay's invisible text wraps identically to the visible
|
|
39
|
+
// backdrop text and mentions paint at the right place.
|
|
40
|
+
'[data-wb-composer-mention-overlay]{position:absolute;inset:0;overflow:hidden;pointer-events:none;box-sizing:border-box;font-family:var(--dsw-font-family);font-size:inherit;line-height:inherit;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;padding:4px 12px 0 16px;color:transparent}',
|
|
41
|
+
'[data-wb-composer-mention-overlay][hidden]{display:none}',
|
|
42
|
+
'.dswb-composer-mention{color:var(--dsw-alias-state-business-primary);-webkit-text-fill-color:var(--dsw-alias-state-business-primary);text-decoration:underline;text-underline-offset:2px}',
|
|
43
|
+
].join('')
|
|
44
|
+
|
|
45
|
+
function installStyle(): void {
|
|
46
|
+
if (styleInstalled || typeof document === 'undefined') return
|
|
47
|
+
styleInstalled = true
|
|
48
|
+
const tagId = 'dsh-plugin-workbench/composer-mention.module.css'
|
|
49
|
+
if (document.querySelector(`style[data-plugin-css=${JSON.stringify(tagId)}]`) === null) {
|
|
50
|
+
const tag = document.createElement('style')
|
|
51
|
+
tag.dataset.plugin = 'dsh-plugin-workbench'
|
|
52
|
+
tag.dataset.pluginCss = tagId
|
|
53
|
+
tag.textContent = OVERLAY_CSS
|
|
54
|
+
document.head.appendChild(tag)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The overlay element for the currently visible composer, or null. */
|
|
59
|
+
let overlayEl: HTMLElement | null = null
|
|
60
|
+
|
|
61
|
+
/** Last draft rendered into the overlay (avoids re-rendering on own mutations). */
|
|
62
|
+
let lastDraft: string | null = null
|
|
63
|
+
|
|
64
|
+
/** Create (once per composer instance) and return the overlay inside `.grow`. */
|
|
65
|
+
function ensureOverlay(): HTMLElement | null {
|
|
66
|
+
const seat = document.querySelector('[data-composer-seat]')
|
|
67
|
+
if (seat === null) return null
|
|
68
|
+
const mirror = seat.querySelector<HTMLElement>('[data-input-mirror]')
|
|
69
|
+
const grow = mirror?.parentElement
|
|
70
|
+
if (grow === null || grow === undefined) return null
|
|
71
|
+
let overlay = grow.querySelector<HTMLElement>('[data-wb-composer-mention-overlay]')
|
|
72
|
+
if (overlay === null) {
|
|
73
|
+
overlay = document.createElement('div')
|
|
74
|
+
overlay.dataset.wbComposerMentionOverlay = ''
|
|
75
|
+
overlay.setAttribute('aria-hidden', 'true')
|
|
76
|
+
grow.appendChild(overlay)
|
|
77
|
+
}
|
|
78
|
+
if (overlayEl !== overlay) {
|
|
79
|
+
overlayEl = overlay
|
|
80
|
+
lastDraft = null
|
|
81
|
+
}
|
|
82
|
+
return overlay
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Render the (transparent) draft with mention spans into the overlay. */
|
|
86
|
+
function render(overlay: HTMLElement, draft: string): void {
|
|
87
|
+
const hits = findMentions(draft)
|
|
88
|
+
if (hits.length === 0) {
|
|
89
|
+
overlay.hidden = true
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
overlay.hidden = false
|
|
93
|
+
const frag = document.createDocumentFragment()
|
|
94
|
+
let cursor = 0
|
|
95
|
+
for (const hit of hits) {
|
|
96
|
+
if (hit.start > cursor) frag.appendChild(document.createTextNode(draft.slice(cursor, hit.start)))
|
|
97
|
+
const span = document.createElement('span')
|
|
98
|
+
span.className = 'dswb-composer-mention'
|
|
99
|
+
span.dataset.wbMention = hit.mention
|
|
100
|
+
span.textContent = draft.slice(hit.start, hit.end)
|
|
101
|
+
frag.appendChild(span)
|
|
102
|
+
cursor = hit.end
|
|
103
|
+
}
|
|
104
|
+
if (cursor < draft.length) frag.appendChild(document.createTextNode(draft.slice(cursor)))
|
|
105
|
+
overlay.replaceChildren(frag)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Re-sync the overlay with the composer draft (cheap no-op when unchanged). */
|
|
109
|
+
function sync(): void {
|
|
110
|
+
const seat = document.querySelector('[data-composer-seat]')
|
|
111
|
+
if (seat === null) return
|
|
112
|
+
// Skip the hero workspace picker / disabled states — only a usable input gets decorated.
|
|
113
|
+
if (seat.querySelector('textarea:not([disabled]):not([readonly])') === null) return
|
|
114
|
+
const mirror = seat.querySelector<HTMLElement>('[data-input-mirror]')
|
|
115
|
+
const overlay = ensureOverlay()
|
|
116
|
+
if (mirror === null || overlay === null) return
|
|
117
|
+
// The mirror renders `${draft}\n`; strip exactly that trailing newline.
|
|
118
|
+
const draft = (mirror.textContent ?? '').replace(/\n$/, '')
|
|
119
|
+
if (draft === lastDraft) return
|
|
120
|
+
lastDraft = draft
|
|
121
|
+
render(overlay, draft)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Ctrl/Cmd+click on the composer: open the mention under the caret. */
|
|
125
|
+
function onDocumentClick(e: MouseEvent): void {
|
|
126
|
+
if (!(e.ctrlKey || e.metaKey)) return
|
|
127
|
+
const target = e.target
|
|
128
|
+
if (!(target instanceof HTMLTextAreaElement)) return
|
|
129
|
+
if (target.closest('[data-composer-seat]') === null) return
|
|
130
|
+
// The caret has already moved to the clicked character when click fires.
|
|
131
|
+
const pos = target.selectionStart
|
|
132
|
+
const draft = target.value
|
|
133
|
+
const hits = findMentions(draft)
|
|
134
|
+
let hit = hits.find((h) => pos >= h.start && pos <= h.end)
|
|
135
|
+
if (hit === undefined) hit = hits.find((h) => pos - 1 >= h.start && pos - 1 < h.end)
|
|
136
|
+
if (hit === undefined) return
|
|
137
|
+
e.preventDefault()
|
|
138
|
+
e.stopPropagation()
|
|
139
|
+
openMention(hit.mention)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Start the composer mention overlay + Ctrl+click opener (idempotent). */
|
|
143
|
+
export function installComposerMentions(): () => void {
|
|
144
|
+
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return () => undefined
|
|
145
|
+
if (installed) return () => undefined
|
|
146
|
+
installed = true
|
|
147
|
+
installStyle()
|
|
148
|
+
document.addEventListener('click', onDocumentClick)
|
|
149
|
+
let pending = false
|
|
150
|
+
const scan = (): void => {
|
|
151
|
+
pending = false
|
|
152
|
+
sync()
|
|
153
|
+
}
|
|
154
|
+
const observer = new MutationObserver(() => {
|
|
155
|
+
if (pending) return
|
|
156
|
+
pending = true
|
|
157
|
+
requestAnimationFrame(scan)
|
|
158
|
+
})
|
|
159
|
+
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
|
|
160
|
+
scan()
|
|
161
|
+
return () => {
|
|
162
|
+
observer.disconnect()
|
|
163
|
+
document.removeEventListener('click', onDocumentClick)
|
|
164
|
+
installed = false
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { FilePreview } from './FilePreview'
|
|
|
13
13
|
import { NS, zh, en } from './locales'
|
|
14
14
|
import { installComposerDrops } from './composer'
|
|
15
15
|
import { installMentionLinkifier } from './mentions'
|
|
16
|
+
import { installComposerMentions } from './composerMentions'
|
|
16
17
|
|
|
17
18
|
const CHANNEL = '/dsh-plugin-files'
|
|
18
19
|
|
|
@@ -22,9 +23,11 @@ export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'connection'
|
|
|
22
23
|
export function apply(ctx: Context): void {
|
|
23
24
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'files-explorer: dictionaries')
|
|
24
25
|
|
|
25
|
-
// Drag-to-chat
|
|
26
|
-
// install (module guards make re-applies on
|
|
26
|
+
// Drag-to-chat, @-mention linkifier, and composer mention hyperlinks:
|
|
27
|
+
// DOM-level integrations, one-time install (module guards make re-applies on
|
|
28
|
+
// HMR idempotent).
|
|
27
29
|
installComposerDrops()
|
|
30
|
+
installComposerMentions()
|
|
28
31
|
ctx.effect(() => installMentionLinkifier(), 'files-explorer: @mention linkifier')
|
|
29
32
|
|
|
30
33
|
const listDir = (path: string, signal?: AbortSignal) => unwrap(ctx.connection.rpc.call(CHANNEL, 'list', { path }, signal))
|
package/src/client/locales.ts
CHANGED
|
@@ -18,7 +18,7 @@ export const zh = {
|
|
|
18
18
|
'tab.expand': '弹出文件详情',
|
|
19
19
|
'tab.diskChanged': '文件已在磁盘上被修改,点击重新加载(会放弃未保存的编辑)',
|
|
20
20
|
'menu.open': '打开预览',
|
|
21
|
-
'menu.atFile': '
|
|
21
|
+
'menu.atFile': '@.\ 在消息中引用',
|
|
22
22
|
'menu.newFile': '新建文件',
|
|
23
23
|
'menu.newFolder': '新建文件夹',
|
|
24
24
|
'menu.rename': '重命名',
|
|
@@ -79,7 +79,7 @@ export const en: Record<FilesKey, string> = {
|
|
|
79
79
|
'tab.expand': 'Expand file details',
|
|
80
80
|
'tab.diskChanged': 'File changed on disk — click to reload (discards unsaved edits)',
|
|
81
81
|
'menu.open': 'Open preview',
|
|
82
|
-
'menu.atFile': '
|
|
82
|
+
'menu.atFile': '@.\ Mention in message',
|
|
83
83
|
'menu.newFile': 'New File',
|
|
84
84
|
'menu.newFolder': 'New Folder',
|
|
85
85
|
'menu.rename': 'Rename',
|
package/src/client/mentions.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @-mention linkifier for the conversation.
|
|
3
3
|
*
|
|
4
|
-
* The workbench inserts
|
|
5
|
-
* gesture "在消息中引用"
|
|
6
|
-
*
|
|
7
|
-
* matches the mention grammar is wrapped
|
|
8
|
-
* the file in the workbench preview.
|
|
4
|
+
* The workbench inserts `@.\<relative-workspace-path>` into the composer (menu
|
|
5
|
+
* gesture "在消息中引用"; the `.\` prefix marks a workspace-relative path), and
|
|
6
|
+
* this module makes the mention VISIBLE as a hyperlink once the message is
|
|
7
|
+
* rendered: any `@`-prefixed token that matches the mention grammar is wrapped
|
|
8
|
+
* in an anchor, and clicking it opens the file in the workbench preview.
|
|
9
9
|
*
|
|
10
10
|
* Grammar (anything else stays plain text with no special meaning):
|
|
11
11
|
* - `@` must sit at a token boundary (start of text, whitespace, or
|
|
@@ -14,8 +14,12 @@
|
|
|
14
14
|
* - the token is the longest run of non-whitespace, non-`@` characters,
|
|
15
15
|
* with trailing sentence punctuation trimmed (`.。 ,, ;; :: !! ?? ))`…);
|
|
16
16
|
* - the remaining token must be a RELATIVE path (never drive-absolute or
|
|
17
|
-
* leading-slash)
|
|
18
|
-
*
|
|
17
|
+
* leading-slash) — optionally prefixed with a `.\` or `./` workspace
|
|
18
|
+
* marker — and path-shaped: either contains a `/` or `\` directory
|
|
19
|
+
* separator, or is a single segment ending in a file extension;
|
|
20
|
+
* - a path containing whitespace may instead use the quoted `@"token"` form
|
|
21
|
+
* (the workbench writes it when the rel path has spaces, e.g.
|
|
22
|
+
* `@"\.\my plan.md"`).
|
|
19
23
|
*
|
|
20
24
|
* Scanning mirrors the table-zoom enhancer: a MutationObserver on
|
|
21
25
|
* `document.body`, rAF-coalesced, walks the text nodes of every
|
|
@@ -28,6 +32,9 @@ import { openMention } from './composer'
|
|
|
28
32
|
/** Mention pattern: `@` + token (no whitespace, no embedded `@`). */
|
|
29
33
|
const MENTION_RE = /@([^\s@]+)/g
|
|
30
34
|
|
|
35
|
+
/** Quoted mention pattern: `@"token"` — used when the path contains whitespace. */
|
|
36
|
+
const QUOTED_MENTION_RE = /@"([^"@]+)"/g
|
|
37
|
+
|
|
31
38
|
/** Trailing characters trimmed from a mention token before validation. */
|
|
32
39
|
const TRAILING = new Set(['.', ',', ';', ':', '!', '?', '。', ',', ';', ':', '!', '?', ')', ')', ']', '】', '}', '》', '」', '』', '"', "'"])
|
|
33
40
|
|
|
@@ -51,9 +58,10 @@ export function isMentionToken(token: string): boolean {
|
|
|
51
58
|
if (token.startsWith('/') || token.startsWith('\\')) return false
|
|
52
59
|
if (token.startsWith('..')) return false
|
|
53
60
|
// Path-shaped: a directory separator anywhere, or a single file segment
|
|
54
|
-
// with an extension.
|
|
61
|
+
// with an extension. Spaces are allowed in the extension part — unquoted
|
|
62
|
+
// tokens never contain whitespace anyway, but `@"..."` paths may.
|
|
55
63
|
if (token.includes('/') || token.includes('\\')) return true
|
|
56
|
-
return /^[^\\/]+\.[A-Za-z0-9_][A-Za-z0-9._
|
|
64
|
+
return /^[^\\/]+\.[A-Za-z0-9_][A-Za-z0-9._~+ -]*$/.test(token)
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
/** Trim trailing sentence punctuation from a raw mention token. */
|
|
@@ -68,7 +76,9 @@ export function trimMentionToken(raw: string): string {
|
|
|
68
76
|
/**
|
|
69
77
|
* Extract every valid mention from `text` as [start, end, mention] ranges.
|
|
70
78
|
* `end` covers `@` + the TRIMMED token (trailing punctuation stays outside the
|
|
71
|
-
* link).
|
|
79
|
+
* link). For the quoted `@"..."` form the span covers `@"..."` including both
|
|
80
|
+
* quotes, while `mention` carries the inner path (used for resolution).
|
|
81
|
+
* Pure and testable: the DOM walk uses it and then splits the text node.
|
|
72
82
|
*/
|
|
73
83
|
export function findMentions(text: string): Array<{ start: number; end: number; mention: string }> {
|
|
74
84
|
const hits: Array<{ start: number; end: number; mention: string }> = []
|
|
@@ -82,7 +92,30 @@ export function findMentions(text: string): Array<{ start: number; end: number;
|
|
|
82
92
|
if (!isMentionToken(token)) continue
|
|
83
93
|
hits.push({ start: at, end: at + 1 + token.length, mention: token })
|
|
84
94
|
}
|
|
85
|
-
|
|
95
|
+
QUOTED_MENTION_RE.lastIndex = 0
|
|
96
|
+
let quoted: RegExpExecArray | null
|
|
97
|
+
while ((quoted = QUOTED_MENTION_RE.exec(text)) !== null) {
|
|
98
|
+
const at = quoted.index
|
|
99
|
+
const inner = quoted[1]
|
|
100
|
+
if (!isBoundaryBefore(at > 0 ? text[at - 1] : undefined)) continue
|
|
101
|
+
if (!isMentionToken(inner)) continue
|
|
102
|
+
hits.push({ start: at, end: at + inner.length + 3, mention: inner })
|
|
103
|
+
}
|
|
104
|
+
// Deterministic order, and a quoted span that starts on the same `@` as a
|
|
105
|
+
// plain one shadows that plain hit (plain hits on `@"` tokens are invalid
|
|
106
|
+
// and filtered out already, so this only guards future grammar regressions).
|
|
107
|
+
hits.sort((a, b) => a.start - b.start || a.end - b.end)
|
|
108
|
+
const merged: Array<{ start: number; end: number; mention: string }> = []
|
|
109
|
+
for (const hit of hits) {
|
|
110
|
+
const prev = merged[merged.length - 1]
|
|
111
|
+
if (prev !== undefined && hit.start === prev.start) {
|
|
112
|
+
merged[merged.length - 1] = hit
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
if (prev !== undefined && hit.start < prev.end) continue
|
|
116
|
+
merged.push(hit)
|
|
117
|
+
}
|
|
118
|
+
return merged
|
|
86
119
|
}
|
|
87
120
|
|
|
88
121
|
/** Containers whose text is never linkified (code, existing links, overlays). */
|