dsh-plugin-workbench 0.0.8 → 0.0.10
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 +39 -0
- package/README.md +20 -6
- package/lib/client.js +444 -18
- package/lib/client.js.map +1 -1
- package/lib/index.js +95 -7
- package/package.json +1 -1
- package/src/client/FileExplorer.tsx +106 -25
- package/src/client/composer.ts +139 -0
- package/src/client/index.ts +7 -0
- package/src/client/locales.ts +6 -0
- package/src/client/mentions.ts +207 -0
- package/src/index.ts +101 -8
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @-mention linkifier for the conversation.
|
|
3
|
+
*
|
|
4
|
+
* The workbench inserts `@<relative-workspace-path>` into the composer (menu
|
|
5
|
+
* gesture "在消息中引用"), and this module makes the mention VISIBLE as a
|
|
6
|
+
* hyperlink once the message is rendered: any `@` followed by a token that
|
|
7
|
+
* matches the mention grammar is wrapped in an anchor, and clicking it opens
|
|
8
|
+
* the file in the workbench preview.
|
|
9
|
+
*
|
|
10
|
+
* Grammar (anything else stays plain text with no special meaning):
|
|
11
|
+
* - `@` must sit at a token boundary (start of text, whitespace, or
|
|
12
|
+
* punctuation like `((` `,,` `。.` quotes and brackets) — so `user@x`,
|
|
13
|
+
* `a.b@c` and URLs never match;
|
|
14
|
+
* - the token is the longest run of non-whitespace, non-`@` characters,
|
|
15
|
+
* with trailing sentence punctuation trimmed (`.。 ,, ;; :: !! ?? ))`…);
|
|
16
|
+
* - the remaining token must be a RELATIVE path (never drive-absolute or
|
|
17
|
+
* leading-slash), and path-shaped: either contains a `/` or `\` directory
|
|
18
|
+
* separator, or is a single segment ending in a file extension.
|
|
19
|
+
*
|
|
20
|
+
* Scanning mirrors the table-zoom enhancer: a MutationObserver on
|
|
21
|
+
* `document.body`, rAF-coalesced, walks the text nodes of every
|
|
22
|
+
* `[data-conversation-scroll]` subtree and skips protected containers
|
|
23
|
+
* (code blocks, existing links, aria-hidden overlays like the composer
|
|
24
|
+
* mirror/backdrop, the composer seat, popups, and the workbench column).
|
|
25
|
+
*/
|
|
26
|
+
import { openMention } from './composer'
|
|
27
|
+
|
|
28
|
+
/** Mention pattern: `@` + token (no whitespace, no embedded `@`). */
|
|
29
|
+
const MENTION_RE = /@([^\s@]+)/g
|
|
30
|
+
|
|
31
|
+
/** Trailing characters trimmed from a mention token before validation. */
|
|
32
|
+
const TRAILING = new Set(['.', ',', ';', ':', '!', '?', '。', ',', ';', ':', '!', '?', ')', ')', ']', '】', '}', '》', '」', '』', '"', "'"])
|
|
33
|
+
|
|
34
|
+
/** Characters that may legally precede `@` in a mention (start/whitespace/punctuation). */
|
|
35
|
+
function isBoundaryBefore(ch: string | undefined): boolean {
|
|
36
|
+
if (ch === undefined) return true
|
|
37
|
+
// NOTE: `]` is escaped — an unescaped `]` inside a character class ends it
|
|
38
|
+
// early and silently turns the rest into required literal matches.
|
|
39
|
+
return /[\s(([【「『"'`、,。;:!?,.!?:;>\])})]/.test(ch)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Whether `token` (already trailing-trimmed) matches the relative-path grammar. */
|
|
43
|
+
export function isMentionToken(token: string): boolean {
|
|
44
|
+
if (token.length === 0) return false
|
|
45
|
+
// Sentence punctuation inside the token means following text bled into the
|
|
46
|
+
// mention (e.g. "@src/index.ts。谢谢"): that is plain text, not a path.
|
|
47
|
+
// Sentence punctuation at the END is trimmed before this check.
|
|
48
|
+
if (/[。,、;:!?()、【】「」『』《》]/.test(token)) return false
|
|
49
|
+
// Relative only: no drive prefix, no leading separator, no leading dot-dot.
|
|
50
|
+
if (/^[A-Za-z]:[\\/]/.test(token)) return false
|
|
51
|
+
if (token.startsWith('/') || token.startsWith('\\')) return false
|
|
52
|
+
if (token.startsWith('..')) return false
|
|
53
|
+
// Path-shaped: a directory separator anywhere, or a single file segment
|
|
54
|
+
// with an extension.
|
|
55
|
+
if (token.includes('/') || token.includes('\\')) return true
|
|
56
|
+
return /^[^\\/]+\.[A-Za-z0-9_][A-Za-z0-9._~+-]*$/.test(token)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Trim trailing sentence punctuation from a raw mention token. */
|
|
60
|
+
export function trimMentionToken(raw: string): string {
|
|
61
|
+
let token = raw
|
|
62
|
+
while (token.length > 0 && TRAILING.has(token[token.length - 1])) {
|
|
63
|
+
token = token.slice(0, -1)
|
|
64
|
+
}
|
|
65
|
+
return token
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Extract every valid mention from `text` as [start, end, mention] ranges.
|
|
70
|
+
* `end` covers `@` + the TRIMMED token (trailing punctuation stays outside the
|
|
71
|
+
* link). Pure and testable: the DOM walk uses it and then splits the text node.
|
|
72
|
+
*/
|
|
73
|
+
export function findMentions(text: string): Array<{ start: number; end: number; mention: string }> {
|
|
74
|
+
const hits: Array<{ start: number; end: number; mention: string }> = []
|
|
75
|
+
MENTION_RE.lastIndex = 0
|
|
76
|
+
let match: RegExpExecArray | null
|
|
77
|
+
while ((match = MENTION_RE.exec(text)) !== null) {
|
|
78
|
+
const at = match.index
|
|
79
|
+
const raw = match[1]
|
|
80
|
+
if (!isBoundaryBefore(at > 0 ? text[at - 1] : undefined)) continue
|
|
81
|
+
const token = trimMentionToken(raw)
|
|
82
|
+
if (!isMentionToken(token)) continue
|
|
83
|
+
hits.push({ start: at, end: at + 1 + token.length, mention: token })
|
|
84
|
+
}
|
|
85
|
+
return hits
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Containers whose text is never linkified (code, existing links, overlays). */
|
|
89
|
+
const SKIP_SELECTOR = [
|
|
90
|
+
'code',
|
|
91
|
+
'pre',
|
|
92
|
+
'a',
|
|
93
|
+
'script',
|
|
94
|
+
'style',
|
|
95
|
+
'textarea',
|
|
96
|
+
'[data-composer-seat]',
|
|
97
|
+
'[data-input-mirror]',
|
|
98
|
+
'[data-input-backdrop]',
|
|
99
|
+
'[data-pane="explorer"]',
|
|
100
|
+
'[aria-hidden="true"]',
|
|
101
|
+
'.dstz-popup',
|
|
102
|
+
'.dshpick-lightbox',
|
|
103
|
+
'[data-wb-mention]',
|
|
104
|
+
].join(',')
|
|
105
|
+
|
|
106
|
+
/** Walk the text nodes of one conversation scroll container and linkify. */
|
|
107
|
+
function linkifyRoot(root: HTMLElement): void {
|
|
108
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
|
109
|
+
const toCheck: Text[] = []
|
|
110
|
+
let node: Node | null = walker.nextNode()
|
|
111
|
+
while (node !== null) {
|
|
112
|
+
if (node.parentElement !== null && node.parentElement.closest(SKIP_SELECTOR) === null) {
|
|
113
|
+
toCheck.push(node as Text)
|
|
114
|
+
}
|
|
115
|
+
node = walker.nextNode()
|
|
116
|
+
}
|
|
117
|
+
for (const textNode of toCheck) {
|
|
118
|
+
linkifyTextNode(textNode)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Wrap every mention in one text node; returns true when anything changed. */
|
|
123
|
+
function linkifyTextNode(node: Text): boolean {
|
|
124
|
+
const text = node.data
|
|
125
|
+
const hits = findMentions(text)
|
|
126
|
+
if (hits.length === 0) return false
|
|
127
|
+
const frag = document.createDocumentFragment()
|
|
128
|
+
let cursor = 0
|
|
129
|
+
for (const hit of hits) {
|
|
130
|
+
if (hit.start > cursor) frag.appendChild(document.createTextNode(text.slice(cursor, hit.start)))
|
|
131
|
+
const anchor = document.createElement('a')
|
|
132
|
+
anchor.className = 'dswb-mention'
|
|
133
|
+
anchor.dataset.wbMention = hit.mention
|
|
134
|
+
anchor.textContent = text.slice(hit.start, hit.end)
|
|
135
|
+
anchor.title = `@${hit.mention}`
|
|
136
|
+
frag.appendChild(anchor)
|
|
137
|
+
cursor = hit.end
|
|
138
|
+
}
|
|
139
|
+
if (cursor < text.length) frag.appendChild(document.createTextNode(text.slice(cursor)))
|
|
140
|
+
node.parentNode?.replaceChild(frag, node)
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Style tag guard (the bundle may re-apply on HMR). */
|
|
145
|
+
let styleInstalled = false
|
|
146
|
+
|
|
147
|
+
const MENTION_CSS = [
|
|
148
|
+
'.dswb-mention{color:var(--dsw-alias-state-business-primary);text-decoration:underline;text-underline-offset:2px;cursor:pointer;border-radius:4px;padding:0 1px}',
|
|
149
|
+
'.dswb-mention:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
150
|
+
].join('')
|
|
151
|
+
|
|
152
|
+
function installStyle(): void {
|
|
153
|
+
if (styleInstalled || typeof document === 'undefined') return
|
|
154
|
+
styleInstalled = true
|
|
155
|
+
const tagId = 'dsh-plugin-workbench/mention.module.css'
|
|
156
|
+
if (document.querySelector(`style[data-plugin-css=${JSON.stringify(tagId)}]`) === null) {
|
|
157
|
+
const tag = document.createElement('style')
|
|
158
|
+
tag.dataset.plugin = 'dsh-plugin-workbench'
|
|
159
|
+
tag.dataset.pluginCss = tagId
|
|
160
|
+
tag.textContent = MENTION_CSS
|
|
161
|
+
document.head.appendChild(tag)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Click handler: open the mentioned file in the workbench preview. */
|
|
166
|
+
function onDocumentClick(e: MouseEvent): void {
|
|
167
|
+
const target = e.target
|
|
168
|
+
if (!(target instanceof Element)) return
|
|
169
|
+
const anchor = target.closest('a.dswb-mention')
|
|
170
|
+
if (anchor === null) return
|
|
171
|
+
const mention = anchor.getAttribute('data-wb-mention')
|
|
172
|
+
if (mention === null) return
|
|
173
|
+
e.preventDefault()
|
|
174
|
+
e.stopPropagation()
|
|
175
|
+
openMention(mention)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** One-time install guard (HMR re-applies must not double-observe). */
|
|
179
|
+
let linkifierInstalled = false
|
|
180
|
+
|
|
181
|
+
/** Start the linkifier: initial scan + MutationObserver with rAF coalescing. */
|
|
182
|
+
export function installMentionLinkifier(): () => void {
|
|
183
|
+
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return () => undefined
|
|
184
|
+
if (linkifierInstalled) return () => undefined
|
|
185
|
+
linkifierInstalled = true
|
|
186
|
+
installStyle()
|
|
187
|
+
document.addEventListener('click', onDocumentClick)
|
|
188
|
+
let pending = false
|
|
189
|
+
const scan = (): void => {
|
|
190
|
+
pending = false
|
|
191
|
+
for (const root of document.querySelectorAll<HTMLElement>('[data-conversation-scroll]')) {
|
|
192
|
+
linkifyRoot(root)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const observer = new MutationObserver(() => {
|
|
196
|
+
if (pending) return
|
|
197
|
+
pending = true
|
|
198
|
+
requestAnimationFrame(scan)
|
|
199
|
+
})
|
|
200
|
+
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
|
|
201
|
+
scan()
|
|
202
|
+
return () => {
|
|
203
|
+
observer.disconnect()
|
|
204
|
+
document.removeEventListener('click', onDocumentClick)
|
|
205
|
+
linkifierInstalled = false
|
|
206
|
+
}
|
|
207
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -27,10 +27,12 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
29
29
|
import { spawn } from 'node:child_process'
|
|
30
|
-
import { watch as watchFs } from 'node:fs'
|
|
30
|
+
import { existsSync, readFileSync, watch as watchFs } from 'node:fs'
|
|
31
31
|
import type { FSWatcher } from 'node:fs'
|
|
32
|
-
import { basename, dirname } from 'node:path'
|
|
32
|
+
import { basename, dirname, join } from 'node:path'
|
|
33
33
|
import { mkdir, rename as renameFs, rm, cp, writeFile as writeFileNode } from 'node:fs/promises'
|
|
34
|
+
import { fileURLToPath } from 'node:url'
|
|
35
|
+
import { homedir } from 'node:os'
|
|
34
36
|
import type { Context } from '@deepseek-ai/cordis'
|
|
35
37
|
|
|
36
38
|
export const name = 'dsh-plugin-workbench'
|
|
@@ -252,11 +254,89 @@ function pathOf(payload: unknown): string | undefined {
|
|
|
252
254
|
return undefined
|
|
253
255
|
}
|
|
254
256
|
|
|
257
|
+
/**
|
|
258
|
+
* The explorer-column patch markers that scripts/patch-layout.mjs injects into
|
|
259
|
+
* the compiled dsh-client-ui-layout client bundle. A dsh upgrade (or a
|
|
260
|
+
* `pnpm install` that refreshes the ui-layout package) silently reverts that
|
|
261
|
+
* bundle, which makes the workbench column vanish even though this plugin is
|
|
262
|
+
* fine — this is the exact failure this auto-heal guards against.
|
|
263
|
+
*/
|
|
264
|
+
const LAYOUT_PATCH_MARKERS = [
|
|
265
|
+
'"explorerCol": "',
|
|
266
|
+
'setExplorer: (d, px) => {',
|
|
267
|
+
'renderSlot("explorer"',
|
|
268
|
+
'conversationSeat',
|
|
269
|
+
] as const
|
|
270
|
+
|
|
271
|
+
/** Resolve the installed dsh-client-ui-layout client bundle (profile node_modules junction). */
|
|
272
|
+
function layoutClientPath(): string {
|
|
273
|
+
const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
274
|
+
return join(dshHome, 'profiles', 'node_modules', '@deepseek-ai', 'dsh-client-ui-layout', 'lib', 'client.js')
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* True when the ui-layout bundle already carries the explorer-column patch.
|
|
279
|
+
* A missing bundle (non-standard install) is treated as "nothing to patch" so
|
|
280
|
+
* the check never blocks a boot; an unreadable file likewise bails out to the
|
|
281
|
+
* caller rather than throwing.
|
|
282
|
+
*/
|
|
283
|
+
function layoutIsPatched(): boolean {
|
|
284
|
+
try {
|
|
285
|
+
const target = layoutClientPath()
|
|
286
|
+
if (!existsSync(target)) return true
|
|
287
|
+
const text = readFileSync(target, 'utf8')
|
|
288
|
+
return LAYOUT_PATCH_MARKERS.every((marker) => text.includes(marker))
|
|
289
|
+
} catch {
|
|
290
|
+
return true
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Module-level guard so a HMR/re-apply burst never spawns the patch twice concurrently. */
|
|
295
|
+
let layoutPatchScheduled = false
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Re-apply the ui-layout explorer-column patch when it is missing.
|
|
299
|
+
*
|
|
300
|
+
* The workbench column renders into a fourth `explorer` slot that is added to
|
|
301
|
+
* the compiled dsh-client-ui-layout bundle by scripts/patch-layout.mjs. A dsh
|
|
302
|
+
* upgrade silently reverts that bundle, so this re-runs the same
|
|
303
|
+
* version-checked script: anchors that no longer match abort the script
|
|
304
|
+
* WITHOUT writing, so an incompatible dsh version never corrupts the bundle —
|
|
305
|
+
* it only logs a warning and the plugin still boots. Idempotent and
|
|
306
|
+
* non-blocking (spawned fire-and-forget), so it never delays a boot.
|
|
307
|
+
*/
|
|
308
|
+
function ensureLayoutPatch(): void {
|
|
309
|
+
if (layoutPatchScheduled) return
|
|
310
|
+
layoutPatchScheduled = true
|
|
311
|
+
try {
|
|
312
|
+
if (layoutIsPatched()) return
|
|
313
|
+
const script = join(dirname(dirname(fileURLToPath(import.meta.url))), 'scripts', 'patch-layout.mjs')
|
|
314
|
+
const child = spawn(process.execPath, [script], { stdio: 'inherit', windowsHide: true })
|
|
315
|
+
child.on('error', (err) => {
|
|
316
|
+
console.warn('[dsh-plugin-workbench] re-applying ui-layout explorer patch failed:', err.message)
|
|
317
|
+
layoutPatchScheduled = false
|
|
318
|
+
})
|
|
319
|
+
child.on('close', (code) => {
|
|
320
|
+
if (code === 0) {
|
|
321
|
+
console.log('[dsh-plugin-workbench] re-applied the missing dsh-client-ui-layout explorer patch (likely reverted by a dsh upgrade).')
|
|
322
|
+
} else {
|
|
323
|
+
console.warn(`[dsh-plugin-workbench] ui-layout patch exited ${code}; the dsh version may have changed — run scripts/patch-layout.mjs manually.`)
|
|
324
|
+
layoutPatchScheduled = false
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
} catch (err) {
|
|
328
|
+
console.warn('[dsh-plugin-workbench] ui-layout patch check failed:', err)
|
|
329
|
+
layoutPatchScheduled = false
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
255
333
|
/**
|
|
256
334
|
* One filesystem-backed RPC endpoint pair. Reads never mutate; `signal`
|
|
257
335
|
* cancels the underlying fs call (or aborts between steps).
|
|
258
336
|
*/
|
|
259
337
|
export function apply(ctx: Context): void {
|
|
338
|
+
// Re-apply the ui-layout explorer-column patch when a dsh upgrade reverted it.
|
|
339
|
+
ensureLayoutPatch()
|
|
260
340
|
// Per-apply watch state: created here (not module-level) so disable/reload
|
|
261
341
|
// cycles never leak watchers or SSE clients across applies.
|
|
262
342
|
const watchState: WatchState = {
|
|
@@ -772,10 +852,22 @@ async function copyEntry(ctx: Context, payload: unknown, signal: AbortSignal): P
|
|
|
772
852
|
// "open in system" gesture (`ctx.workspaces.openPath`).
|
|
773
853
|
// ---------------------------------------------------------------------------
|
|
774
854
|
|
|
775
|
-
/**
|
|
776
|
-
|
|
855
|
+
/**
|
|
856
|
+
* Spawn one short-lived desktop command; resolves once the process launched.
|
|
857
|
+
*
|
|
858
|
+
* `windowsHide` defaults to true (no console flash for console-subsystem
|
|
859
|
+
* helpers). EXPLORER.EXE IS THE ONE EXCEPTION: spawning it with
|
|
860
|
+
* `windowsHide: true` sets CREATE_NO_WINDOW, and the new shell folder window
|
|
861
|
+
* is then created HIDDEN — the folder opens on screen but stays invisible, so
|
|
862
|
+
* the user sees "nothing happened". (Verified empirically: the CabinetWClass
|
|
863
|
+
* window exists with `visible=False`; dropping the flag makes it visible.)
|
|
864
|
+
* explorer.exe is a GUI-subsystem app, so `windowsHide: false` never flashes
|
|
865
|
+
* a console — pass false on every Windows explorer.exe spawn.
|
|
866
|
+
* @param args - argv (never a shell string).
|
|
867
|
+
*/
|
|
868
|
+
function runDesktop(command: string, args: string[], windowsHide = true): Promise<void> {
|
|
777
869
|
return new Promise((resolve, reject) => {
|
|
778
|
-
const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide
|
|
870
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide })
|
|
779
871
|
child.once('error', reject)
|
|
780
872
|
child.once('spawn', () => {
|
|
781
873
|
// The window stays after the harness exits; nothing to wait for.
|
|
@@ -808,8 +900,9 @@ async function revealNative(osPath: string, isDir: boolean, signal: AbortSignal)
|
|
|
808
900
|
if (platform === 'win32') {
|
|
809
901
|
// explorer returns exit code 1 when it opens a NEW window, so exit codes
|
|
810
902
|
// carry no meaning; a clean spawn is success. Files are selected in their
|
|
811
|
-
// folder; folders are opened directly.
|
|
812
|
-
|
|
903
|
+
// folder; folders are opened directly. windowsHide MUST stay false for
|
|
904
|
+
// explorer.exe (see runDesktop doc: CREATE_NO_WINDOW hides the new window).
|
|
905
|
+
await runDesktop('explorer.exe', isDir ? [osPath] : ['/select,', osPath], false)
|
|
813
906
|
return
|
|
814
907
|
}
|
|
815
908
|
if (platform === 'darwin') {
|
|
@@ -823,7 +916,7 @@ async function revealNative(osPath: string, isDir: boolean, signal: AbortSignal)
|
|
|
823
916
|
if (env.WSL_DISTRO_NAME !== undefined || env.WSL_INTEROP !== undefined) {
|
|
824
917
|
const windowsPath = (await execCapture('wslpath', ['-w', osPath])).replace(/[\r\n]+$/, '')
|
|
825
918
|
if (windowsPath === '') throw new Error('wslpath returned no Windows path')
|
|
826
|
-
await runDesktop('explorer.exe', isDir ? [windowsPath] : ['/select,', windowsPath])
|
|
919
|
+
await runDesktop('explorer.exe', isDir ? [windowsPath] : ['/select,', windowsPath], false)
|
|
827
920
|
return
|
|
828
921
|
}
|
|
829
922
|
// Desktop Linux: the default file manager opens folders; files open in
|