dsh-plugin-workbench 0.0.9 → 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
@@ -852,10 +852,22 @@ async function copyEntry(ctx: Context, payload: unknown, signal: AbortSignal): P
852
852
  // "open in system" gesture (`ctx.workspaces.openPath`).
853
853
  // ---------------------------------------------------------------------------
854
854
 
855
- /** Spawn one short-lived desktop command; resolves once the process launched. */
856
- 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> {
857
869
  return new Promise((resolve, reject) => {
858
- const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true })
870
+ const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide })
859
871
  child.once('error', reject)
860
872
  child.once('spawn', () => {
861
873
  // The window stays after the harness exits; nothing to wait for.
@@ -888,8 +900,9 @@ async function revealNative(osPath: string, isDir: boolean, signal: AbortSignal)
888
900
  if (platform === 'win32') {
889
901
  // explorer returns exit code 1 when it opens a NEW window, so exit codes
890
902
  // carry no meaning; a clean spawn is success. Files are selected in their
891
- // folder; folders are opened directly.
892
- 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)
893
906
  return
894
907
  }
895
908
  if (platform === 'darwin') {
@@ -903,7 +916,7 @@ async function revealNative(osPath: string, isDir: boolean, signal: AbortSignal)
903
916
  if (env.WSL_DISTRO_NAME !== undefined || env.WSL_INTEROP !== undefined) {
904
917
  const windowsPath = (await execCapture('wslpath', ['-w', osPath])).replace(/[\r\n]+$/, '')
905
918
  if (windowsPath === '') throw new Error('wslpath returned no Windows path')
906
- await runDesktop('explorer.exe', isDir ? [windowsPath] : ['/select,', windowsPath])
919
+ await runDesktop('explorer.exe', isDir ? [windowsPath] : ['/select,', windowsPath], false)
907
920
  return
908
921
  }
909
922
  // Desktop Linux: the default file manager opens folders; files open in