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.
@@ -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
- /** Spawn one short-lived desktop command; resolves once the process launched. */
776
- function runDesktop(command: string, args: string[]): Promise<void> {
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: true })
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
- await runDesktop('explorer.exe', isDir ? [osPath] : ['/select,', osPath])
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