dsh-plugin-workbench 0.0.9 → 0.0.11
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 +34 -0
- package/README.md +22 -2
- package/lib/client.js +444 -18
- package/lib/client.js.map +1 -1
- package/lib/index.js +17 -5
- 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 +19 -6
|
@@ -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
|
-
/**
|
|
856
|
-
|
|
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
|
|
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
|
-
|
|
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
|