@jidekaixin2dian/dsh-file-actions 0.1.11 → 0.1.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/lib/client.js CHANGED
@@ -1,2237 +1,2300 @@
1
- // dsh-file-actions — file and link actions (web bundle, ModuleLoader CJS).
2
- //
3
- // Every file reference the Web GUI renders (inline-code links in the closing
4
- // prose, delivery cards, changed-file rows, tool-call rows, the right Sidebar
5
- // file tree, the review tab) is a plain DOM node carrying the Host path in a
6
- // title/data attribute or as its visible text. This module listens for
7
- // `contextmenu` in the capture phase, resolves that node to a real Host path
8
- // through the shipped Remotes, and replaces Chromium's link menu with one that
9
- // acts on the file.
10
- //
11
- // No host half is needed: opening and revealing go through
12
- // `session.openWorkspacePath` (the same Session-authorized route the document
13
- // preview uses), file associations through `session.workspacePathApplications`,
14
- // and contents/save-as through `workspaceFiles.readBytes`. All three validate
15
- // the path against the composed filesystem before touching the desktop.
16
- // DSH concatenates classic bundles; every registration needs its own scope.
17
- ;(() => {
18
- const factory = () => {
19
- const module = { exports: {} }
20
- const exports = module.exports
21
- Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
22
-
23
- const MENU_ATTR = 'data-dfa-menu'
24
- const ITEM_ATTR = 'data-dfa-item'
25
- // One live install per page: a second materialization (HMR, a re-enabled row)
26
- // would otherwise register a second contextmenu listener and open two menus.
27
- let activeCleanup = null
28
-
29
- /* ─────────────────────────────── copy ─────────────────────────────── */
30
-
31
- const ZH = {
32
- open: '打开文件',
33
- openFolder: '打开文件夹',
34
- openIn: (name) => `在 ${name} 中打开`,
35
- openWith: '打开方式',
36
- reveal: '在资源管理器中显示',
37
- revealFolder: '在资源管理器中打开',
38
- browser: '在浏览器中打开',
39
- copyPath: '复制路径',
40
- copyRelPath: '复制相对路径',
41
- copyContents: '复制文件内容',
42
- saveAs: '另存为…',
43
- copiedPath: '路径已复制',
44
- copiedRelPath: '相对路径已复制',
45
- copiedContents: '文件内容已复制',
46
- copyFailed: '复制失败',
47
- readFailed: '读取失败',
48
- tooLarge: '文件过大,请改用「另存为」',
49
- binary: '二进制文件,无法复制为文本',
50
- openFailed: '打开失败',
51
- revealFailed: '无法定位该文件',
52
- popupBlocked: '浏览器拦截了新窗口',
53
- noSession: '没有可用的会话',
54
- loading: '正在读取…',
55
- dir: '文件夹',
56
- notFound: '找不到该文件',
57
- outsideWorkspace: '工作区外',
58
- defaultApp: '默认',
59
- appsUnit: '个应用',
60
- openLink: '打开链接',
61
- copyLink: '复制链接',
62
- copyLinkText: '复制链接文字',
63
- copiedLink: '链接已复制',
64
- copiedLinkText: '链接文字已复制',
65
- keysMove: '↑↓ 选择',
66
- keysRun: 'Enter 执行',
67
- keysClose: 'Esc 关闭',
68
- searchApps: '搜索应用…',
69
- noMatch: '没有匹配的应用',
70
- appsLoading: '正在查询关联应用…',
71
- appsEmpty: '没有关联应用',
72
- appsFailed: '应用查询失败,点击重试',
73
- preferred: '首选',
74
- pinApp: '设为此文件类型的首选应用',
75
- unpinApp: '取消此文件类型的首选应用',
76
- pinHint: 'Shift+Enter 设置或取消首选',
77
- preferenceSaved: '首选应用已保存',
78
- preferenceCleared: '已恢复自动选择应用',
79
- preferenceFailed: '无法保存首选应用,请检查浏览器存储设置',
80
- noPreferred: '未选择首选应用',
81
- previewLoading: '正在读取缩略图…',
82
- previewUnavailable: '无可用缩略图',
83
- checking: '正在确认文件…',
84
- unavailable: '文件服务不可用',
85
- denied: '没有权限访问该文件',
86
- probeFailed: '无法确认文件,请检查连接或权限',
87
- retry: '重试',
88
- nativeHint: 'Shift+右键 使用原生菜单',
89
- kinds: {
90
- text: '文本',
91
- markdown: 'Markdown',
92
- html: '网页',
93
- pdf: 'PDF',
94
- image: '图片',
95
- office: 'Office 文档',
96
- archive: '压缩包',
97
- video: '视频',
98
- audio: '音频',
99
- link: '链接',
100
- binary: '二进制',
101
- dir: '文件夹',
102
- },
103
- }
104
-
105
- const EN = {
106
- open: 'Open file',
107
- openFolder: 'Open folder',
108
- openIn: (name) => `Open in ${name}`,
109
- openWith: 'Open with',
110
- reveal: 'Reveal in File Explorer',
111
- revealFolder: 'Reveal in File Explorer',
112
- browser: 'Open in browser',
113
- copyPath: 'Copy path',
114
- copyRelPath: 'Copy relative path',
115
- copyContents: 'Copy file contents',
116
- saveAs: 'Save as…',
117
- copiedPath: 'Path copied',
118
- copiedRelPath: 'Relative path copied',
119
- copiedContents: 'File contents copied',
120
- copyFailed: 'Copy failed',
121
- readFailed: 'Read failed',
122
- tooLarge: 'File is too large — use Save as',
123
- binary: 'Binary file cannot be copied as text',
124
- openFailed: 'Open failed',
125
- revealFailed: 'Could not reveal this file',
126
- popupBlocked: 'The browser blocked the new window',
127
- noSession: 'No active session',
128
- loading: 'Reading…',
129
- dir: 'Folder',
130
- notFound: 'File not found',
131
- outsideWorkspace: 'Outside workspace',
132
- defaultApp: 'Default',
133
- appsUnit: 'apps',
134
- openLink: 'Open link',
135
- copyLink: 'Copy link',
136
- copyLinkText: 'Copy link text',
137
- copiedLink: 'Link copied',
138
- copiedLinkText: 'Link text copied',
139
- keysMove: '↑↓ select',
140
- keysRun: 'Enter run',
141
- keysClose: 'Esc close',
142
- searchApps: 'Search apps…',
143
- noMatch: 'No matching app',
144
- appsLoading: 'Finding applications…',
145
- appsEmpty: 'No associated applications',
146
- appsFailed: 'Application lookup failed — retry',
147
- preferred: 'Preferred',
148
- pinApp: 'Prefer this application for this file type',
149
- unpinApp: 'Clear the preferred application for this file type',
150
- pinHint: 'Shift+Enter to set or clear preference',
151
- preferenceSaved: 'Preferred application saved',
152
- preferenceCleared: 'Automatic application selection restored',
153
- preferenceFailed: 'Could not save preference; check browser storage settings',
154
- noPreferred: 'No preferred application',
155
- previewLoading: 'Loading thumbnail…',
156
- previewUnavailable: 'No thumbnail available',
157
- checking: 'Checking file…',
158
- unavailable: 'File service unavailable',
159
- denied: 'Permission to access this file was denied',
160
- probeFailed: 'Could not verify file; check connection or permissions',
161
- retry: 'Retry',
162
- nativeHint: 'Shift+right-click for the native menu',
163
- kinds: {
164
- text: 'Text',
165
- markdown: 'Markdown',
166
- html: 'Web page',
167
- pdf: 'PDF',
168
- image: 'Image',
169
- office: 'Office document',
170
- archive: 'Archive',
171
- video: 'Video',
172
- audio: 'Audio',
173
- link: 'Link',
174
- binary: 'Binary',
175
- dir: 'Folder',
176
- },
177
- }
178
-
179
- function messages(lang) {
180
- return /^zh/i.test(String(lang || '')) ? ZH : EN
181
- }
182
-
183
- /* ───────────────────────────── path helpers ───────────────────────── */
184
-
185
- const TEXT_EXT = new Set([
186
- // documents, data, config
187
- 'txt', 'md', 'markdown', 'mdx', 'rst', 'log', 'csv', 'tsv', 'json', 'jsonc', 'json5', 'jsonl', 'ndjson',
188
- 'har', 'xml', 'yml', 'yaml', 'toml', 'ini', 'cfg', 'conf', 'env', 'properties', 'plist', 'lock',
189
- // subtitles and captions
190
- 'srt', 'ass', 'ssa', 'vtt', 'sub', 'sbv', 'lrc',
191
- // web
192
- 'js', 'mjs', 'cjs', 'jsx', 'ts', 'mts', 'cts', 'tsx', 'vue', 'svelte', 'astro', 'html', 'htm', 'xhtml',
193
- 'svg', 'css', 'scss', 'sass', 'less', 'styl', 'hbs', 'ejs', 'pug', 'twig', 'liquid', 'njk',
194
- // languages
195
- 'py', 'pyi', 'ipynb', 'rb', 'php', 'go', 'rs', 'java', 'kt', 'kts', 'scala', 'groovy',
196
- 'clj', 'cljs', 'ex', 'exs', 'erl', 'hrl', 'hs', 'elm', 'lua', 'pl', 'r', 'jl', 'dart', 'swift', 'm', 'mm',
197
- 'fs', 'fsx', 'cs', 'vb', 'pas', 'f90', 'for', 'asm', 's', 'v', 'sv', 'vhd', 'vhdl',
198
- 'c', 'h', 'cc', 'cpp', 'cxx', 'hpp', 'hh', 'hxx',
199
- // shells, build, schemas
200
- 'sh', 'bash', 'zsh', 'fish', 'ps1', 'psm1', 'bat', 'cmd', 'sql', 'graphql', 'gql', 'proto', 'thrift', 'prisma',
201
- 'diff', 'patch', 'cmake', 'mk', 'ninja', 'bazel', 'bzl', 'gradle', 'tf', 'tfvars',
202
- // shaders, typesetting
203
- 'glsl', 'hlsl', 'wgsl', 'frag', 'vert', 'comp', 'shader', 'mtl', 'tex', 'bib', 'rtf',
204
- ])
205
- const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'avif', 'tif', 'tiff', 'heic', 'heif', 'jfif'])
206
- const HTML_EXT = new Set(['html', 'htm', 'xhtml', 'svg'])
207
- const PDF_EXT = new Set(['pdf'])
208
- const MD_EXT = new Set(['md', 'markdown', 'mdx'])
209
- const OFFICE_EXT = new Set(['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf', 'pages', 'numbers', 'key'])
210
- const ARCHIVE_EXT = new Set(['zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar', 'zst'])
211
- const VIDEO_EXT = new Set(['mp4', 'm4v', 'mov', 'webm', 'ogv', 'mkv', 'avi', 'wmv', 'flv', 'mpg', 'mpeg', '3gp', 'mts', 'm2ts'])
212
- const AUDIO_EXT = new Set(['mp3', 'wav', 'flac', 'm4a', 'aac', 'ogg', 'oga', 'opus', 'wma', 'aiff', 'mid', 'midi'])
213
- // Chromium can play these in a blob tab; the rest only get external actions.
214
- const BROWSER_VIDEO = new Set(['mp4', 'm4v', 'mov', 'webm', 'ogv'])
215
- const BROWSER_AUDIO = new Set(['mp3', 'wav', 'flac', 'm4a', 'ogg', 'oga', 'opus'])
216
-
217
- const KNOWN_EXT = new Set([...TEXT_EXT, ...IMAGE_EXT, ...PDF_EXT, ...OFFICE_EXT, ...ARCHIVE_EXT, ...VIDEO_EXT, ...AUDIO_EXT,
218
- // binaries and assets whose names still deserve a menu
219
- 'wasm', 'map', 'lockb', 'db', 'sqlite', 'sqlite3', 'parquet', 'arrow', 'avro', 'orc',
220
- 'woff', 'woff2', 'ttf', 'otf', 'ttc', 'eot', 'fon', 'icns', 'cur',
221
- 'psd', 'ai', 'eps', 'indd', 'fig', 'sketch', 'xd', 'blend', 'fbx', 'obj', 'glb', 'gltf', 'stl',
222
- 'step', 'stp', 'iges', 'igs', '3ds', 'max', 'c4d', 'ma', 'mb', 'abc', 'usd', 'usdz',
223
- 'iso', 'dmg', 'img', 'vhd', 'vhdx', 'vmdk', 'exe', 'msi', 'dll', 'so', 'dylib', 'bin', 'dat', 'pak',
224
- 'class', 'jar', 'war', 'apk', 'ipa', 'deb', 'rpm', 'appimage', 'raw', 'cr2', 'nef', 'arw', 'dng'])
225
-
226
- const MIME = {
227
- html: 'text/html;charset=utf-8', htm: 'text/html;charset=utf-8', xhtml: 'application/xhtml+xml',
228
- svg: 'image/svg+xml', pdf: 'application/pdf', md: 'text/plain;charset=utf-8',
229
- markdown: 'text/plain;charset=utf-8', mdx: 'text/plain;charset=utf-8',
230
- png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
231
- bmp: 'image/bmp', ico: 'image/x-icon', avif: 'image/avif', tif: 'image/tiff', tiff: 'image/tiff',
232
- json: 'application/json', txt: 'text/plain;charset=utf-8',
233
- mp4: 'video/mp4', m4v: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', ogv: 'video/ogg',
234
- mp3: 'audio/mpeg', wav: 'audio/wav', flac: 'audio/flac', m4a: 'audio/mp4', aac: 'audio/aac',
235
- ogg: 'audio/ogg', oga: 'audio/ogg', opus: 'audio/ogg',
236
- }
237
-
238
- /** An absolute http(s) URL, or '' for anything else (mailto:, blob:, javascript:). */
239
- function linkFromHref(href) {
240
- const value = String(href || '').trim()
241
- if (!/^https?:\/\//i.test(value)) return ''
242
- try {
243
- const url = new URL(value)
244
- if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
245
- return url.href
246
- } catch {
247
- return ''
248
- }
249
- }
250
-
251
- function isAbsolutePath(value) {
252
- return /^(?:[A-Za-z]:[\\/]|\\\\[^\\]|\/)/.test(String(value || ''))
253
- }
254
-
255
- function normalizeSlashes(value) {
256
- return String(value || '').replace(/\\/g, '/')
257
- }
258
-
259
- function basename(value) {
260
- const s = normalizeSlashes(value).replace(/\/+$/, '')
261
- const at = s.lastIndexOf('/')
262
- return at === -1 ? s : s.slice(at + 1)
263
- }
264
-
265
- function extname(value) {
266
- const name = basename(value)
267
- const at = name.lastIndexOf('.')
268
- if (at <= 0) return ''
269
- return name.slice(at + 1).toLowerCase()
270
- }
271
-
272
- function formatBytes(bytes) {
273
- const n = Number(bytes)
274
- if (!Number.isFinite(n) || n < 0) return ''
275
- if (n < 1024) return `${n} B`
276
- const units = ['KB', 'MB', 'GB', 'TB']
277
- let value = n / 1024
278
- let unit = 0
279
- while (value >= 1024 && unit < units.length - 1) {
280
- value /= 1024
281
- unit += 1
282
- }
283
- return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`
284
- }
285
-
286
- /** `src/app.ts:12:3` and `src/app.ts#L12-L20` both name one file plus a location. */
287
- function stripLocationSuffix(value) {
288
- let path = String(value || '').trim()
289
- const hash = path.match(/#L(\d+)(?:-L?(\d+))?$/)
290
- if (hash) {
291
- return {
292
- path: path.slice(0, hash.index),
293
- line: Number(hash[1]),
294
- endLine: hash[2] === undefined ? undefined : Number(hash[2]),
295
- }
296
- }
297
- const colon = path.match(/:(\d+)(?::(\d+))?$/)
298
- if (colon) {
299
- const head = path.slice(0, colon.index)
300
- // A bare drive prefix (`C:`) is not a location suffix.
301
- if (!/^[A-Za-z]:$/.test(head)) {
302
- return {
303
- path: head,
304
- line: Number(colon[1]),
305
- column: colon[2] === undefined ? undefined : Number(colon[2]),
306
- }
307
- }
308
- }
309
- return { path }
310
- }
311
-
312
- function joinPath(cwd, relative) {
313
- if (!cwd) return relative
314
- const base = String(cwd).replace(/[\\/]+$/, '')
315
- const rest = String(relative).replace(/^\.?[\\/]+/, '')
316
- const sep = /^[A-Za-z]:/.test(base) || base.includes('\\') ? '\\' : '/'
317
- return `${base}${sep}${rest}`
318
- }
319
-
320
- /** Whether one path sits inside a root directory (case-insensitive on Windows). */
321
- function isInside(root, path) {
322
- const windows = /^(?:[A-Za-z]:|\\\\|\/\/)/.test(String(root || ''))
323
- const normalize = (value) => windows ? normalizeSlashes(value).toLowerCase() : normalizeSlashes(value)
324
- const a = normalize(root || '').replace(/\/+$/, '')
325
- const b = normalize(path || '')
326
- if (a === '' && root === '/') return b.startsWith('/')
327
- if (a === '' || b === '') return false
328
- return b === a || b.startsWith(`${a}/`)
329
- }
330
-
331
- /** The path relative to a root, or '' when it is not inside it. */
332
- function relativeTo(root, path) {
333
- const a = normalizeSlashes(root || '').replace(/\/+$/, '')
334
- const b = normalizeSlashes(path || '')
335
- if (a === '' && root === '/') return b.startsWith('/') ? b.slice(1) : ''
336
- if (a === '' || b === '') return ''
337
- if (!isInside(root, path)) return ''
338
- if (b.length === a.length) return ''
339
- const prefix = `${a}/`
340
- return b.slice(prefix.length)
341
- }
342
-
343
- /** A title, an attribute value, or a text token worth resolving as a file. */
344
- function looksLikePath(value) {
345
- if (typeof value !== 'string') return false
346
- const s = value.trim()
347
- if (s.length === 0 || s.length > 1024) return false
348
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) return false
349
- if (/[\r\n\t]/.test(s)) return false
350
- if (/[\\/]/.test(s)) return true
351
- const ext = extname(s)
352
- return ext.length > 0 && KNOWN_EXT.has(ext)
353
- }
354
-
355
- function pathFromHref(href) {
356
- const value = String(href || '').trim()
357
- if (value === '') return ''
358
- // The chat, the Sidebar and the delivery table address files as
359
- // `dsh-resource://file/session/<sessionId>/<path>`; the path is workspace
360
- // relative or absolute with its drive letter kept literal.
361
- const resource = /^dsh-resource:\/\/file\/([^?#]*)/i.exec(value)
362
- if (resource) {
363
- const match = /^(?:session\/[^/]+|absolute)\/(.+)$/.exec(resource[1])
364
- if (!match) return ''
365
- try { return decodeURIComponent(match[1]) } catch { return match[1] }
366
- }
367
- if (/^file:\/\//i.test(value)) {
368
- let path = ''
369
- try {
370
- path = decodeURIComponent(value.replace(/^file:\/\//i, ''))
371
- } catch {
372
- // A malformed escape (file:///C:/a%zz) must not throw inside the
373
- // capture-phase listener; fall back to the raw text.
374
- path = value.replace(/^file:\/\//i, '')
375
- }
376
- if (/^\/[A-Za-z]:/.test(path)) path = path.slice(1)
377
- return path
378
- }
379
- if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return ''
380
- if (value.startsWith('#')) return ''
381
- return looksLikePath(value) ? value : ''
382
- }
383
-
384
- /* ─────────────────────────── file classification ─────────────────────────── */
385
-
386
- /**
387
- * Extension-less text files are the norm in a repository root (Dockerfile,
388
- * Makefile, LICENSE, .gitignore). `extname` returns '' for all of them, so
389
- * without this set they would be classified as binary and lose "copy
390
- * contents".
391
- */
392
- const TEXT_NAMES = new Set([
393
- 'dockerfile', 'containerfile', 'makefile', 'gnumakefile', 'cmakelists.txt', 'procfile',
394
- 'gemfile', 'rakefile', 'vagrantfile', 'jenkinsfile', 'brewfile', 'justfile', 'taskfile',
395
- 'license', 'licence', 'notice', 'readme', 'changelog', 'changes', 'authors', 'contributors',
396
- 'copying', 'copyright', 'install', 'todo', 'version', 'codeowners', 'editorconfig',
397
- ])
398
-
399
- /** A dotfile with no second dot is configuration text, not a binary blob. */
400
- function isTextName(path) {
401
- const name = basename(path).toLowerCase()
402
- if (name === '') return false
403
- if (TEXT_NAMES.has(name)) return true
404
- // `.gitignore`, `.env`, and also `.env.local` / `.env.production`: a leading
405
- // dot names a config file, whatever the trailing label looks like.
406
- return name.startsWith('.')
407
- }
408
-
409
- /**
410
- * The kind decides which rows appear: images get a browser preview, text gets
411
- * copy-contents, archives and Office files only get the external actions.
412
- */
413
- function classify(path, isDir) {
414
- if (isDir) return { kind: 'dir', ext: '', canCopyText: false, canBrowser: false, canSaveAs: false }
415
- const ext = extname(path)
416
- if (IMAGE_EXT.has(ext)) return { kind: 'image', ext, canCopyText: false, canBrowser: true, canSaveAs: true }
417
- if (HTML_EXT.has(ext)) return { kind: 'html', ext, canCopyText: true, canBrowser: true, canSaveAs: true }
418
- if (PDF_EXT.has(ext)) return { kind: 'pdf', ext, canCopyText: false, canBrowser: true, canSaveAs: true }
419
- if (MD_EXT.has(ext)) return { kind: 'markdown', ext, canCopyText: true, canBrowser: true, canSaveAs: true }
420
- if (OFFICE_EXT.has(ext)) return { kind: 'office', ext, canCopyText: false, canBrowser: false, canSaveAs: true }
421
- if (ARCHIVE_EXT.has(ext)) return { kind: 'archive', ext, canCopyText: false, canBrowser: false, canSaveAs: true }
422
- if (VIDEO_EXT.has(ext)) return { kind: 'video', ext, canCopyText: false, canBrowser: BROWSER_VIDEO.has(ext), canSaveAs: true }
423
- if (AUDIO_EXT.has(ext)) return { kind: 'audio', ext, canCopyText: false, canBrowser: BROWSER_AUDIO.has(ext), canSaveAs: true }
424
- if (TEXT_EXT.has(ext)) return { kind: 'text', ext, canCopyText: true, canBrowser: false, canSaveAs: true }
425
- if (isTextName(path)) return { kind: 'text', ext, canCopyText: true, canBrowser: false, canSaveAs: true }
426
- return { kind: 'binary', ext, canCopyText: false, canBrowser: false, canSaveAs: true }
427
- }
428
-
429
- function mimeOf(path) {
430
- const ext = extname(path)
431
- if (MIME[ext]) return MIME[ext]
432
- if (TEXT_EXT.has(ext) || isTextName(path)) return 'text/plain;charset=utf-8'
433
- return 'application/octet-stream'
434
- }
435
-
436
- const EDITOR_RE = /(visual studio code|vs ?code|vscode|cursor|windsurf|zed|sublime|intellij|pycharm|webstorm|goland|rider|phpstorm|android studio|notepad\+\+|vim|neovim|emacs|textmate|xcode)/i
437
-
438
- function pickEditor(apps) {
439
- if (!Array.isArray(apps)) return null
440
- return apps.find((app) => app && typeof app.name === 'string' && EDITOR_RE.test(app.name)) || null
441
- }
442
-
443
- function orderApplications(apps, preferredId) {
444
- const seen = new Set()
445
- const rank = (app) => app.id === preferredId ? 0 : app.default ? 1 : EDITOR_RE.test(app.name) ? 2 : 3
446
- return (Array.isArray(apps) ? apps : [])
447
- .filter((app) => {
448
- if (!app || typeof app.id !== 'string' || typeof app.name !== 'string' || seen.has(app.id)) return false
449
- seen.add(app.id)
450
- return true
451
- })
452
- .map((app, index) => ({ app, index }))
453
- .sort((a, b) => rank(a.app) - rank(b.app) || a.index - b.index)
454
- .map(({ app }) => app)
455
- }
456
-
457
- // Store application IDs only, never commands. Host validation still owns opens.
458
- function applicationPreferences(storage) {
459
- const key = 'dsh-file-actions.preferred-apps.v1'
460
- const type = (path) => extname(path).toLowerCase() || '[no extension]'
461
- const read = () => {
462
- try {
463
- const value = JSON.parse(storage.getItem(key) || '{}')
464
- return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
465
- } catch { return {} }
466
- }
467
- return {
468
- get(path) { const value = read()[type(path)]; return typeof value === 'string' ? value : '' },
469
- set(path, appId) {
470
- try {
471
- const value = read()
472
- if (appId) Object.defineProperty(value, type(path), { value: appId, enumerable: true, configurable: true, writable: true })
473
- else delete value[type(path)]
474
- storage.setItem(key, JSON.stringify(value))
475
- return true
476
- } catch { return false }
477
- },
478
- }
479
- }
480
-
481
- function buildStatusModel(path, message, t, retry = false) {
482
- return { title: basename(path) || path, subtitle: path, pathLine: path, kind: 'binary',
483
- status: true, sections: [[{ id: 'status', label: message, disabled: true }],
484
- ...(retry ? [[{ id: 'retry', label: t.retry, action: 'retry', icon: 'open' }]] : [])] }
485
- }
486
-
487
- /* ─────────────────────────── menu model (pure) ─────────────────────────── */
488
-
489
- /**
490
- * Build the rows as data so the widget stays a dumb renderer and the tests can
491
- * assert which actions a file kind earns.
492
- * @param input - resolved target: absolute path, display path, kind flags, apps.
493
- * @returns header text plus ordered sections of rows.
494
- */
495
- function buildMenuModel(input) {
496
- const t = input.t || ZH
497
- const { absolutePath, displayPath, isDir, bytes } = input
498
- const apps = orderApplications(input.apps, input.preferredId)
499
- const file = classify(absolutePath, isDir)
500
- const sections = []
501
- const primary = []
502
-
503
- primary.push({
504
- id: 'open',
505
- label: isDir ? t.openFolder : t.open,
506
- icon: isDir ? 'folder' : 'open',
507
- action: 'open',
508
- })
509
-
510
- const editor = apps.find((app) => app.id === input.preferredId) || pickEditor(apps)
511
- if (!isDir && (editor || input.appsState)) {
512
- primary.push({
513
- id: 'editor',
514
- label: editor ? t.openIn(editor.name) : input.appsState === 'loading' ? t.appsLoading : t.noPreferred,
515
- icon: 'editor',
516
- action: 'open',
517
- appId: editor?.id,
518
- disabled: !editor,
519
- })
520
- }
521
-
522
- if (!isDir && (apps.length > 0 || input.appsState)) {
523
- primary.push({
524
- id: 'openWith',
525
- label: input.appsState === 'failed' ? t.appsFailed : input.appsState === 'ready' && !apps.length ? t.appsEmpty : t.openWith,
526
- icon: 'chevron',
527
- action: input.appsState === 'failed' ? 'retryApps' : undefined,
528
- keepOpen: input.appsState === 'failed',
529
- disabled: apps.length === 0 && input.appsState !== 'failed',
530
- children: apps.length ? apps.map((app) => ({
531
- id: `app:${app.id}`,
532
- label: app.name,
533
- action: 'open',
534
- appId: app.id,
535
- iconData: app.icon || null,
536
- checked: app.default === true,
537
- preferred: app.id === input.preferredId,
538
- pinnable: true,
539
- })) : undefined,
540
- })
541
- }
542
- if (primary.length > 0) sections.push(primary)
543
-
544
- const navigate = []
545
- if (!isDir && file.canBrowser) {
546
- navigate.push({
547
- id: 'browser',
548
- label: t.browser,
549
- icon: 'globe',
550
- action: 'browser',
551
- })
552
- }
553
- navigate.push({
554
- id: 'reveal',
555
- label: isDir ? t.revealFolder : t.reveal,
556
- icon: 'reveal',
557
- action: 'reveal',
558
- })
559
- sections.push(navigate)
560
-
561
- const transfer = [
562
- { id: 'copyPath', label: t.copyPath, icon: 'copy', action: 'copyPath' },
563
- ]
564
- if (displayPath && normalizeSlashes(displayPath) !== normalizeSlashes(absolutePath)) {
565
- transfer.push({ id: 'copyRelPath', label: t.copyRelPath, icon: 'copy', action: 'copyRelPath' })
566
- }
567
- if (!isDir && file.canCopyText) {
568
- transfer.push({ id: 'copyContents', label: t.copyContents, icon: 'clipboard', action: 'copyContents' })
569
- }
570
- if (!isDir) {
571
- transfer.push({ id: 'saveAs', label: t.saveAs, icon: 'download', action: 'saveAs' })
572
- }
573
- sections.push(transfer)
574
-
575
- const kindLabel = t.kinds[file.kind] || t.kinds.binary
576
- const metaParts = [kindLabel]
577
- if (!isDir && bytes) metaParts.push(formatBytes(bytes))
578
- // Copy/save-as read through the Host, which also serves paths outside the
579
- // session workspace. Say so instead of letting the absolute path be the only
580
- // clue that the file is not part of this project.
581
- if (input.outsideWorkspace) metaParts.push(t.outsideWorkspace)
582
- const meta = metaParts.join(' · ')
583
-
584
- return {
585
- title: basename(absolutePath) || absolutePath,
586
- subtitle: absolutePath,
587
- // Inside the workspace the relative path answers "which one"; outside it,
588
- // the absolute path is the only thing that does.
589
- pathLine: input.outsideWorkspace
590
- ? absolutePath
591
- : (relativeTo(input.cwd, absolutePath) || displayPath || absolutePath),
592
- sizeLabel: !isDir && bytes ? formatBytes(bytes) : '',
593
- kindLabel,
594
- meta,
595
- kind: file.kind,
596
- preview: file.kind === 'image' || extname(absolutePath) === 'svg',
597
- outsideWorkspace: input.outsideWorkspace === true,
598
- sections: sections.filter((section) => section.length > 0),
599
- }
600
- }
601
-
602
- /**
603
- * The same menu shape for a web link: the header names the site and the path,
604
- * and the rows act on the URL instead of a file.
605
- */
606
- function buildLinkMenuModel(input) {
607
- const t = input.t || ZH
608
- let host = input.url
609
- let pathLine = input.url
610
- try {
611
- const url = new URL(input.url)
612
- host = url.hostname.replace(/^www\./i, '') || input.url
613
- const path = `${url.pathname}${url.search}`.replace(/\/$/, '')
614
- pathLine = path === '' ? '/' : path
615
- } catch { /* keep the raw URL */ }
616
- return {
617
- title: host,
618
- subtitle: input.url,
619
- pathLine,
620
- sizeLabel: '',
621
- kindLabel: t.kinds.link,
622
- meta: t.kinds.link,
623
- kind: 'link',
624
- outsideWorkspace: false,
625
- sections: [
626
- [{ id: 'openLink', label: t.openLink, icon: 'globe', action: 'openLink' }],
627
- [
628
- { id: 'copyLink', label: t.copyLink, icon: 'copy', action: 'copyLink' },
629
- { id: 'copyLinkText', label: t.copyLinkText, icon: 'clipboard', action: 'copyLinkText' },
630
- ],
631
- ],
632
- }
633
- }
634
-
635
- /* ─────────────────────────────── icons ─────────────────────────────── */
636
-
637
- const ICON_PATHS = {
638
- open: '<path d="M9.2 2.6H4.4a1 1 0 0 0-1 1v8.8a1 1 0 0 0 1 1h7.2a1 1 0 0 0 1-1V6.2z"/><path d="M9.2 2.6v3.6h3.4"/>',
639
- folder: '<path d="M2.5 5.5A1.5 1.5 0 0 1 4 4h3l1.4 1.5H12a1.5 1.5 0 0 1 1.5 1.5v4.5A1.5 1.5 0 0 1 12 13H4a1.5 1.5 0 0 1-1.5-1.5z"/>',
640
- editor: '<path d="M5.8 4.6 2.8 8l3 3.4"/><path d="M10.2 4.6 13.2 8l-3 3.4"/>',
641
- chevron: '<path d="M6.5 4.5 10 8l-3.5 3.5"/>',
642
- reveal: '<path d="M2.5 4.5h5l1.3 1.6H13a1 1 0 0 1 1 1v5.4a1 1 0 0 1-1 1H3.5a1 1 0 0 1-1-1z"/><path d="M6 10.4h4"/>',
643
- copy: '<rect x="5.5" y="5.5" width="7" height="8" rx="1.2"/><path d="M10.5 3.6V3a1 1 0 0 0-1-1H3.9a1 1 0 0 0-1 1v7.2a1 1 0 0 0 1 1h.6"/>',
644
- clipboard: '<rect x="4.5" y="3.5" width="7" height="10" rx="1.3"/><path d="M6.6 3.5V2.8h2.8v.7"/><path d="M6.4 7h3.2"/><path d="M6.4 9.4h3.2"/>',
645
- download: '<path d="M8 2.8v6.4"/><path d="M5.6 7.2 8 9.6l2.4-2.4"/><path d="M3.2 11.4v1.2a1 1 0 0 0 1 1h7.6a1 1 0 0 0 1-1v-1.2"/>',
646
- globe: '<circle cx="8" cy="8" r="5.4"/><path d="M2.8 8h10.4"/><path d="M8 2.6c1.6 1.6 2.4 3.4 2.4 5.4S9.6 11.8 8 13.4C6.4 11.8 5.6 10 5.6 8S6.4 4.2 8 2.6"/>',
647
- check: '<path d="M3.6 8.4 6.6 11.4 12.4 4.9"/>',
648
- link: '<path d="M6.6 9.4a2.6 2.6 0 0 0 3.7 0l1.9-1.9a2.6 2.6 0 0 0-3.7-3.7l-1 1"/><path d="M9.4 6.6a2.6 2.6 0 0 0-3.7 0L3.8 8.5a2.6 2.6 0 0 0 3.7 3.7l1-1"/>',
649
- // File-type glyphs for the header tile: shape carries the kind, the tile hue
650
- // only reinforces it.
651
- text: '<path d="M4 4.6h8"/><path d="M4 7.6h8"/><path d="M4 10.6h5"/>',
652
- markdown: '<path d="M2.8 10.6V5.4l2.1 2.5 2.1-2.5v5.2"/><path d="M11.1 5.6v3.4"/><path d="M9.6 7.6l1.5 1.5 1.5-1.5"/>',
653
- image: '<rect x="2.6" y="3.6" width="10.8" height="8.8" rx="1.4"/><circle cx="6" cy="6.6" r="1"/><path d="M3.6 11.2 6.9 8.3l1.9 1.7 1.8-1.4 1.9 2.1"/>',
654
- web: '<rect x="2.6" y="3.4" width="10.8" height="9.2" rx="1.4"/><path d="M2.6 6.2h10.8"/><circle cx="4.6" cy="4.8" r=".5" fill="currentColor" stroke="none"/><circle cx="6.2" cy="4.8" r=".5" fill="currentColor" stroke="none"/>',
655
- doc: '<path d="M9.2 2.6H4.4a1 1 0 0 0-1 1v8.8a1 1 0 0 0 1 1h7.2a1 1 0 0 0 1-1V6.2z"/><path d="M9.2 2.6v3.6h3.4"/>',
656
- office: '<rect x="3.4" y="2.8" width="9.2" height="10.4" rx="1.3"/><path d="M6 6h4"/><path d="M6 8.3h4"/><path d="M6 10.6h2.4"/>',
657
- archive: '<rect x="3" y="4.8" width="10" height="7.4" rx="1.2"/><path d="M3 7.5h10"/><path d="M6.6 4.8V3.4h2.8v1.4"/>',
658
- binary: '<rect x="3" y="3.4" width="10" height="9.2" rx="1.2"/><path d="M5.6 6.6h1.1v3.2"/><path d="M9.3 6.6h1.5l-1.5 3.2h1.5"/>',
659
- }
660
-
661
- /** The header tile glyph for one file kind. */
662
- const KIND_ICONS = {
663
- text: 'text',
664
- markdown: 'markdown',
665
- html: 'web',
666
- pdf: 'doc',
667
- image: 'image',
668
- office: 'office',
669
- archive: 'archive',
670
- binary: 'binary',
671
- dir: 'folder',
672
- link: 'link',
673
- }
674
-
675
- function iconSvg(name) {
676
- const body = ICON_PATHS[name] || ICON_PATHS.open
677
- return `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`
678
- }
679
-
680
- /* ─────────────────────────────── styles ─────────────────────────────── */
681
-
682
- const CSS = `
683
- .dfa-pin{flex:none;margin-left:6px;border:0;border-radius:4px;background:transparent;color:inherit;
684
- font:inherit;cursor:pointer;padding:0 5px;line-height:1.6}
685
- .dfa-pin:hover,.dfa-pin:focus-visible{background:rgba(127,127,127,.22);outline:1px solid currentColor}
686
- .dfa-pin[aria-pressed="true"]{color:#5b8cff}
687
-
688
- .dfa-menu{position:fixed;z-index:2147483000;width:min(24em,calc(100vw - 16px));min-width:0;max-width:calc(100vw - 16px);box-sizing:border-box;
689
- display:flex;flex-direction:column;max-height:min(78vh,560px);
690
- border-radius:var(--dsw-radius-lg,10px);border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.32));
691
- /* Readability floor: an almost-opaque base colour sits under the themed layer.
692
- A translucent theme (liquid glass over a wallpaper) keeps its tint, but text
693
- never lands on the wallpaper itself. The exact colour is computed at open
694
- time from the theme's own background; Canvas is the pre-JS fallback. */
695
- background-color:var(--dfa-floor,Canvas);
696
- background-image:linear-gradient(var(--dsw-alias-bg-layer-1,transparent),var(--dsw-alias-bg-layer-1,transparent));
697
- color:var(--dsw-alias-label-primary,#e8e8e8);
698
- box-shadow:0 1px 2px rgba(0,0,0,.14),0 14px 38px rgba(0,0,0,.32);
699
- font-family:var(--dsw-font-family,system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif);
700
- font-size:max(13px,.875rem);line-height:1.5;-webkit-font-smoothing:antialiased;user-select:none;outline:none;
701
- animation:dfa-in 90ms cubic-bezier(.2,.7,.3,1) both}
702
- /* An explicit display on a row beats the UA [hidden] rule, so filtered rows need
703
- this to actually disappear. */
704
- .dfa-menu [hidden]{display:none!important}
705
- @keyframes dfa-in{from{opacity:0}to{opacity:1}}
706
- @media (prefers-reduced-motion:reduce){.dfa-menu{animation:none}.dfa-item{transition:none}}
707
- /* The body is the scroll container so the fades can sit on its edges without
708
- fading the menu's own border and shadow. */
709
- .dfa-body{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;scrollbar-gutter:stable;
710
- padding:5px;scrollbar-width:thin;scrollbar-color:var(--dsw-alias-border-l2,rgba(127,127,127,.4)) transparent}
711
- .dfa-body[data-fade="both"]{mask-image:linear-gradient(to bottom,transparent 0,#000 16px,#000 calc(100% - 16px),transparent 100%)}
712
- .dfa-body[data-fade="top"]{mask-image:linear-gradient(to bottom,transparent 0,#000 16px)}
713
- .dfa-body[data-fade="bottom"]{mask-image:linear-gradient(to bottom,#000 calc(100% - 16px),transparent 100%)}
714
- .dfa-thumb{height:118px;box-sizing:content-box;color:var(--dsw-alias-label-tertiary,#888);font-size:.85em;margin:-5px -5px 6px;padding:9px;display:grid;place-items:center;
715
- background:color-mix(in srgb,var(--dsw-static-neutral-00,#fff) 6%,transparent);
716
- border-bottom:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.28))}
717
- .dfa-thumb img{max-width:100%;max-height:118px;display:block;object-fit:contain;border-radius:6px}
718
- .dfa-head{display:flex;align-items:center;gap:9px;padding:6px 7px 8px;min-width:0;
719
- border-bottom:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.28));margin-bottom:3px}
720
- .dfa-tile{flex:none;width:28px;height:28px;border-radius:8px;display:grid;place-items:center;
721
- color:var(--dfa-hue,#5b8cff);
722
- background:color-mix(in srgb,var(--dfa-hue,#5b8cff) 13%,transparent);
723
- box-shadow:inset 0 0 0 .5px color-mix(in srgb,var(--dfa-hue,#5b8cff) 30%,transparent)}
724
- .dfa-tile svg{width:16px;height:16px}
725
- .dfa-tile[data-kind="text"]{--dfa-hue:#5b8cff}
726
- .dfa-tile[data-kind="markdown"]{--dfa-hue:#7b8cff}
727
- .dfa-tile[data-kind="html"]{--dfa-hue:#e0714a}
728
- .dfa-tile[data-kind="pdf"]{--dfa-hue:#e5484d}
729
- .dfa-tile[data-kind="image"]{--dfa-hue:#2fa46b}
730
- .dfa-tile[data-kind="office"]{--dfa-hue:#2f6fdb}
731
- .dfa-tile[data-kind="archive"]{--dfa-hue:#c9971f}
732
- .dfa-tile[data-kind="binary"]{--dfa-hue:#8b8d98}
733
- .dfa-tile[data-kind="dir"]{--dfa-hue:#c9971f}
734
- .dfa-tile[data-kind="link"]{--dfa-hue:#4f8cff}
735
- .dfa-headtext{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}
736
- .dfa-name{font-weight:600;font-size:1em;line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
737
- .dfa-path{font-family:var(--ds-font-family-code,Consolas,monospace);font-size:.85em;line-height:1.4;
738
- color:var(--dsw-alias-label-tertiary,rgba(160,160,160,.95));overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
739
- .dfa-size{flex:none;align-self:flex-start;padding-top:2px;font-size:.85em;line-height:1.4;
740
- color:var(--dsw-alias-label-tertiary,rgba(160,160,160,.95));font-variant-numeric:tabular-nums}
741
- .dfa-aside{flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:2px}
742
- .dfa-badge{flex:none;align-self:flex-start;margin-top:2px;font-size:.85em;line-height:1.4;padding:0 6px;border-radius:999px;
743
- border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.4));color:var(--dsw-alias-label-tertiary,rgba(160,160,160,.95))}
744
- .dfa-sep{height:1px;margin:5px 7px;background:var(--dsw-alias-border-l2,rgba(127,127,127,.28))}
745
- .dfa-item{display:flex;align-items:center;gap:10px;width:100%;box-sizing:border-box;min-height:30px;padding:4px 8px;
746
- border:0;border-radius:6px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;
747
- transition:background-color 70ms ease}
748
- .dfa-item[data-focus="true"]{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.18))}
749
- .dfa-menu[data-keyboard="true"] .dfa-item[data-focus="true"]{
750
- box-shadow:inset 0 0 0 1.5px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4f8cff))}
751
- .dfa-item[aria-disabled="true"]{opacity:.45;cursor:default}
752
- .dfa-ico{flex:none;width:16px;height:16px;display:flex;align-items:center;justify-content:center;
753
- color:var(--dsw-alias-label-secondary,rgba(190,190,190,.95))}
754
- .dfa-ico img{width:16px;height:16px;object-fit:contain;display:block;border-radius:3px}
755
- .dfa-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
756
- .dfa-tail{flex:none;display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-tertiary,rgba(160,160,160,.95));font-size:.85em}
757
- .dfa-default{font-size:.85em;line-height:1.4;padding:0 6px;border-radius:999px;
758
- color:var(--dsw-alias-state-business-primary,#4f8cff);
759
- background:color-mix(in srgb,var(--dsw-alias-state-business-primary,#4f8cff) 14%,transparent)}
760
- .dfa-sub{position:fixed;z-index:2147483001;max-height:min(64vh,440px)}
761
- .dfa-subhead{position:sticky;top:-5px;z-index:2;display:flex;align-items:center;justify-content:space-between;gap:8px;
762
- margin:-5px -5px 0;padding:6px 13px 5px;background:var(--dsw-alias-bg-layer-1,#24262b);
763
- border-bottom:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.28));
764
- font-size:.85em;line-height:1.4;color:var(--dsw-alias-label-tertiary,rgba(160,160,160,.95))}
765
- .dfa-searchwrap{position:sticky;top:27px;z-index:1;margin:0 -5px 4px;padding:5px 8px 6px;
766
- background:var(--dsw-alias-bg-layer-1,#24262b);border-bottom:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.2))}
767
- .dfa-search{width:100%;box-sizing:border-box;font:inherit;font-size:1em;line-height:1.4;padding:3px 7px;
768
- border-radius:5px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.4));
769
- background:var(--dsw-alias-bg-layer-2,transparent);color:inherit;outline:none}
770
- .dfa-search:focus{border-color:var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4f8cff))}
771
- .dfa-search::placeholder{color:var(--dsw-alias-label-tertiary,rgba(160,160,160,.95))}
772
- .dfa-empty{padding:8px 10px;font-size:.85em;color:var(--dsw-alias-label-tertiary,rgba(160,160,160,.95))}
773
- .dfa-foot{display:flex;justify-content:center;gap:12px;margin-top:5px;padding:6px 8px 2px;
774
- border-top:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.28));
775
- font-size:.85em;line-height:1.4;color:var(--dsw-alias-label-tertiary,rgba(160,160,160,.95))}
776
- .dfa-toast{position:fixed;left:50%;bottom:32px;transform:translateX(-50%);z-index:2147483002;
777
- padding:7px 14px;border-radius:8px;font-size:.875rem;line-height:1.4;pointer-events:none;
778
- background-color:var(--dfa-floor,Canvas);
779
- background-image:linear-gradient(var(--dsw-alias-bg-layer-2,transparent),var(--dsw-alias-bg-layer-2,transparent));
780
- color:var(--dsw-alias-label-primary,#eee);
781
- box-shadow:0 2px 6px rgba(0,0,0,.18),0 10px 28px rgba(0,0,0,.28);
782
- opacity:0;transition:opacity .16s ease}
783
- .dfa-toast[data-show="true"]{opacity:1}
784
- .dfa-toast[data-error="true"]{color:var(--dsw-alias-state-error-primary,#f85149)}
785
- `
786
-
787
- function ensureCss() {
788
- if (typeof document === 'undefined') return
789
- if (document.querySelector('style[data-plugin-css="dsh-file-actions"]')) return
790
- const tag = document.createElement('style')
791
- tag.dataset.plugin = 'dsh-file-actions'
792
- tag.dataset.pluginCss = 'dsh-file-actions'
793
- tag.textContent = CSS
794
- document.head.appendChild(tag)
795
- }
796
-
797
- /**
798
- * A near-opaque colour derived from the theme's own page background. A theme
799
- * that makes every surface translucent (liquid glass over a wallpaper) would
800
- * otherwise let the wallpaper through and drop the menu's contrast. Recomputed
801
- * per open so a theme switch is picked up without a reload.
802
- */
803
- function menuFloor() {
804
- if (typeof document === 'undefined') return ''
805
- try {
806
- const probe = document.createElement('div')
807
- // Derive the floor from the same surface colour the menu paints, so a dark
808
- // glass theme gets a dark floor instead of a white one.
809
- probe.style.cssText = 'position:fixed;left:-9999px;top:0;width:1px;height:1px;background:var(--dsw-alias-bg-layer-1,#24262b)'
810
- document.body.appendChild(probe)
811
- const raw = getComputedStyle(probe).backgroundColor
812
- probe.remove()
813
- const match = /rgba?\(([^)]+)\)/.exec(raw)
814
- if (!match) return ''
815
- const parts = match[1].split(',').map((value) => parseFloat(value))
816
- const [r, g, b] = parts
817
- const alpha = parts.length > 3 ? parts[3] : 1
818
- if (![r, g, b, alpha].every((value) => Number.isFinite(value))) return ''
819
- // Flatten the theme colour against black or white by its own luminance, then
820
- // keep a hint of transparency so a glass theme still reads as glass.
821
- const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
822
- const base = luminance < 0.5 ? 0 : 255
823
- const blend = (channel) => Math.round(channel * alpha + base * (1 - alpha))
824
- return `rgba(${blend(r)}, ${blend(g)}, ${blend(b)}, 0.94)`
825
- } catch {
826
- return ''
827
- }
828
- }
829
-
830
- /* ─────────────────────────────── toast ─────────────────────────────── */
831
-
832
- let toastTimer = null
833
- function toast(text, options) {
834
- if (typeof document === 'undefined' || !text) return
835
- let node = document.querySelector('.dfa-toast')
836
- if (!node) {
837
- node = document.createElement('div')
838
- node.className = 'dfa-toast'
839
- const floor = menuFloor()
840
- if (floor) node.style.setProperty('--dfa-floor', floor)
841
- document.body.appendChild(node)
842
- }
843
- node.textContent = text
844
- node.dataset.error = options && options.error ? 'true' : 'false'
845
- // Force a frame so a repeated message re-runs the fade-in.
846
- node.dataset.show = 'false'
847
- requestAnimationFrame(() => {
848
- node.dataset.show = 'true'
849
- })
850
- if (toastTimer) clearTimeout(toastTimer)
851
- toastTimer = setTimeout(() => {
852
- node.dataset.show = 'false'
853
- }, options && options.error ? 3200 : 1800)
854
- }
855
-
856
- /* ─────────────────────────────── bridge ─────────────────────────────── */
857
-
858
- /** Every Remote call answers `{ ok, value }`; unwrap or return null. */
859
- function unwrap(result) {
860
- if (!result || typeof result !== 'object') return null
861
- if (result.ok === true) return result.value
862
- return null
863
- }
864
-
865
- function probeError(error) {
866
- const code = String(error?.code || '')
867
- if (/not-found|ENOENT/.test(code)) return 'notFound'
868
- if (/denied|forbidden|EACCES|EPERM/.test(code)) return 'denied'
869
- return 'probeFailed'
870
- }
871
-
872
- async function boundedCall(call, milliseconds = 8000) {
873
- let timer
874
- try {
875
- return await Promise.race([Promise.resolve().then(call), new Promise((_, reject) => {
876
- timer = setTimeout(() => reject(new Error('Remote request timed out')), milliseconds)
877
- })])
878
- } finally { clearTimeout(timer) }
879
- }
880
-
881
- function createBridge(ctx) {
882
- const remote = ctx.remote
883
- const sessions = ctx.sessions
884
- // Both namespaces are optional so a Host without one still gets the rows it
885
- // can serve: no workspaceFiles means no verification and no read actions.
886
- // Cordis throws on an undeclared sub-service, so both are declared in
887
- // `inject` and every access stays guarded.
888
- let files = null
889
- let sessionApi = null
890
- try { files = remote && remote.workspaceFiles ? remote.workspaceFiles : null } catch { files = null }
891
- try { sessionApi = remote && remote.session ? remote.session : null } catch { sessionApi = null }
892
-
893
- function activeSession(sessionId) {
894
- try {
895
- const snapshot = sessions && sessions.list && sessions.list.getSnapshot
896
- ? sessions.list.getSnapshot()
897
- : null
898
- const rows = snapshot && snapshot.byId ? Object.values(snapshot.byId) : []
899
- if (sessionId) {
900
- const exact = rows.find((row) => row && row.id === sessionId)
901
- // The Host can resolve a Session header even when the Client has not
902
- // retained it. Leave relative paths for that Session's Remote.
903
- return { id: sessionId, cwd: exact?.cwd }
904
- }
905
- if (rows.length === 0) return null
906
- const retained = rows.find((row) => row && row.retainedBy && row.retainedBy.mainView > 0)
907
- const pick = retained || rows.find((row) => row && typeof row.cwd === 'string' && row.cwd !== '')
908
- return pick ? { id: pick.id, cwd: pick.cwd } : null
909
- } catch {
910
- return null
911
- }
912
- }
913
-
914
- return {
915
- activeSession,
916
- /** Resolve one path to a real file or directory, or null when it is neither. */
917
- async probe(sessionId, path) {
918
- if (!files) return { error: 'unavailable' }
919
- let failure = 'probeFailed'
920
- try {
921
- const result = await boundedCall(() => files.stat(sessionId, path))
922
- const stat = unwrap(result)
923
- if (!result?.ok) failure = probeError(result?.error)
924
- if (stat && typeof stat.absolutePath === 'string') {
925
- return { isDir: false, absolutePath: stat.absolutePath, bytes: stat.bytes, version: stat.version }
926
- }
927
- } catch (error) { failure = probeError(error) }
928
- try {
929
- const listing = unwrap(await boundedCall(() => files.list(sessionId, path)))
930
- if (listing && Array.isArray(listing.entries)) {
931
- return { isDir: true, absolutePath: path, bytes: undefined }
932
- }
933
- } catch { /* not a directory either */ }
934
- return { error: failure }
935
- },
936
- async applications(path) {
937
- if (!sessionApi) return { state: 'failed', apps: [] }
938
- try {
939
- const result = await boundedCall(() => sessionApi.workspacePathApplications({ path }))
940
- const value = unwrap(result)
941
- return result?.ok && Array.isArray(value) ? { state: 'ready', apps: value } : { state: 'failed', apps: [] }
942
- } catch {
943
- return { state: 'failed', apps: [] }
944
- }
945
- },
946
- async open(path, appId) {
947
- if (!sessionApi) return false
948
- try {
949
- const request = appId === undefined ? { path } : { path, application: appId }
950
- return unwrap(await sessionApi.openWorkspacePath(request)) !== null
951
- } catch {
952
- return false
953
- }
954
- },
955
- async reveal(path) {
956
- if (!sessionApi) return false
957
- try {
958
- return unwrap(await sessionApi.openWorkspacePath({ path, action: 'reveal' })) !== null
959
- } catch {
960
- return false
961
- }
962
- },
963
- async readBytes(sessionId, path, options) {
964
- if (!files) return null
965
- try {
966
- return unwrap(await files.readBytes(sessionId, path, options || {}))
967
- } catch {
968
- return null
969
- }
970
- },
971
- }
972
- }
973
-
974
- /* ───────────────────────────── detection ───────────────────────────── */
975
-
976
- const PATH_ATTRS = ['data-file-actions-path', 'data-files-path', 'data-review-file']
977
-
978
- function isEditable(node) {
979
- if (!node || !node.closest) return false
980
- return node.closest('input, textarea, [contenteditable]:not([contenteditable="false"])') !== null
981
- }
982
-
983
- /** Expand the text under the pointer to one path-like token. */
984
- function tokenAtPoint(doc, x, y) {
985
- if (!doc || typeof doc.caretRangeFromPoint !== 'function') return ''
986
- let range = null
987
- try {
988
- range = doc.caretRangeFromPoint(x, y)
989
- } catch {
990
- return ''
991
- }
992
- if (!range || !range.startContainer || range.startContainer.nodeType !== 3) return ''
993
- const text = range.startContainer.nodeValue || ''
994
- const at = Math.min(Math.max(range.startOffset, 0), text.length)
995
- const isPathChar = (ch) => /[\w.@()+\-\[\]{}~#$%&',=!\\/:]/.test(ch)
996
- let start = at
997
- let end = at
998
- while (start > 0 && isPathChar(text[start - 1])) start -= 1
999
- while (end < text.length && isPathChar(text[end])) end += 1
1000
- return text.slice(start, end).replace(/^[`'"(<[]+/, '').replace(/[`'")>\],.;:!?]+$/, '')
1001
- }
1002
-
1003
- /**
1004
- * A soft source (a tooltip, an element's text, the caret token) only counts when
1005
- * it looks like an actual file: an absolute path or a name with an extension.
1006
- * "2026/10/22 12:00", "2/3 files" and "src/app.ts · 2.1 KB" all contain a slash
1007
- * but are prose, and swallowing the native menu for them is worse than missing
1008
- * an exotic path.
1009
- */
1010
- function softPath(value) {
1011
- const text = String(value || '').trim()
1012
- if (text === '') return false
1013
- if (isAbsolutePath(text)) return true
1014
- return /\.[A-Za-z0-9]{1,10}$/.test(text)
1015
- }
1016
-
1017
- /**
1018
- * A delivery table prefixes the name with an icon glyph ("📄发布文案.md"), and a
1019
- * chip may start with a bullet or arrow. Strip that decoration before the name
1020
- * is tested as a path — never strip a leading dot, slash or drive letter.
1021
- */
1022
- function stripDecorations(value) {
1023
- return String(value || '').replace(/^[^\p{L}\p{N}._\\/-]+/u, '').trim()
1024
- }
1025
-
1026
- /**
1027
- * Find the file a right-click points at. Structured carriers (a title or a
1028
- * data attribute the GUI already writes) win; a bare text token is the
1029
- * fallback for surfaces that only render the path as text.
1030
- */
1031
- function candidateFromEvent(event, doc) {
1032
- const target = event.target
1033
- if (!target || !target.closest) return null
1034
- if (isEditable(target)) return null
1035
-
1036
- // A resource address owns its Session and path, even when its child has a
1037
- // different title. Do not reinterpret it against the selected chat.
1038
- const resourceAnchor = target.closest('a[href^="dsh-resource://file/"]')
1039
- if (resourceAnchor) {
1040
- const href = resourceAnchor.getAttribute('href')
1041
- const resourcePath = pathFromHref(href)
1042
- const session = /^dsh-resource:\/\/file\/session\/([^/]+)\//i.exec(href)
1043
- if (!resourcePath) return null
1044
- let sessionId = session?.[1]
1045
- try { if (sessionId) sessionId = decodeURIComponent(sessionId) } catch { return null }
1046
- return { path: resourcePath, sessionId, source: 'href', element: resourceAnchor }
1047
- }
1048
-
1049
- for (const attr of PATH_ATTRS) {
1050
- const holder = target.closest(`[${attr}]`)
1051
- if (holder) {
1052
- const value = holder.getAttribute(attr)
1053
- if (value && looksLikePath(value)) return { path: value, source: attr, element: holder }
1054
- }
1055
- }
1056
-
1057
- const card = target.closest('[data-presented-file]')
1058
- if (card) {
1059
- const titled = card.querySelector('[title]')
1060
- const value = titled && titled.getAttribute('title')
1061
- if (value && looksLikePath(value)) return { path: value, source: 'presented', element: card }
1062
- }
1063
-
1064
- let node = target
1065
- for (let depth = 0; depth < 5 && node && node !== doc.body; depth += 1, node = node.parentElement) {
1066
- const value = node.getAttribute && node.getAttribute('title')
1067
- if (value && looksLikePath(value) && softPath(value)) return { path: value, source: 'title', element: node }
1068
- }
1069
-
1070
- const anchor = target.closest('a[href]')
1071
- if (anchor) {
1072
- const href = String(anchor.getAttribute('href') || '')
1073
- const fromHref = pathFromHref(href)
1074
- if (fromHref) return { path: fromHref, source: 'href', element: anchor }
1075
- // A blue reference can also be an ordinary web link (a GitHub release, a
1076
- // discussion). It gets the same menu shape with link actions instead of
1077
- // file actions, so the two kinds of blue text behave alike.
1078
- const link = linkFromHref(href)
1079
- if (link) return { link, source: 'href', element: anchor }
1080
- if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return null
1081
- }
1082
-
1083
- // The element's own text is often exactly the path (a tool row's file link,
1084
- // a chip). Cheaper and steadier than caret hit-testing. Delivery-table cells
1085
- // arrive as "📄发布文案.md", so the decoration goes first.
1086
- const own = stripDecorations(target.textContent)
1087
- if (own && own.length <= 512 && looksLikePath(own) && softPath(own)) {
1088
- return { path: own, source: 'text', element: target }
1089
- }
1090
-
1091
- const token = stripDecorations(tokenAtPoint(doc, event.clientX, event.clientY))
1092
- if (token && looksLikePath(token) && softPath(token)) return { path: token, source: 'text', element: target }
1093
- return null
1094
- }
1095
-
1096
- /* ───────────────────────────── menu widget ───────────────────────────── */
1097
-
1098
- function makeNode(tag, props, children) {
1099
- const node = document.createElement(tag)
1100
- if (props) {
1101
- for (const [key, value] of Object.entries(props)) {
1102
- if (value === undefined || value === null || value === false) continue
1103
- if (key === 'html') node.innerHTML = value
1104
- else if (key === 'text') node.textContent = value
1105
- else if (key === 'class') node.className = value
1106
- else node.setAttribute(key, String(value))
1107
- }
1108
- }
1109
- for (const child of children || []) {
1110
- if (child === null || child === undefined) continue
1111
- node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child)
1112
- }
1113
- return node
1114
- }
1115
-
1116
- class FileMenu {
1117
- constructor(options) {
1118
- this.model = options.model
1119
- this.x = options.x
1120
- this.y = options.y
1121
- this.onPick = options.onPick
1122
- this.onClose = options.onClose
1123
- this.keyboard = options.keyboard === true
1124
- this.anchor = options.anchor || null
1125
- this.t = options.t || ZH
1126
- this.root = null
1127
- this.body = null
1128
- this.thumbUrl = null
1129
- this.submenu = null
1130
- this.submenuFor = null
1131
- this.subItems = []
1132
- this.subVisible = []
1133
- this.subIndex = -1
1134
- this.searchInput = null
1135
- this.subBody = null
1136
- this.subEmpty = null
1137
- this.items = []
1138
- this.index = -1
1139
- this.closed = false
1140
- this.handleKeyDown = this.handleKeyDown.bind(this)
1141
- this.handlePointerDown = this.handlePointerDown.bind(this)
1142
- }
1143
-
1144
- open() {
1145
- const root = makeNode('div', {
1146
- class: 'dfa-menu',
1147
- role: 'menu',
1148
- tabindex: '-1',
1149
- [MENU_ATTR]: '1',
1150
- 'aria-label': this.model.title,
1151
- })
1152
- this.root = root
1153
- this.body = makeNode('div', { class: 'dfa-body' })
1154
- root.appendChild(this.body)
1155
- const floor = menuFloor()
1156
- if (floor) root.style.setProperty('--dfa-floor', floor)
1157
- this.previousFocus = document.activeElement
1158
- root.setAttribute('data-keyboard', this.keyboard ? 'true' : 'false')
1159
- root.addEventListener('mousedown', (event) => { if (!event.target.closest('input,button')) event.preventDefault() })
1160
- this.body.addEventListener('scroll', () => this.updateFade(), { passive: true })
1161
- document.body.appendChild(root)
1162
- this.render()
1163
- this.place(root, this.x, this.y)
1164
- document.addEventListener('keydown', this.handleKeyDown, true)
1165
- document.addEventListener('pointerdown', this.handlePointerDown, true)
1166
- window.addEventListener('resize', this.handlePointerDown, true)
1167
- window.addEventListener('blur', this.handlePointerDown, true)
1168
- // Move focus into the menu so the composer does not keep receiving the
1169
- // keystrokes meant for the rows; close() hands focus back. The window
1170
- // blur listener ignores descendant blurs, so this cannot close the menu.
1171
- try { root.focus({ preventScroll: true }) } catch { /* older engine */ }
1172
- }
1173
-
1174
- /** Fade the scroll edges so a clipped list reads as scrollable. */
1175
- updateFade() {
1176
- const body = this.body
1177
- if (!body) return
1178
- const top = body.scrollTop > 2
1179
- const bottom = body.scrollTop + body.clientHeight < body.scrollHeight - 2
1180
- const mode = top && bottom ? 'both' : top ? 'top' : bottom ? 'bottom' : 'none'
1181
- if (body.dataset.fade !== mode) body.dataset.fade = mode
1182
- }
1183
-
1184
- /**
1185
- * Paint the current model into the existing body. File associations arrive a
1186
- * few seconds after the first right-click, so the same menu is re-rendered in
1187
- * place instead of opening late or flickering through a close/reopen.
1188
- */
1189
- render() {
1190
- if (!this.root || !this.body) return
1191
- this.closeSubmenu()
1192
- const scrollTop = this.body.scrollTop
1193
- const position = this.root.getBoundingClientRect()
1194
- const painted = this.items.length > 0
1195
- const keep = this.items[this.index] ? this.items[this.index].item.id : null
1196
- this.body.textContent = ''
1197
- this.items = []
1198
- this.index = -1
1199
- if (this.model.preview) this.body.appendChild(this.renderThumb())
1200
- this.body.appendChild(this.renderHead())
1201
- this.model.sections.forEach((section, at) => {
1202
- if (at > 0) this.body.appendChild(makeNode('div', { class: 'dfa-sep' }))
1203
- for (const item of section) this.body.appendChild(this.renderItem(item))
1204
- })
1205
- this.body.appendChild(makeNode('div', { class: 'dfa-foot', text: this.t.nativeHint }))
1206
- if (this.keyboard) {
1207
- this.body.appendChild(makeNode('div', { class: 'dfa-foot' }, [
1208
- makeNode('span', { text: this.t.keysMove }),
1209
- makeNode('span', { text: this.t.keysRun }),
1210
- makeNode('span', { text: this.t.keysClose }),
1211
- ]))
1212
- }
1213
- const restored = keep === null ? -1 : this.items.findIndex((entry) => entry.item.id === keep)
1214
- // A pointer-opened menu keeps no row highlighted; the keyboard opener and
1215
- // the post-enrichment repaint both restore a real selection.
1216
- if (restored >= 0 && !this.items[restored].disabled) this.focus(restored)
1217
- else if (this.keyboard) this.focus(this.items.findIndex((entry) => !entry.disabled))
1218
- this.body.scrollTop = scrollTop
1219
- if (this.root.parentNode) {
1220
- // Keep the chosen side of the pointer; only clamp when the viewport ends.
1221
- this.place(this.root, painted ? position.left : this.x, painted ? position.top : this.y, painted)
1222
- }
1223
- this.root.setAttribute('aria-label', this.model.title)
1224
- requestAnimationFrame(() => this.updateFade())
1225
- }
1226
-
1227
- renderThumb() {
1228
- const wrap = makeNode('div', { class: 'dfa-thumb' })
1229
- if (this.thumbUrl) wrap.appendChild(makeNode('img', { src: this.thumbUrl, alt: this.model.title }))
1230
- else wrap.textContent = this.thumbnailDone ? this.t.previewUnavailable : this.t.previewLoading
1231
- return wrap
1232
- }
1233
-
1234
- /** Drop an image preview above the header once its bytes have arrived. */
1235
- setThumbnail(url) {
1236
- if (this.closed) return
1237
- this.thumbUrl = url
1238
- this.thumbnailDone = true
1239
- const previous = this.body?.querySelector('.dfa-thumb')
1240
- if (previous) previous.replaceWith(this.renderThumb())
1241
- }
1242
-
1243
- /**
1244
- * The header is the one deliberate element: a tinted file-type tile, the name,
1245
- * and the path that answers "which file is this" without leaving the menu.
1246
- */
1247
- renderHead() {
1248
- const model = this.model
1249
- const tile = makeNode('span', { class: 'dfa-tile', 'data-kind': model.kind || 'binary' })
1250
- tile.innerHTML = iconSvg(KIND_ICONS[model.kind] || 'doc')
1251
- const text = makeNode('span', { class: 'dfa-headtext' }, [
1252
- makeNode('span', { class: 'dfa-name', text: model.title, title: model.subtitle }),
1253
- makeNode('span', { class: 'dfa-path', text: model.pathLine || model.subtitle, title: model.subtitle }),
1254
- ])
1255
- const aside = makeNode('span', { class: 'dfa-aside' })
1256
- if (model.outsideWorkspace) aside.appendChild(makeNode('span', { class: 'dfa-badge', text: this.t.outsideWorkspace }))
1257
- if (model.sizeLabel) aside.appendChild(makeNode('span', { class: 'dfa-size', text: model.sizeLabel }))
1258
- return makeNode('div', { class: 'dfa-head' }, [tile, text, aside])
1259
- }
1260
-
1261
- /** Replace the model (rows only) and repaint without moving the menu. */
1262
- setModel(model) {
1263
- if (this.closed) return
1264
- this.model = model
1265
- this.render()
1266
- }
1267
-
1268
- renderItem(item) {
1269
- const row = makeNode('div', {
1270
- class: 'dfa-item',
1271
- role: 'menuitem',
1272
- tabindex: '-1',
1273
- id: `dfa-row-${this.items.length}`,
1274
- [ITEM_ATTR]: item.id,
1275
- 'aria-disabled': item.disabled ? 'true' : 'false',
1276
- 'aria-haspopup': item.children ? 'menu' : 'false',
1277
- 'aria-expanded': item.children ? 'false' : undefined,
1278
- })
1279
- const icon = makeNode('span', { class: 'dfa-ico' })
1280
- if (item.iconData) {
1281
- icon.appendChild(makeNode('img', { src: item.iconData, alt: '' }))
1282
- } else {
1283
- icon.innerHTML = iconSvg(item.icon)
1284
- }
1285
- row.appendChild(icon)
1286
- row.appendChild(makeNode('span', { class: 'dfa-label', text: item.label }))
1287
- if (item.children) {
1288
- row.appendChild(makeNode('span', { class: 'dfa-tail', html: iconSvg('chevron') }))
1289
- }
1290
- const entry = { element: row, item, disabled: item.disabled === true }
1291
- this.items.push(entry)
1292
- row.addEventListener('mouseenter', () => {
1293
- this.focus(this.items.indexOf(entry))
1294
- if (item.children) this.openSubmenu(entry)
1295
- else this.closeSubmenu()
1296
- })
1297
- row.addEventListener('click', (event) => {
1298
- event.preventDefault()
1299
- event.stopPropagation()
1300
- if (entry.disabled) return
1301
- if (item.children) {
1302
- this.openSubmenu(entry)
1303
- return
1304
- }
1305
- if (!item.keepOpen) this.close()
1306
- this.onPick(item)
1307
- })
1308
- return row
1309
- }
1310
-
1311
- openSubmenu(entry) {
1312
- if (this.submenuFor === entry.element) return
1313
- this.closeSubmenu()
1314
- const submenu = makeNode('div', {
1315
- class: 'dfa-menu dfa-sub',
1316
- role: 'menu',
1317
- [MENU_ATTR]: 'submenu',
1318
- })
1319
- const body = makeNode('div', { class: 'dfa-body' })
1320
- submenu.appendChild(body)
1321
- submenu.addEventListener('mousedown', (event) => { if (!event.target.closest('input,button')) event.preventDefault() })
1322
- this.subItems = []
1323
- this.subVisible = []
1324
- this.subIndex = -1
1325
- this.subBody = body
1326
- // A long association list needs a header that stays while it scrolls.
1327
- body.appendChild(makeNode('div', { class: 'dfa-subhead' }, [
1328
- makeNode('span', { text: this.t.openWith }),
1329
- makeNode('span', { text: `${entry.item.children.length} ${this.t.appsUnit}` }),
1330
- ]))
1331
- body.appendChild(makeNode('div', { class: 'dfa-foot', text: this.t.pinHint }))
1332
- const needsSearch = entry.item.children.length > 7
1333
- if (needsSearch) {
1334
- const wrap = makeNode('div', { class: 'dfa-searchwrap' })
1335
- this.searchInput = makeNode('input', {
1336
- class: 'dfa-search',
1337
- type: 'text',
1338
- placeholder: this.t.searchApps,
1339
- 'aria-label': this.t.searchApps,
1340
- spellcheck: 'false',
1341
- autocomplete: 'off',
1342
- })
1343
- this.searchInput.addEventListener('input', () => this.filterSubmenu())
1344
- this.searchInput.addEventListener('keydown', (event) => event.stopPropagation())
1345
- wrap.appendChild(this.searchInput)
1346
- body.appendChild(wrap)
1347
- }
1348
- for (const item of entry.item.children) {
1349
- const row = makeNode('div', {
1350
- class: 'dfa-item',
1351
- role: 'menuitem',
1352
- tabindex: '-1',
1353
- id: `dfa-subrow-${this.subItems.length}`,
1354
- [ITEM_ATTR]: item.id,
1355
- title: item.label,
1356
- })
1357
- const icon = makeNode('span', { class: 'dfa-ico' })
1358
- if (item.iconData) icon.appendChild(makeNode('img', { src: item.iconData, alt: '' }))
1359
- else icon.innerHTML = iconSvg('open')
1360
- row.appendChild(icon)
1361
- row.appendChild(makeNode('span', { class: 'dfa-label', text: item.label }))
1362
- if (item.checked || item.preferred) {
1363
- row.appendChild(makeNode('span', { class: 'dfa-tail' }, [
1364
- makeNode('span', { class: 'dfa-default', text: item.preferred ? this.t.preferred : this.t.defaultApp }),
1365
- ]))
1366
- }
1367
- if (item.pinnable) {
1368
- const pin = makeNode('button', { class: 'dfa-pin', type: 'button', tabindex: '-1',
1369
- 'aria-label': `${item.preferred ? this.t.unpinApp : this.t.pinApp}: ${item.label}`,
1370
- 'aria-pressed': item.preferred ? 'true' : 'false',
1371
- title: item.preferred ? this.t.unpinApp : this.t.pinApp,
1372
- text: item.preferred ? '★' : '☆' })
1373
- pin.addEventListener('click', (event) => {
1374
- event.preventDefault(); event.stopPropagation()
1375
- this.close()
1376
- this.onPick({ ...item, action: 'preferApp' })
1377
- })
1378
- row.appendChild(pin)
1379
- }
1380
- const index = this.subItems.length
1381
- const record = { element: row, item, label: String(item.label).toLowerCase() }
1382
- this.subItems.push(record)
1383
- this.subVisible.push(record)
1384
- row.addEventListener('mouseenter', () => this.focusSub(index))
1385
- row.addEventListener('click', (event) => {
1386
- event.preventDefault()
1387
- event.stopPropagation()
1388
- this.close()
1389
- this.onPick(item)
1390
- })
1391
- body.appendChild(row)
1392
- }
1393
- this.subEmpty = makeNode('div', { class: 'dfa-empty', text: this.t.noMatch })
1394
- this.subEmpty.hidden = true
1395
- body.appendChild(this.subEmpty)
1396
- const floor = menuFloor()
1397
- if (floor) submenu.style.setProperty('--dfa-floor', floor)
1398
- document.body.appendChild(submenu)
1399
- this.placeSubmenu(submenu, entry.element)
1400
- this.submenu = submenu
1401
- this.submenuFor = entry.element
1402
- entry.element.setAttribute('aria-expanded', 'true')
1403
- body.addEventListener('scroll', () => this.updateSubFade(), { passive: true })
1404
- requestAnimationFrame(() => this.updateSubFade())
1405
- if (this.keyboard && this.searchInput) {
1406
- try { this.searchInput.focus({ preventScroll: true }) } catch { /* older engine */ }
1407
- }
1408
- }
1409
-
1410
- /** Filter the association rows by a case-insensitive substring. */
1411
- filterSubmenu() {
1412
- const query = String(this.searchInput ? this.searchInput.value : '').trim().toLowerCase()
1413
- this.subVisible = []
1414
- for (const record of this.subItems) {
1415
- const match = query === '' || record.label.includes(query)
1416
- record.element.hidden = !match
1417
- if (match) this.subVisible.push(record)
1418
- }
1419
- this.subEmpty.hidden = this.subVisible.length > 0
1420
- this.subIndex = -1
1421
- for (const record of this.subItems) record.element.dataset.focus = 'false'
1422
- if (this.submenu) this.submenu.removeAttribute('aria-activedescendant')
1423
- if (this.subVisible.length > 0) this.focusSub(0)
1424
- this.updateSubFade()
1425
- }
1426
-
1427
- updateSubFade() {
1428
- const body = this.subBody
1429
- if (!body) return
1430
- const top = body.scrollTop > 2
1431
- const bottom = body.scrollTop + body.clientHeight < body.scrollHeight - 2
1432
- const mode = top && bottom ? 'both' : top ? 'top' : bottom ? 'bottom' : 'none'
1433
- if (body.dataset.fade !== mode) body.dataset.fade = mode
1434
- }
1435
-
1436
- /** Roving focus inside the submenu; the parent menu keeps its own index. */
1437
- focusSub(index) {
1438
- const rows = this.subVisible
1439
- if (rows.length === 0) return
1440
- const next = Math.min(Math.max(index, 0), rows.length - 1)
1441
- const target = rows[next]
1442
- for (const record of this.subItems) record.element.dataset.focus = 'false'
1443
- target.element.dataset.focus = 'true'
1444
- // Keep the parent menu's own row highlighted while the submenu is open.
1445
- this.subIndex = this.subItems.indexOf(target)
1446
- if (this.submenu) this.submenu.setAttribute('aria-activedescendant', target.element.id)
1447
- if (target.element.scrollIntoView) target.element.scrollIntoView({ block: 'nearest' })
1448
- }
1449
-
1450
- moveSub(delta) {
1451
- const rows = this.subVisible
1452
- if (rows.length === 0) return
1453
- const current = this.subVisible.length > 0 ? this.subVisible.indexOf(this.subItems[this.subIndex]) : this.subIndex
1454
- const from = current < 0 ? (delta > 0 ? -1 : 0) : current
1455
- const next = (from + delta + rows.length) % rows.length
1456
- this.focusSub(next)
1457
- }
1458
-
1459
- activateSub(prefer = false) {
1460
- const rows = this.subVisible
1461
- const current = this.subItems[this.subIndex]
1462
- const entry = rows.includes(current) ? current : rows[0]
1463
- if (!entry) return
1464
- this.close()
1465
- this.onPick(prefer && entry.item.pinnable ? { ...entry.item, action: 'preferApp' } : entry.item)
1466
- }
1467
-
1468
- /**
1469
- * Anchor the submenu to its row, flipping to the parent menu's left side when
1470
- * the right edge would clip — flipping relative to the row would cover the
1471
- * menu it belongs to.
1472
- */
1473
- placeSubmenu(submenu, row) {
1474
- submenu.style.left = '0px'
1475
- submenu.style.top = '0px'
1476
- const box = row.getBoundingClientRect()
1477
- const rect = submenu.getBoundingClientRect()
1478
- const parent = this.root ? this.root.getBoundingClientRect() : box
1479
- const vw = window.innerWidth
1480
- const vh = window.innerHeight
1481
- let left = parent.right - 4
1482
- if (left + rect.width > vw - 8) left = parent.left - rect.width + 4
1483
- if (left < 8) left = 8
1484
- let top = box.top - 4
1485
- if (top + rect.height > vh - 8) top = vh - rect.height - 8
1486
- if (top < 8) top = 8
1487
- submenu.style.left = `${Math.round(left)}px`
1488
- submenu.style.top = `${Math.round(top)}px`
1489
- }
1490
-
1491
- closeSubmenu() {
1492
- if (this.submenu) {
1493
- this.submenuFor?.setAttribute('aria-expanded', 'false')
1494
- this.submenu.remove()
1495
- this.submenu = null
1496
- this.submenuFor = null
1497
- }
1498
- this.subItems = []
1499
- this.subVisible = []
1500
- this.subIndex = -1
1501
- this.searchInput = null
1502
- this.subBody = null
1503
- this.subEmpty = null
1504
- }
1505
-
1506
- place(menu, x, y, preserveSide = false) {
1507
- menu.style.left = '0px'
1508
- menu.style.top = '0px'
1509
- const rect = menu.getBoundingClientRect()
1510
- const vw = window.innerWidth
1511
- const vh = window.innerHeight
1512
- let left = x
1513
- let top = y
1514
- if (left + rect.width > vw - 8) left = Math.max(8, preserveSide ? vw - rect.width - 8 : x - rect.width)
1515
- if (left < 8) left = 8
1516
- if (top + rect.height > vh - 8) top = Math.max(8, vh - rect.height - 8)
1517
- if (top < 8) top = 8
1518
- menu.style.left = `${Math.round(left)}px`
1519
- menu.style.top = `${Math.round(top)}px`
1520
- }
1521
-
1522
- focus(index) {
1523
- if (this.items.length === 0) return
1524
- const next = Math.min(Math.max(index, 0), this.items.length - 1)
1525
- this.items.forEach((entry, at) => {
1526
- entry.element.dataset.focus = at === next ? 'true' : 'false'
1527
- })
1528
- this.index = next
1529
- const entry = this.items[next]
1530
- if (entry && this.root) this.root.setAttribute('aria-activedescendant', entry.element.id)
1531
- if (entry && entry.element.scrollIntoView) entry.element.scrollIntoView({ block: 'nearest' })
1532
- }
1533
-
1534
- move(delta) {
1535
- if (this.items.length === 0) return
1536
- let next = this.index
1537
- for (let step = 0; step < this.items.length; step += 1) {
1538
- next = (next + delta + this.items.length) % this.items.length
1539
- if (!this.items[next].disabled) break
1540
- }
1541
- this.focus(next)
1542
- }
1543
-
1544
- activate(index) {
1545
- const entry = this.items[index]
1546
- if (!entry || entry.disabled) return
1547
- if (entry.item.children) {
1548
- this.openSubmenu(entry)
1549
- if (this.keyboard) this.focusSub(0)
1550
- return
1551
- }
1552
- if (!entry.item.keepOpen) this.close()
1553
- this.onPick(entry.item)
1554
- }
1555
-
1556
- handleKeyDown(event) {
1557
- if (!this.root) return
1558
- const typing = this.searchInput && event.target === this.searchInput
1559
- // While the submenu is open it owns the arrows, Enter and Escape, so a
1560
- // keyboard user can actually pick an application.
1561
- if (this.submenu) {
1562
- if (event.key === 'ArrowDown') {
1563
- event.preventDefault()
1564
- this.moveSub(1)
1565
- return
1566
- }
1567
- if (event.key === 'ArrowUp') {
1568
- event.preventDefault()
1569
- this.moveSub(-1)
1570
- return
1571
- }
1572
- if (event.key === 'Enter' || (event.key === ' ' && !typing)) {
1573
- event.preventDefault()
1574
- this.activateSub(event.shiftKey && event.key === 'Enter')
1575
- return
1576
- }
1577
- if (event.key === 'ArrowLeft' || event.key === 'Escape') {
1578
- event.preventDefault()
1579
- event.stopPropagation()
1580
- // Escape in a non-empty filter clears it first, then closes.
1581
- if (event.key === 'Escape' && this.searchInput && this.searchInput.value !== '') {
1582
- this.searchInput.value = ''
1583
- this.filterSubmenu()
1584
- return
1585
- }
1586
- this.closeSubmenu()
1587
- return
1588
- }
1589
- }
1590
- if (event.key === 'Escape') {
1591
- event.preventDefault()
1592
- event.stopPropagation()
1593
- this.close()
1594
- return
1595
- }
1596
- if (event.key === 'ArrowDown') {
1597
- event.preventDefault()
1598
- this.move(1)
1599
- return
1600
- }
1601
- if (event.key === 'ArrowUp') {
1602
- event.preventDefault()
1603
- this.move(-1)
1604
- return
1605
- }
1606
- if (event.key === 'Home') {
1607
- event.preventDefault()
1608
- this.focus(0)
1609
- return
1610
- }
1611
- if (event.key === 'End') {
1612
- event.preventDefault()
1613
- this.focus(this.items.length - 1)
1614
- return
1615
- }
1616
- if (event.key === 'ArrowRight') {
1617
- const entry = this.items[this.index]
1618
- if (entry && entry.item.children) {
1619
- event.preventDefault()
1620
- this.openSubmenu(entry)
1621
- this.focusSub(0)
1622
- }
1623
- return
1624
- }
1625
- if (event.key === 'ArrowLeft') {
1626
- if (this.submenu) {
1627
- event.preventDefault()
1628
- this.closeSubmenu()
1629
- }
1630
- return
1631
- }
1632
- if (event.key === 'Enter' || event.key === ' ') {
1633
- event.preventDefault()
1634
- this.activate(this.index)
1635
- }
1636
- }
1637
-
1638
- handlePointerDown(event) {
1639
- if (!this.root) return
1640
- // `blur` does not bubble, but a capture listener on window still sees the
1641
- // blur of any descendant — including the element that loses focus to this
1642
- // menu when it opens. Only a real window blur may close it.
1643
- if (event.type === 'blur' && event.target !== window) return
1644
- const target = event.target
1645
- if (target instanceof Node && (this.root.contains(target) || (this.submenu && this.submenu.contains(target)))) return
1646
- this.close()
1647
- }
1648
-
1649
- /**
1650
- * The chat transcript streams, so the page scrolls under an open menu. Follow
1651
- * the anchor row instead of vanishing; close only once it leaves the viewport.
1652
- */
1653
- followAnchor() {
1654
- if (!this.root) return
1655
- const anchor = this.anchor
1656
- if (!anchor || !anchor.isConnected) {
1657
- this.close()
1658
- return
1659
- }
1660
- const rect = anchor.getBoundingClientRect()
1661
- if ((rect.width === 0 && rect.height === 0) || rect.bottom < 0 || rect.top > window.innerHeight) {
1662
- this.close()
1663
- return
1664
- }
1665
- this.closeSubmenu()
1666
- this.x = rect.left
1667
- this.y = rect.bottom
1668
- this.place(this.root, this.x, this.y)
1669
- }
1670
-
1671
- close() {
1672
- if (this.closed) return
1673
- this.closed = true
1674
- this.closeSubmenu()
1675
- document.removeEventListener('keydown', this.handleKeyDown, true)
1676
- document.removeEventListener('pointerdown', this.handlePointerDown, true)
1677
- window.removeEventListener('resize', this.handlePointerDown, true)
1678
- window.removeEventListener('blur', this.handlePointerDown, true)
1679
- if (this.root) this.root.remove()
1680
- this.root = null
1681
- this.body = null
1682
- this.thumbUrl = null
1683
- this.items = []
1684
- const previous = this.previousFocus
1685
- this.previousFocus = null
1686
- if (previous && previous !== document.body && typeof previous.focus === 'function' && previous.isConnected) {
1687
- try { previous.focus({ preventScroll: true }) } catch { /* the element may have unmounted */ }
1688
- }
1689
- if (this.onClose) this.onClose()
1690
- }
1691
- }
1692
-
1693
- /* ───────────────────────────── clipboard ───────────────────────────── */
1694
-
1695
- async function copyText(text) {
1696
- try {
1697
- await navigator.clipboard.writeText(text)
1698
- return true
1699
- } catch { /* fall through to the legacy path */ }
1700
- try {
1701
- const area = document.createElement('textarea')
1702
- area.value = text
1703
- area.setAttribute('readonly', '')
1704
- area.style.position = 'fixed'
1705
- area.style.top = '-1000px'
1706
- area.style.opacity = '0'
1707
- document.body.appendChild(area)
1708
- area.select()
1709
- const ok = document.execCommand('copy')
1710
- area.remove()
1711
- return ok
1712
- } catch {
1713
- return false
1714
- }
1715
- }
1716
-
1717
- function downloadBytes(bytes, name, mime) {
1718
- const blob = new Blob([bytes], { type: mime || 'application/octet-stream' })
1719
- const url = URL.createObjectURL(blob)
1720
- const link = document.createElement('a')
1721
- link.href = url
1722
- link.download = name || 'download'
1723
- link.style.display = 'none'
1724
- document.body.appendChild(link)
1725
- link.click()
1726
- link.remove()
1727
- setTimeout(() => URL.revokeObjectURL(url), 60000)
1728
- }
1729
-
1730
- /** Hand a URL to the app's own link handling (a new window/tab). */
1731
- function openExternal(url) {
1732
- try {
1733
- const anchor = document.createElement('a')
1734
- anchor.href = url
1735
- anchor.target = '_blank'
1736
- anchor.rel = 'noopener noreferrer'
1737
- anchor.style.display = 'none'
1738
- document.body.appendChild(anchor)
1739
- anchor.click()
1740
- anchor.remove()
1741
- return true
1742
- } catch {
1743
- return false
1744
- }
1745
- }
1746
-
1747
- /** Fill the window opened synchronously by the click with the file's bytes. */
1748
- function fillBrowserWindow(win, bytes, mime) {
1749
- try {
1750
- const blob = new Blob([bytes], { type: mime })
1751
- const url = URL.createObjectURL(blob)
1752
- // File HTML/SVG may contain scripts. Keep working previews, but give
1753
- // them an opaque origin so they cannot read the DSH page or its cookies.
1754
- win.opener = null
1755
- const frame = win.document.createElement('iframe')
1756
- frame.setAttribute('sandbox', 'allow-scripts allow-forms allow-downloads allow-popups')
1757
- frame.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;border:0'
1758
- frame.src = url
1759
- win.document.body.replaceChildren(frame)
1760
- setTimeout(() => URL.revokeObjectURL(url), 60000)
1761
- return true
1762
- } catch {
1763
- try { win.close() } catch { /* already gone */ }
1764
- return false
1765
- }
1766
- }
1767
-
1768
- /* ─────────────────────────────── actions ─────────────────────────────── */
1769
-
1770
- const MAX_COPY_BYTES = 2 * 1024 * 1024
1771
-
1772
- async function dispatch(item, target, context) {
1773
- const { bridge, t, sessionId } = context
1774
- const path = target.absolutePath
1775
- if (item.action === 'openLink' || item.action === 'copyLink' || item.action === 'copyLinkText') {
1776
- if (item.action === 'openLink') {
1777
- if (!openExternal(target.link)) toast(t.openFailed, { error: true })
1778
- return
1779
- }
1780
- const value = item.action === 'copyLink' ? target.link : target.text
1781
- const ok = await copyText(value || target.link)
1782
- toast(ok ? (item.action === 'copyLink' ? t.copiedLink : t.copiedLinkText) : t.copyFailed, { error: !ok })
1783
- return
1784
- }
1785
- if (item.action === 'open') {
1786
- const ok = await bridge.open(path, item.appId)
1787
- if (!ok) toast(t.openFailed, { error: true })
1788
- return
1789
- }
1790
- if (item.action === 'reveal') {
1791
- const ok = await bridge.reveal(path)
1792
- if (!ok) toast(t.revealFailed, { error: true })
1793
- return
1794
- }
1795
- if (item.action === 'copyPath' || item.action === 'copyRelPath') {
1796
- const value = item.action === 'copyPath' ? path : target.displayPath
1797
- const ok = await copyText(value)
1798
- toast(ok ? (item.action === 'copyPath' ? t.copiedPath : t.copiedRelPath) : t.copyFailed, { error: !ok })
1799
- return
1800
- }
1801
- if (item.action === 'copyContents' && typeof target.bytes === 'number' && target.bytes > MAX_COPY_BYTES) {
1802
- // The size is already known from the stat; never pull a huge file across
1803
- // the wire just to refuse it.
1804
- toast(t.tooLarge, { error: true })
1805
- return
1806
- }
1807
- if (item.action === 'browser') {
1808
- // Must happen inside the click's gesture, before any await, or the popup
1809
- // blocker eats it.
1810
- const win = window.open('', '_blank')
1811
- if (!win) {
1812
- toast(t.popupBlocked, { error: true })
1813
- return
1814
- }
1815
- win.opener = null
1816
- const result = await bridge.readBytes(sessionId, path)
1817
- if (!result || !(result.data instanceof Uint8Array) || result.eof === false) {
1818
- try { win.close() } catch { /* already gone */ }
1819
- toast(t.readFailed, { error: true })
1820
- return
1821
- }
1822
- if (!fillBrowserWindow(win, result.data, mimeOf(path))) toast(t.popupBlocked, { error: true })
1823
- return
1824
- }
1825
- if (item.action === 'copyContents' || item.action === 'saveAs') {
1826
- const options = item.action === 'copyContents'
1827
- // The Host refuses a window above its own maxBytes (2 MB by default), so
1828
- // ask for exactly the cap and read `eof === false` as "too large".
1829
- ? { range: { offset: 0, length: MAX_COPY_BYTES } }
1830
- : {}
1831
- const result = await bridge.readBytes(sessionId, path, options)
1832
- if (!result || !(result.data instanceof Uint8Array) || (item.action === 'saveAs' && result.eof === false)) {
1833
- toast(t.readFailed, { error: true })
1834
- return
1835
- }
1836
- const bytes = result.data
1837
- if (item.action === 'saveAs') {
1838
- downloadBytes(bytes, basename(path), mimeOf(path))
1839
- return
1840
- }
1841
- if (bytes.byteLength > MAX_COPY_BYTES || result.eof === false) {
1842
- toast(t.tooLarge, { error: true })
1843
- return
1844
- }
1845
- let text = ''
1846
- try {
1847
- text = new TextDecoder('utf-8', { fatal: false }).decode(bytes)
1848
- } catch {
1849
- toast(t.readFailed, { error: true })
1850
- return
1851
- }
1852
- if (text.includes('\u0000')) {
1853
- toast(t.binary, { error: true })
1854
- return
1855
- }
1856
- const ok = await copyText(text)
1857
- toast(ok ? t.copiedContents : t.copyFailed, { error: !ok })
1858
- }
1859
- }
1860
-
1861
- /* ─────────────────────────────── plugin ─────────────────────────────── */
1862
-
1863
- function detectLanguage(ctx) {
1864
- try {
1865
- const service = ctx.locale
1866
- const candidates = [
1867
- service && service.language && service.language.getSnapshot ? service.language.getSnapshot() : null,
1868
- service && service.locale && service.locale.getSnapshot ? service.locale.getSnapshot() : null,
1869
- service && service.current && service.current.getSnapshot ? service.current.getSnapshot() : null,
1870
- ]
1871
- for (const value of candidates) {
1872
- if (typeof value === 'string' && value) return value
1873
- if (value && typeof value.language === 'string') return value.language
1874
- }
1875
- } catch { /* fall back to the browser */ }
1876
- try {
1877
- return navigator.language || 'zh-CN'
1878
- } catch {
1879
- return 'zh-CN'
1880
- }
1881
- }
1882
-
1883
- function start(ctx) {
1884
- if (typeof document === 'undefined' || typeof window === 'undefined') return
1885
- ensureCss()
1886
- const bridge = createBridge(ctx)
1887
- const t = messages(detectLanguage(ctx))
1888
-
1889
- let menu = null
1890
- let generation = 0
1891
- let disposed = false
1892
-
1893
- function closeMenu() {
1894
- generation++
1895
- if (menu) {
1896
- menu.close()
1897
- menu = null
1898
- }
1899
- }
1900
-
1901
- // Guessed text must be verified before cancelling the browser's menu. Warm
1902
- // it on hover/focus; a cold right-click keeps the native menu for that click.
1903
- const probeCache = new Map()
1904
- const probePending = new Map()
1905
- function locate(candidate) {
1906
- const session = bridge.activeSession(candidate.sessionId)
1907
- const located = stripLocationSuffix(candidate.path)
1908
- const path = isAbsolutePath(located.path) ? located.path : joinPath(session?.cwd, located.path)
1909
- return { session, located, path, key: JSON.stringify([session?.id, path]) }
1910
- }
1911
- function cachedProbe(candidate) {
1912
- const cached = probeCache.get(locate(candidate).key)
1913
- return cached && Date.now() - cached.at < 5000 ? cached.value : null
1914
- }
1915
- function warmCandidate(candidate) {
1916
- if (!candidate || candidate.link || !['text', 'title'].includes(candidate.source)) return
1917
- const { session, path, key } = locate(candidate)
1918
- if (!session || !path || cachedProbe(candidate) || probePending.has(key) || probePending.size >= 4) return
1919
- const pending = bridge.probe(session.id, path).then((value) => {
1920
- if (disposed) return
1921
- if (probeCache.size >= 64) probeCache.delete(probeCache.keys().next().value)
1922
- probeCache.set(key, { value, at: Date.now() })
1923
- }).finally(() => probePending.delete(key))
1924
- probePending.set(key, pending)
1925
- }
1926
- let preferenceStorage
1927
- try { preferenceStorage = window.localStorage } catch { /* private storage may be disabled */ }
1928
- const preferences = applicationPreferences(preferenceStorage)
1929
-
1930
- async function openAt(candidate, x, y, keyboard) {
1931
- const debug = { path: candidate.path, source: candidate.source, stage: 'start' }
1932
- try { window.__dfaDebug = debug } catch { /* no window */ }
1933
- if (disposed) return
1934
- const attempt = ++generation
1935
- let handle
1936
- try {
1937
- if (candidate.link) {
1938
- const target = { link: candidate.link,
1939
- text: String(candidate.element?.textContent || '').trim().slice(0, 512) }
1940
- handle = new FileMenu({ model: buildLinkMenuModel({ url: candidate.link, t }),
1941
- x, y, keyboard, anchor: candidate.element, t,
1942
- onClose: () => { if (menu === handle) menu = null },
1943
- onPick: (item) => { void dispatch(item, target, { bridge, t }) },
1944
- })
1945
- menu = handle
1946
- handle.open()
1947
- debug.stage = 'opened'
1948
- return
1949
- }
1950
- const { session, located, path } = locate(candidate)
1951
- const modelInput = { absolutePath: path, displayPath: located.path, apps: [], appsState: 'loading', t }
1952
- let target
1953
- handle = new FileMenu({ model: buildStatusModel(path, t.checking, t),
1954
- x, y, keyboard, anchor: candidate.element, t,
1955
- onClose: () => { if (menu === handle) menu = null },
1956
- onPick: (item) => {
1957
- if (item.action === 'retry') { void openAt(candidate, x, y, keyboard); return }
1958
- if (item.action === 'retryApps') {
1959
- modelInput.appsState = 'loading'
1960
- handle.setModel(buildMenuModel(modelInput))
1961
- void enrich(handle, modelInput)
1962
- return
1963
- }
1964
- if (item.action === 'preferApp') {
1965
- const appId = preferences.get(path) === item.appId ? '' : item.appId
1966
- const saved = preferences.set(path, appId)
1967
- toast(saved ? (appId ? t.preferenceSaved : t.preferenceCleared) : t.preferenceFailed, { error: !saved })
1968
- return
1969
- }
1970
- if (target) void dispatch(item, target, { bridge, t, sessionId: session.id })
1971
- },
1972
- })
1973
- menu = handle
1974
- handle.open()
1975
- if (!session) {
1976
- debug.stage = 'no-session'
1977
- handle.setModel(buildStatusModel(path, t.noSession, t, true))
1978
- return
1979
- }
1980
- debug.session = session.id
1981
- debug.cwd = session.cwd
1982
- debug.absolutePath = path
1983
- debug.stage = 'probe'
1984
- const probe = cachedProbe(candidate) || await bridge.probe(session.id, path)
1985
- if (disposed || attempt !== generation || handle.closed) return
1986
- debug.probe = probe
1987
- if (!probe || probe.error) {
1988
- debug.stage = 'probe-failed'
1989
- handle.setModel(buildStatusModel(path, t[probe?.error] || t.probeFailed, t, true))
1990
- return
1991
- }
1992
- Object.assign(modelInput, { absolutePath: probe.absolutePath, isDir: probe.isDir,
1993
- bytes: probe.bytes, outsideWorkspace: !isInside(session.cwd, probe.absolutePath),
1994
- cwd: session.cwd, preferredId: preferences.get(probe.absolutePath) })
1995
- target = { absolutePath: probe.absolutePath, displayPath: located.path,
1996
- isDir: probe.isDir, line: located.line, column: located.column, bytes: probe.bytes }
1997
- handle.setModel(buildMenuModel(modelInput))
1998
- debug.stage = 'opened'
1999
- if (!probe.isDir) void enrich(handle, modelInput)
2000
- if (!probe.isDir && (classify(probe.absolutePath, false).kind === 'image' || extname(probe.absolutePath) === 'svg')) {
2001
- void loadThumb(handle, session.id, probe.absolutePath, probe.bytes, probe.version)
2002
- }
2003
- } catch (error) {
2004
- debug.stage = 'error'
2005
- debug.error = String(error?.message || error)
2006
- if (!disposed && attempt === generation && handle && !handle.closed) {
2007
- handle.setModel(buildStatusModel(candidate.path, t.probeFailed, t, true))
2008
- }
2009
- console.warn('[dsh-file-actions] context menu failed:', error)
2010
- }
2011
- }
2012
-
2013
- // Associations are per file type, so one query per extension serves the page.
2014
- // Ten minutes keeps a newly installed editor visible without re-asking the
2015
- // OS on every right-click.
2016
- const APPS_TTL_MS = 10 * 60 * 1000
2017
- const appsCache = new Map()
2018
- async function enrich(handle, input) {
2019
- const key = extname(input.absolutePath).toLowerCase()
2020
- const cached = appsCache.get(key)
2021
- const result = cached && Date.now() - cached.at < cached.ttl
2022
- ? cached.result : await bridge.applications(input.absolutePath)
2023
- if (disposed) return
2024
- // Successful empty answers are different from a failed Remote query.
2025
- if (result.state === 'ready') appsCache.set(key, { result, at: Date.now(), ttl: result.apps.length ? APPS_TTL_MS : 15000 })
2026
- if (menu !== handle || handle.closed) return
2027
- Object.assign(input, { apps: result.apps, appsState: result.state })
2028
- handle.setModel(buildMenuModel(input))
2029
- }
2030
-
2031
- // A thumbnail is worth one bounded read; bigger files just keep the tile.
2032
- const THUMB_MAX_BYTES = 8 * 1024 * 1024
2033
- const thumbCache = new Map()
2034
- async function loadThumb(handle, sessionId, absolutePath, bytes, version) {
2035
- try {
2036
- if (typeof bytes === 'number' && bytes > THUMB_MAX_BYTES) { handle.setThumbnail(null); return }
2037
- const key = JSON.stringify([sessionId, absolutePath, version])
2038
- const cached = version && thumbCache.get(key)
2039
- if (cached) {
2040
- if (menu === handle && !handle.closed) handle.setThumbnail(cached.url)
2041
- return
2042
- }
2043
- const result = await bridge.readBytes(sessionId, absolutePath)
2044
- if (disposed || menu !== handle || handle.closed) return
2045
- if (!result || !(result.data instanceof Uint8Array) || result.eof === false || result.data.byteLength > THUMB_MAX_BYTES) {
2046
- handle.setThumbnail(null)
2047
- return
2048
- }
2049
- const url = URL.createObjectURL(new Blob([result.data], { type: mimeOf(absolutePath) }))
2050
- const previous = thumbCache.get(key)
2051
- if (previous) URL.revokeObjectURL(previous.url)
2052
- thumbCache.set(key, { url, at: Date.now() })
2053
- if (thumbCache.size > 12) {
2054
- const oldest = [...thumbCache.entries()].sort((a, b) => a[1].at - b[1].at)[0]
2055
- if (oldest) {
2056
- URL.revokeObjectURL(oldest[1].url)
2057
- thumbCache.delete(oldest[0])
2058
- }
2059
- }
2060
- if (menu === handle && !handle.closed) handle.setThumbnail(url)
2061
- } catch (error) {
2062
- if (menu === handle && !handle.closed) handle.setThumbnail(null)
2063
- console.warn('[dsh-file-actions] thumbnail failed:', error)
2064
- }
2065
- }
2066
-
2067
- function onContextMenu(event) {
2068
- if (event.defaultPrevented) return
2069
- if (event.shiftKey) { closeMenu(); return }
2070
- const target = event.target
2071
- // A right-click on the menu itself must not open a second menu over it.
2072
- if (target && target.closest && target.closest(`[${MENU_ATTR}]`)) {
2073
- event.preventDefault()
2074
- event.stopPropagation()
2075
- return
2076
- }
2077
- closeMenu()
2078
- const candidate = candidateFromEvent(event, document)
2079
- if (!candidate) return
2080
- if (['text', 'title'].includes(candidate.source)) {
2081
- const probe = cachedProbe(candidate)
2082
- if (!probe || probe.error) { warmCandidate(candidate); return }
2083
- }
2084
- event.preventDefault()
2085
- event.stopPropagation()
2086
- void openAt(candidate, event.clientX, event.clientY, false)
2087
- }
2088
-
2089
- function onKeyDown(event) {
2090
- const wantsMenu = (event.key === 'F10' && event.shiftKey) || event.key === 'ContextMenu'
2091
- if (!wantsMenu) return
2092
- const active = document.activeElement
2093
- if (!active || !active.getBoundingClientRect) return
2094
- const rect = active.getBoundingClientRect()
2095
- const candidate = candidateFromEvent({
2096
- target: active,
2097
- clientX: rect.left + rect.width / 2,
2098
- clientY: rect.top + rect.height / 2,
2099
- }, document)
2100
- if (!candidate) return
2101
- if (['text', 'title'].includes(candidate.source)) {
2102
- const probe = cachedProbe(candidate)
2103
- if (!probe || probe.error) { warmCandidate(candidate); return }
2104
- }
2105
- event.preventDefault()
2106
- void openAt(candidate, rect.left, rect.bottom, true)
2107
- }
2108
-
2109
- function onWarm(event) {
2110
- if (event.shiftKey || event.target?.closest?.(`[${MENU_ATTR}]`)) return
2111
- const rect = event.target?.getBoundingClientRect?.()
2112
- warmCandidate(candidateFromEvent({ target: event.target,
2113
- clientX: event.clientX ?? rect?.left, clientY: event.clientY ?? rect?.top }, document))
2114
- }
2115
-
2116
- function onPointerDown(event) {
2117
- const target = event.target
2118
- if (target && target.closest && target.closest(`[${MENU_ATTR}]`)) return
2119
- closeMenu()
2120
- }
2121
-
2122
- function onViewportChange(event) {
2123
- // Scrolling the menu's own long lists (or the "open with" submenu) is not
2124
- // the page moving; closing on it would make those lists unusable.
2125
- const target = event && event.target
2126
- if (target && target.closest && target.closest(`[${MENU_ATTR}]`)) return
2127
- if (!menu) return
2128
- if (event && event.type === 'scroll') {
2129
- menu.followAnchor()
2130
- return
2131
- }
2132
- closeMenu()
2133
- }
2134
-
2135
- const install = () => {
2136
- if (activeCleanup) activeCleanup()
2137
- document.addEventListener('contextmenu', onContextMenu, true)
2138
- document.addEventListener('pointerover', onWarm, true)
2139
- document.addEventListener('focusin', onWarm, true)
2140
- document.addEventListener('keydown', onKeyDown, true)
2141
- document.addEventListener('pointerdown', onPointerDown, true)
2142
- window.addEventListener('resize', onViewportChange)
2143
- document.addEventListener('scroll', onViewportChange, true)
2144
- // A DOM marker survives page-side identity quirks and gives the e2e driver
2145
- // one stable readiness signal instead of guessing at boot timing.
2146
- document.documentElement.setAttribute('data-dfa-ready', '1')
2147
- const cleanup = () => {
2148
- disposed = true
2149
- document.documentElement.removeAttribute('data-dfa-ready')
2150
- document.removeEventListener('contextmenu', onContextMenu, true)
2151
- document.removeEventListener('pointerover', onWarm, true)
2152
- document.removeEventListener('focusin', onWarm, true)
2153
- probeCache.clear()
2154
- probePending.clear()
2155
- document.removeEventListener('keydown', onKeyDown, true)
2156
- document.removeEventListener('pointerdown', onPointerDown, true)
2157
- window.removeEventListener('resize', onViewportChange)
2158
- document.removeEventListener('scroll', onViewportChange, true)
2159
- closeMenu()
2160
- for (const value of thumbCache.values()) URL.revokeObjectURL(value.url)
2161
- thumbCache.clear()
2162
- appsCache.clear()
2163
- if (activeCleanup === cleanup) activeCleanup = null
2164
- }
2165
- activeCleanup = cleanup
2166
- return cleanup
2167
- }
2168
-
2169
- if (typeof ctx.effect === 'function') {
2170
- ctx.effect(install, 'dsh-file-actions: context menu')
2171
- } else {
2172
- install()
2173
- }
2174
- }
2175
-
2176
- /**
2177
- * The loader records one failed row as a boot-audit failure with no message, so
2178
- * log the real stack before rethrowing.
2179
- */
2180
- function apply(ctx) {
2181
- try {
2182
- start(ctx)
2183
- } catch (error) {
2184
- console.error('[dsh-file-actions] activation failed:', (error && error.stack) || error)
2185
- throw error
2186
- }
2187
- }
2188
-
2189
- module.exports = {
2190
- name: 'dsh-file-actions',
2191
- inject: ['sessions', 'remote', 'remote.session', 'remote.workspaceFiles'],
2192
- apply,
2193
- // Exposed for the Node unit tests: the bundle is a classic script, so the
2194
- // test harness evaluates it with a fake `window` and reads these directly.
2195
- __internals: {
2196
- messages,
2197
- isAbsolutePath,
2198
- normalizeSlashes,
2199
- basename,
2200
- extname,
2201
- formatBytes,
2202
- stripLocationSuffix,
2203
- joinPath,
2204
- looksLikePath,
2205
- pathFromHref,
2206
- classify,
2207
- isTextName,
2208
- isInside,
2209
- relativeTo,
2210
- softPath,
2211
- stripDecorations,
2212
- dispatch,
2213
- mimeOf,
2214
- pickEditor,
2215
- orderApplications,
2216
- applicationPreferences,
2217
- buildStatusModel,
2218
- FileMenu,
2219
- buildMenuModel,
2220
- buildLinkMenuModel,
2221
- linkFromHref,
2222
- candidateFromEvent,
2223
- createBridge,
2224
- fillBrowserWindow,
2225
- tokenAtPoint,
2226
- unwrap,
2227
- menuFloor,
2228
- MAX_COPY_BYTES,
2229
- },
2230
- }
2231
- return module.exports
2232
- }
2233
-
2234
- if (typeof window !== 'undefined' && window.__ModuleLoader__) {
2235
- window.__ModuleLoader__.load({ id: '@jidekaixin2dian/dsh-file-actions', factory })
2236
- }
2237
- })()
1
+ // dsh-file-actions — file and link actions (web bundle, ModuleLoader CJS).
2
+ //
3
+ // Every file reference the Web GUI renders (inline-code links in the closing
4
+ // prose, delivery cards, changed-file rows, tool-call rows, the right Sidebar
5
+ // file tree, the review tab) is a plain DOM node carrying the Host path in a
6
+ // title/data attribute or as its visible text. This module listens for
7
+ // `contextmenu` in the capture phase, resolves that node to a real Host path
8
+ // through the shipped Remotes, and replaces Chromium's link menu with one that
9
+ // acts on the file.
10
+ //
11
+ // No host half is needed: opening and revealing go through
12
+ // `session.openWorkspacePath` (the same Session-authorized route the document
13
+ // preview uses), file associations through `session.workspacePathApplications`,
14
+ // and contents/save-as through `workspaceFiles.readBytes`. All three validate
15
+ // the path against the composed filesystem before touching the desktop.
16
+ // DSH concatenates classic bundles; every registration needs its own scope.
17
+ ;(() => {
18
+ const factory = () => {
19
+ const module = { exports: {} }
20
+ const exports = module.exports
21
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
22
+
23
+ const MENU_ATTR = 'data-dfa-menu'
24
+ const ITEM_ATTR = 'data-dfa-item'
25
+ // One live install per page: a second materialization (HMR, a re-enabled row)
26
+ // would otherwise register a second contextmenu listener and open two menus.
27
+ let activeCleanup = null
28
+
29
+ /* ─────────────────────────────── copy ─────────────────────────────── */
30
+
31
+ const ZH = {
32
+ open: '打开文件',
33
+ openFolder: '打开文件夹',
34
+ openIn: (name) => `在 ${name} 中打开`,
35
+ openWith: '打开方式',
36
+ reveal: '在资源管理器中显示',
37
+ revealFolder: '在资源管理器中打开',
38
+ browser: '在浏览器中打开',
39
+ copyPath: '复制路径',
40
+ copyRelPath: '复制相对路径',
41
+ copyFileName: '复制文件名',
42
+ copyReference: '复制引用',
43
+ copyMarkdown: '复制为 Markdown',
44
+ copyContents: '复制文件内容',
45
+ saveAs: '另存为…',
46
+ copiedPath: '路径已复制',
47
+ copiedRelPath: '相对路径已复制',
48
+ copiedFileName: '文件名已复制',
49
+ copiedReference: '引用已复制',
50
+ copiedMarkdown: 'Markdown 已复制',
51
+ lineAt: '第 {line} 行',
52
+ lineRange: '第 {from}–{to} 行',
53
+ copiedContents: '文件内容已复制',
54
+ copyFailed: '复制失败',
55
+ readFailed: '读取失败',
56
+ tooLarge: '文件过大,请改用「另存为」',
57
+ binary: '二进制文件,无法复制为文本',
58
+ confirmOutsideCopy: '该文件在会话工作区之外,不是本对话的一部分。确认要把它的内容复制到剪贴板吗?\n{path}',
59
+ openFailed: '打开失败',
60
+ revealFailed: '无法定位该文件',
61
+ popupBlocked: '浏览器拦截了新窗口',
62
+ noSession: '没有可用的会话',
63
+ loading: '正在读取…',
64
+ dir: '文件夹',
65
+ notFound: '找不到该文件',
66
+ outsideWorkspace: '工作区外',
67
+ defaultApp: '默认',
68
+ appsUnit: '个应用',
69
+ openLink: '打开链接',
70
+ copyLink: '复制链接',
71
+ copyLinkText: '复制链接文字',
72
+ copiedLink: '链接已复制',
73
+ copiedLinkText: '链接文字已复制',
74
+ keysMove: '↑↓ 选择',
75
+ keysRun: 'Enter 执行',
76
+ keysClose: 'Esc 关闭',
77
+ searchApps: '搜索应用…',
78
+ noMatch: '没有匹配的应用',
79
+ appsLoading: '正在查询关联应用…',
80
+ appsEmpty: '没有关联应用',
81
+ appsFailed: '应用查询失败,点击重试',
82
+ preferred: '首选',
83
+ pinApp: '设为此文件类型的首选应用',
84
+ unpinApp: '取消此文件类型的首选应用',
85
+ pinHint: 'Shift+Enter 设置或取消首选',
86
+ preferenceSaved: '首选应用已保存',
87
+ preferenceCleared: '已恢复自动选择应用',
88
+ preferenceFailed: '无法保存首选应用,请检查浏览器存储设置',
89
+ noPreferred: '未选择首选应用',
90
+ previewLoading: '正在读取缩略图…',
91
+ previewUnavailable: '无可用缩略图',
92
+ checking: '正在确认文件…',
93
+ unavailable: '文件服务不可用',
94
+ denied: '没有权限访问该文件',
95
+ probeFailed: '无法确认文件,请检查连接或权限',
96
+ retry: '重试',
97
+ nativeHint: 'Shift+右键 使用原生菜单',
98
+ kinds: {
99
+ text: '文本',
100
+ markdown: 'Markdown',
101
+ html: '网页',
102
+ pdf: 'PDF',
103
+ image: '图片',
104
+ office: 'Office 文档',
105
+ archive: '压缩包',
106
+ video: '视频',
107
+ audio: '音频',
108
+ link: '链接',
109
+ binary: '二进制',
110
+ dir: '文件夹',
111
+ },
112
+ }
113
+
114
+ const EN = {
115
+ open: 'Open file',
116
+ openFolder: 'Open folder',
117
+ openIn: (name) => `Open in ${name}`,
118
+ openWith: 'Open with',
119
+ reveal: 'Reveal in File Explorer',
120
+ revealFolder: 'Reveal in File Explorer',
121
+ browser: 'Open in browser',
122
+ copyPath: 'Copy path',
123
+ copyRelPath: 'Copy relative path',
124
+ copyFileName: 'Copy file name',
125
+ copyReference: 'Copy reference',
126
+ copyMarkdown: 'Copy as Markdown',
127
+ copyContents: 'Copy file contents',
128
+ saveAs: 'Save as…',
129
+ copiedPath: 'Path copied',
130
+ copiedRelPath: 'Relative path copied',
131
+ copiedFileName: 'File name copied',
132
+ copiedReference: 'Reference copied',
133
+ copiedMarkdown: 'Markdown copied',
134
+ lineAt: 'line {line}',
135
+ lineRange: 'lines {from}–{to}',
136
+ copiedContents: 'File contents copied',
137
+ copyFailed: 'Copy failed',
138
+ readFailed: 'Read failed',
139
+ tooLarge: 'File is too large — use Save as',
140
+ binary: 'Binary file cannot be copied as text',
141
+ confirmOutsideCopy: 'This file is outside the session workspace and not part of this conversation. Copy its contents to the clipboard?\n{path}',
142
+ openFailed: 'Open failed',
143
+ revealFailed: 'Could not reveal this file',
144
+ popupBlocked: 'The browser blocked the new window',
145
+ noSession: 'No active session',
146
+ loading: 'Reading…',
147
+ dir: 'Folder',
148
+ notFound: 'File not found',
149
+ outsideWorkspace: 'Outside workspace',
150
+ defaultApp: 'Default',
151
+ appsUnit: 'apps',
152
+ openLink: 'Open link',
153
+ copyLink: 'Copy link',
154
+ copyLinkText: 'Copy link text',
155
+ copiedLink: 'Link copied',
156
+ copiedLinkText: 'Link text copied',
157
+ keysMove: '↑↓ select',
158
+ keysRun: 'Enter run',
159
+ keysClose: 'Esc close',
160
+ searchApps: 'Search apps…',
161
+ noMatch: 'No matching app',
162
+ appsLoading: 'Finding applications…',
163
+ appsEmpty: 'No associated applications',
164
+ appsFailed: 'Application lookup failed — retry',
165
+ preferred: 'Preferred',
166
+ pinApp: 'Prefer this application for this file type',
167
+ unpinApp: 'Clear the preferred application for this file type',
168
+ pinHint: 'Shift+Enter to set or clear preference',
169
+ preferenceSaved: 'Preferred application saved',
170
+ preferenceCleared: 'Automatic application selection restored',
171
+ preferenceFailed: 'Could not save preference; check browser storage settings',
172
+ noPreferred: 'No preferred application',
173
+ previewLoading: 'Loading thumbnail…',
174
+ previewUnavailable: 'No thumbnail available',
175
+ checking: 'Checking file…',
176
+ unavailable: 'File service unavailable',
177
+ denied: 'Permission to access this file was denied',
178
+ probeFailed: 'Could not verify file; check connection or permissions',
179
+ retry: 'Retry',
180
+ nativeHint: 'Shift+right-click for the native menu',
181
+ kinds: {
182
+ text: 'Text',
183
+ markdown: 'Markdown',
184
+ html: 'Web page',
185
+ pdf: 'PDF',
186
+ image: 'Image',
187
+ office: 'Office document',
188
+ archive: 'Archive',
189
+ video: 'Video',
190
+ audio: 'Audio',
191
+ link: 'Link',
192
+ binary: 'Binary',
193
+ dir: 'Folder',
194
+ },
195
+ }
196
+
197
+ function messages(lang) {
198
+ return /^zh/i.test(String(lang || '')) ? ZH : EN
199
+ }
200
+
201
+ /* ───────────────────────────── path helpers ───────────────────────── */
202
+
203
+ const TEXT_EXT = new Set([
204
+ // documents, data, config
205
+ 'txt', 'md', 'markdown', 'mdx', 'rst', 'log', 'csv', 'tsv', 'json', 'jsonc', 'json5', 'jsonl', 'ndjson',
206
+ 'har', 'xml', 'yml', 'yaml', 'toml', 'ini', 'cfg', 'conf', 'env', 'properties', 'plist', 'lock',
207
+ // subtitles and captions
208
+ 'srt', 'ass', 'ssa', 'vtt', 'sub', 'sbv', 'lrc',
209
+ // web
210
+ 'js', 'mjs', 'cjs', 'jsx', 'ts', 'mts', 'cts', 'tsx', 'vue', 'svelte', 'astro', 'html', 'htm', 'xhtml',
211
+ 'svg', 'css', 'scss', 'sass', 'less', 'styl', 'hbs', 'ejs', 'pug', 'twig', 'liquid', 'njk',
212
+ // languages
213
+ 'py', 'pyi', 'ipynb', 'rb', 'php', 'go', 'rs', 'java', 'kt', 'kts', 'scala', 'groovy',
214
+ 'clj', 'cljs', 'ex', 'exs', 'erl', 'hrl', 'hs', 'elm', 'lua', 'pl', 'r', 'jl', 'dart', 'swift', 'm', 'mm',
215
+ 'fs', 'fsx', 'cs', 'vb', 'pas', 'f90', 'for', 'asm', 's', 'v', 'sv', 'vhd', 'vhdl',
216
+ 'c', 'h', 'cc', 'cpp', 'cxx', 'hpp', 'hh', 'hxx',
217
+ // shells, build, schemas
218
+ 'sh', 'bash', 'zsh', 'fish', 'ps1', 'psm1', 'bat', 'cmd', 'sql', 'graphql', 'gql', 'proto', 'thrift', 'prisma',
219
+ 'diff', 'patch', 'cmake', 'mk', 'ninja', 'bazel', 'bzl', 'gradle', 'tf', 'tfvars',
220
+ // shaders, typesetting
221
+ 'glsl', 'hlsl', 'wgsl', 'frag', 'vert', 'comp', 'shader', 'mtl', 'tex', 'bib', 'rtf',
222
+ ])
223
+ const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'avif', 'tif', 'tiff', 'heic', 'heif', 'jfif'])
224
+ const HTML_EXT = new Set(['html', 'htm', 'xhtml', 'svg'])
225
+ const PDF_EXT = new Set(['pdf'])
226
+ const MD_EXT = new Set(['md', 'markdown', 'mdx'])
227
+ const OFFICE_EXT = new Set(['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf', 'pages', 'numbers', 'key'])
228
+ const ARCHIVE_EXT = new Set(['zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar', 'zst'])
229
+ const VIDEO_EXT = new Set(['mp4', 'm4v', 'mov', 'webm', 'ogv', 'mkv', 'avi', 'wmv', 'flv', 'mpg', 'mpeg', '3gp', 'mts', 'm2ts'])
230
+ const AUDIO_EXT = new Set(['mp3', 'wav', 'flac', 'm4a', 'aac', 'ogg', 'oga', 'opus', 'wma', 'aiff', 'mid', 'midi'])
231
+ // Chromium can play these in a blob tab; the rest only get external actions.
232
+ const BROWSER_VIDEO = new Set(['mp4', 'm4v', 'mov', 'webm', 'ogv'])
233
+ const BROWSER_AUDIO = new Set(['mp3', 'wav', 'flac', 'm4a', 'ogg', 'oga', 'opus'])
234
+
235
+ const KNOWN_EXT = new Set([...TEXT_EXT, ...IMAGE_EXT, ...PDF_EXT, ...OFFICE_EXT, ...ARCHIVE_EXT, ...VIDEO_EXT, ...AUDIO_EXT,
236
+ // binaries and assets whose names still deserve a menu
237
+ 'wasm', 'map', 'lockb', 'db', 'sqlite', 'sqlite3', 'parquet', 'arrow', 'avro', 'orc',
238
+ 'woff', 'woff2', 'ttf', 'otf', 'ttc', 'eot', 'fon', 'icns', 'cur',
239
+ 'psd', 'ai', 'eps', 'indd', 'fig', 'sketch', 'xd', 'blend', 'fbx', 'obj', 'glb', 'gltf', 'stl',
240
+ 'step', 'stp', 'iges', 'igs', '3ds', 'max', 'c4d', 'ma', 'mb', 'abc', 'usd', 'usdz',
241
+ 'iso', 'dmg', 'img', 'vhd', 'vhdx', 'vmdk', 'exe', 'msi', 'dll', 'so', 'dylib', 'bin', 'dat', 'pak',
242
+ 'class', 'jar', 'war', 'apk', 'ipa', 'deb', 'rpm', 'appimage', 'raw', 'cr2', 'nef', 'arw', 'dng'])
243
+
244
+ const MIME = {
245
+ html: 'text/html;charset=utf-8', htm: 'text/html;charset=utf-8', xhtml: 'application/xhtml+xml',
246
+ svg: 'image/svg+xml', pdf: 'application/pdf', md: 'text/plain;charset=utf-8',
247
+ markdown: 'text/plain;charset=utf-8', mdx: 'text/plain;charset=utf-8',
248
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
249
+ bmp: 'image/bmp', ico: 'image/x-icon', avif: 'image/avif', tif: 'image/tiff', tiff: 'image/tiff',
250
+ json: 'application/json', txt: 'text/plain;charset=utf-8',
251
+ mp4: 'video/mp4', m4v: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', ogv: 'video/ogg',
252
+ mp3: 'audio/mpeg', wav: 'audio/wav', flac: 'audio/flac', m4a: 'audio/mp4', aac: 'audio/aac',
253
+ ogg: 'audio/ogg', oga: 'audio/ogg', opus: 'audio/ogg',
254
+ }
255
+
256
+ /** An absolute http(s) URL, or '' for anything else (mailto:, blob:, javascript:). */
257
+ function linkFromHref(href) {
258
+ const value = String(href || '').trim()
259
+ if (!/^https?:\/\//i.test(value)) return ''
260
+ try {
261
+ const url = new URL(value)
262
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''
263
+ return url.href
264
+ } catch {
265
+ return ''
266
+ }
267
+ }
268
+
269
+ function isAbsolutePath(value) {
270
+ return /^(?:[A-Za-z]:[\\/]|\\\\[^\\]|\/)/.test(String(value || ''))
271
+ }
272
+
273
+ function normalizeSlashes(value) {
274
+ return String(value || '').replace(/\\/g, '/')
275
+ }
276
+
277
+ function basename(value) {
278
+ const s = normalizeSlashes(value).replace(/\/+$/, '')
279
+ const at = s.lastIndexOf('/')
280
+ return at === -1 ? s : s.slice(at + 1)
281
+ }
282
+
283
+ function extname(value) {
284
+ const name = basename(value)
285
+ const at = name.lastIndexOf('.')
286
+ if (at <= 0) return ''
287
+ return name.slice(at + 1).toLowerCase()
288
+ }
289
+
290
+ function formatBytes(bytes) {
291
+ const n = Number(bytes)
292
+ if (!Number.isFinite(n) || n < 0) return ''
293
+ if (n < 1024) return `${n} B`
294
+ const units = ['KB', 'MB', 'GB', 'TB']
295
+ let value = n / 1024
296
+ let unit = 0
297
+ while (value >= 1024 && unit < units.length - 1) {
298
+ value /= 1024
299
+ unit += 1
300
+ }
301
+ return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`
302
+ }
303
+
304
+ /** `src/app.ts:12:3` and `src/app.ts#L12-L20` both name one file plus a location. */
305
+ function stripLocationSuffix(value) {
306
+ let path = String(value || '').trim()
307
+ const hash = path.match(/#L(\d+)(?:-L?(\d+))?$/)
308
+ if (hash) {
309
+ return {
310
+ path: path.slice(0, hash.index),
311
+ line: Number(hash[1]),
312
+ endLine: hash[2] === undefined ? undefined : Number(hash[2]),
313
+ }
314
+ }
315
+ const colon = path.match(/:(\d+)(?::(\d+))?$/)
316
+ if (colon) {
317
+ const head = path.slice(0, colon.index)
318
+ // A bare drive prefix (`C:`) is not a location suffix.
319
+ if (!/^[A-Za-z]:$/.test(head)) {
320
+ return {
321
+ path: head,
322
+ line: Number(colon[1]),
323
+ column: colon[2] === undefined ? undefined : Number(colon[2]),
324
+ }
325
+ }
326
+ }
327
+ return { path }
328
+ }
329
+
330
+ function joinPath(cwd, relative) {
331
+ if (!cwd) return relative
332
+ const base = String(cwd).replace(/[\\/]+$/, '')
333
+ const rest = String(relative).replace(/^\.?[\\/]+/, '')
334
+ const sep = /^[A-Za-z]:/.test(base) || base.includes('\\') ? '\\' : '/'
335
+ return `${base}${sep}${rest}`
336
+ }
337
+
338
+ /** Whether one path sits inside a root directory (case-insensitive on Windows). */
339
+ function isInside(root, path) {
340
+ const windows = /^(?:[A-Za-z]:|\\\\|\/\/)/.test(String(root || ''))
341
+ const normalize = (value) => windows ? normalizeSlashes(value).toLowerCase() : normalizeSlashes(value)
342
+ const a = normalize(root || '').replace(/\/+$/, '')
343
+ const b = normalize(path || '')
344
+ if (a === '' && root === '/') return b.startsWith('/')
345
+ if (a === '' || b === '') return false
346
+ return b === a || b.startsWith(`${a}/`)
347
+ }
348
+
349
+ /** The path relative to a root, or '' when it is not inside it. */
350
+ function relativeTo(root, path) {
351
+ const a = normalizeSlashes(root || '').replace(/\/+$/, '')
352
+ const b = normalizeSlashes(path || '')
353
+ if (a === '' && root === '/') return b.startsWith('/') ? b.slice(1) : ''
354
+ if (a === '' || b === '') return ''
355
+ if (!isInside(root, path)) return ''
356
+ if (b.length === a.length) return ''
357
+ const prefix = `${a}/`
358
+ return b.slice(prefix.length)
359
+ }
360
+
361
+ /** A title, an attribute value, or a text token worth resolving as a file. */
362
+ function looksLikePath(value) {
363
+ if (typeof value !== 'string') return false
364
+ const s = value.trim()
365
+ if (s.length === 0 || s.length > 1024) return false
366
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) return false
367
+ if (/[\r\n\t]/.test(s)) return false
368
+ if (/[\\/]/.test(s)) return true
369
+ const ext = extname(s)
370
+ return ext.length > 0 && KNOWN_EXT.has(ext)
371
+ }
372
+
373
+ function pathFromHref(href) {
374
+ const value = String(href || '').trim()
375
+ if (value === '') return ''
376
+ // The chat, the Sidebar and the delivery table address files as
377
+ // `dsh-resource://file/session/<sessionId>/<path>`; the path is workspace
378
+ // relative or absolute with its drive letter kept literal.
379
+ const resource = /^dsh-resource:\/\/file\/([^?#]*)/i.exec(value)
380
+ if (resource) {
381
+ const match = /^(?:session\/[^/]+|absolute)\/(.+)$/.exec(resource[1])
382
+ if (!match) return ''
383
+ try { return decodeURIComponent(match[1]) } catch { return match[1] }
384
+ }
385
+ if (/^file:\/\//i.test(value)) {
386
+ let path = ''
387
+ try {
388
+ path = decodeURIComponent(value.replace(/^file:\/\//i, ''))
389
+ } catch {
390
+ // A malformed escape (file:///C:/a%zz) must not throw inside the
391
+ // capture-phase listener; fall back to the raw text.
392
+ path = value.replace(/^file:\/\//i, '')
393
+ }
394
+ if (/^\/[A-Za-z]:/.test(path)) path = path.slice(1)
395
+ return path
396
+ }
397
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return ''
398
+ if (value.startsWith('#')) return ''
399
+ return looksLikePath(value) ? value : ''
400
+ }
401
+
402
+ /* ─────────────────────────── file classification ─────────────────────────── */
403
+
404
+ /**
405
+ * Extension-less text files are the norm in a repository root (Dockerfile,
406
+ * Makefile, LICENSE, .gitignore). `extname` returns '' for all of them, so
407
+ * without this set they would be classified as binary and lose "copy
408
+ * contents".
409
+ */
410
+ const TEXT_NAMES = new Set([
411
+ 'dockerfile', 'containerfile', 'makefile', 'gnumakefile', 'cmakelists.txt', 'procfile',
412
+ 'gemfile', 'rakefile', 'vagrantfile', 'jenkinsfile', 'brewfile', 'justfile', 'taskfile',
413
+ 'license', 'licence', 'notice', 'readme', 'changelog', 'changes', 'authors', 'contributors',
414
+ 'copying', 'copyright', 'install', 'todo', 'version', 'codeowners', 'editorconfig',
415
+ ])
416
+
417
+ /** A dotfile with no second dot is configuration text, not a binary blob. */
418
+ function isTextName(path) {
419
+ const name = basename(path).toLowerCase()
420
+ if (name === '') return false
421
+ if (TEXT_NAMES.has(name)) return true
422
+ // `.gitignore`, `.env`, and also `.env.local` / `.env.production`: a leading
423
+ // dot names a config file, whatever the trailing label looks like.
424
+ return name.startsWith('.')
425
+ }
426
+
427
+ /**
428
+ * The kind decides which rows appear: images get a browser preview, text gets
429
+ * copy-contents, archives and Office files only get the external actions.
430
+ */
431
+ function classify(path, isDir) {
432
+ if (isDir) return { kind: 'dir', ext: '', canCopyText: false, canBrowser: false, canSaveAs: false }
433
+ const ext = extname(path)
434
+ if (IMAGE_EXT.has(ext)) return { kind: 'image', ext, canCopyText: false, canBrowser: true, canSaveAs: true }
435
+ if (HTML_EXT.has(ext)) return { kind: 'html', ext, canCopyText: true, canBrowser: true, canSaveAs: true }
436
+ if (PDF_EXT.has(ext)) return { kind: 'pdf', ext, canCopyText: false, canBrowser: true, canSaveAs: true }
437
+ if (MD_EXT.has(ext)) return { kind: 'markdown', ext, canCopyText: true, canBrowser: true, canSaveAs: true }
438
+ if (OFFICE_EXT.has(ext)) return { kind: 'office', ext, canCopyText: false, canBrowser: false, canSaveAs: true }
439
+ if (ARCHIVE_EXT.has(ext)) return { kind: 'archive', ext, canCopyText: false, canBrowser: false, canSaveAs: true }
440
+ if (VIDEO_EXT.has(ext)) return { kind: 'video', ext, canCopyText: false, canBrowser: BROWSER_VIDEO.has(ext), canSaveAs: true }
441
+ if (AUDIO_EXT.has(ext)) return { kind: 'audio', ext, canCopyText: false, canBrowser: BROWSER_AUDIO.has(ext), canSaveAs: true }
442
+ if (TEXT_EXT.has(ext)) return { kind: 'text', ext, canCopyText: true, canBrowser: false, canSaveAs: true }
443
+ if (isTextName(path)) return { kind: 'text', ext, canCopyText: true, canBrowser: false, canSaveAs: true }
444
+ return { kind: 'binary', ext, canCopyText: false, canBrowser: false, canSaveAs: true }
445
+ }
446
+
447
+ function mimeOf(path) {
448
+ const ext = extname(path)
449
+ if (MIME[ext]) return MIME[ext]
450
+ if (TEXT_EXT.has(ext) || isTextName(path)) return 'text/plain;charset=utf-8'
451
+ return 'application/octet-stream'
452
+ }
453
+
454
+ const EDITOR_RE = /(visual studio code|vs ?code|vscode|cursor|windsurf|zed|sublime|intellij|pycharm|webstorm|goland|rider|phpstorm|android studio|notepad\+\+|vim|neovim|emacs|textmate|xcode)/i
455
+
456
+ function pickEditor(apps) {
457
+ if (!Array.isArray(apps)) return null
458
+ return apps.find((app) => app && typeof app.name === 'string' && EDITOR_RE.test(app.name)) || null
459
+ }
460
+
461
+ function orderApplications(apps, preferredId) {
462
+ const seen = new Set()
463
+ const rank = (app) => app.id === preferredId ? 0 : app.default ? 1 : EDITOR_RE.test(app.name) ? 2 : 3
464
+ return (Array.isArray(apps) ? apps : [])
465
+ .filter((app) => {
466
+ if (!app || typeof app.id !== 'string' || typeof app.name !== 'string' || seen.has(app.id)) return false
467
+ seen.add(app.id)
468
+ return true
469
+ })
470
+ .map((app, index) => ({ app, index }))
471
+ .sort((a, b) => rank(a.app) - rank(b.app) || a.index - b.index)
472
+ .map(({ app }) => app)
473
+ }
474
+
475
+ // Store application IDs only, never commands. Host validation still owns opens.
476
+ function applicationPreferences(storage) {
477
+ const key = 'dsh-file-actions.preferred-apps.v1'
478
+ const type = (path) => extname(path).toLowerCase() || '[no extension]'
479
+ const read = () => {
480
+ try {
481
+ const value = JSON.parse(storage.getItem(key) || '{}')
482
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
483
+ } catch { return {} }
484
+ }
485
+ return {
486
+ get(path) { const value = read()[type(path)]; return typeof value === 'string' ? value : '' },
487
+ set(path, appId) {
488
+ try {
489
+ const value = read()
490
+ if (appId) Object.defineProperty(value, type(path), { value: appId, enumerable: true, configurable: true, writable: true })
491
+ else delete value[type(path)]
492
+ storage.setItem(key, JSON.stringify(value))
493
+ return true
494
+ } catch { return false }
495
+ },
496
+ }
497
+ }
498
+
499
+ function buildStatusModel(path, message, t, retry = false) {
500
+ return { title: basename(path) || path, subtitle: path, pathLine: path, kind: 'binary',
501
+ status: true, sections: [[{ id: 'status', label: message, disabled: true }],
502
+ ...(retry ? [[{ id: 'retry', label: t.retry, action: 'retry', icon: 'open' }]] : [])] }
503
+ }
504
+
505
+ /* ─────────────────────────── menu model (pure) ─────────────────────────── */
506
+
507
+ /**
508
+ * Build the rows as data so the widget stays a dumb renderer and the tests can
509
+ * assert which actions a file kind earns.
510
+ * @param input - resolved target: absolute path, display path, kind flags, apps.
511
+ * @returns header text plus ordered sections of rows.
512
+ */
513
+ /**
514
+ * A reference like `src/app.ts:42:7` or `notes.md#L42-L48` names a line. The
515
+ * Host cannot position an editor, but showing and copying the location still
516
+ * answers "which part of the file" while the menu is open.
517
+ */
518
+ function formatLocation(location, t) {
519
+ const line = location && Number(location.line)
520
+ if (!line) return ''
521
+ const end = Number(location && location.endLine)
522
+ if (end && end !== line) return t.lineRange.replace('{from}', line).replace('{to}', end)
523
+ return t.lineAt.replace('{line}', line)
524
+ }
525
+
526
+ function buildMenuModel(input) {
527
+ const t = input.t || ZH
528
+ const { absolutePath, displayPath, isDir, bytes } = input
529
+ const apps = orderApplications(input.apps, input.preferredId)
530
+ const file = classify(absolutePath, isDir)
531
+ const sections = []
532
+ const primary = []
533
+
534
+ primary.push({
535
+ id: 'open',
536
+ label: isDir ? t.openFolder : t.open,
537
+ icon: isDir ? 'folder' : 'open',
538
+ action: 'open',
539
+ })
540
+
541
+ const editor = apps.find((app) => app.id === input.preferredId) || pickEditor(apps)
542
+ if (!isDir && (editor || input.appsState)) {
543
+ primary.push({
544
+ id: 'editor',
545
+ label: editor ? t.openIn(editor.name) : input.appsState === 'loading' ? t.appsLoading : t.noPreferred,
546
+ icon: 'editor',
547
+ action: 'open',
548
+ appId: editor?.id,
549
+ disabled: !editor,
550
+ })
551
+ }
552
+
553
+ if (!isDir && (apps.length > 0 || input.appsState)) {
554
+ primary.push({
555
+ id: 'openWith',
556
+ label: input.appsState === 'failed' ? t.appsFailed : input.appsState === 'ready' && !apps.length ? t.appsEmpty : t.openWith,
557
+ icon: 'chevron',
558
+ action: input.appsState === 'failed' ? 'retryApps' : undefined,
559
+ keepOpen: input.appsState === 'failed',
560
+ disabled: apps.length === 0 && input.appsState !== 'failed',
561
+ children: apps.length ? apps.map((app) => ({
562
+ id: `app:${app.id}`,
563
+ label: app.name,
564
+ action: 'open',
565
+ appId: app.id,
566
+ iconData: app.icon || null,
567
+ checked: app.default === true,
568
+ preferred: app.id === input.preferredId,
569
+ pinnable: true,
570
+ })) : undefined,
571
+ })
572
+ }
573
+ if (primary.length > 0) sections.push(primary)
574
+
575
+ const navigate = []
576
+ if (!isDir && file.canBrowser) {
577
+ navigate.push({
578
+ id: 'browser',
579
+ label: t.browser,
580
+ icon: 'globe',
581
+ action: 'browser',
582
+ })
583
+ }
584
+ navigate.push({
585
+ id: 'reveal',
586
+ label: isDir ? t.revealFolder : t.reveal,
587
+ icon: 'reveal',
588
+ action: 'reveal',
589
+ })
590
+ sections.push(navigate)
591
+
592
+ const transfer = [
593
+ { id: 'copyPath', label: t.copyPath, icon: 'copy', action: 'copyPath' },
594
+ ]
595
+ if (displayPath && normalizeSlashes(displayPath) !== normalizeSlashes(absolutePath)) {
596
+ transfer.push({ id: 'copyRelPath', label: t.copyRelPath, icon: 'copy', action: 'copyRelPath' })
597
+ }
598
+ if (!isDir) {
599
+ transfer.push({ id: 'copyFileName', label: t.copyFileName, icon: 'copy', action: 'copyFileName' })
600
+ }
601
+ // Copying the reference verbatim is only useful when it carries a location.
602
+ if (!isDir && input.location && Number(input.location.line) && input.reference) {
603
+ transfer.push({ id: 'copyReference', label: t.copyReference, icon: 'clipboard', action: 'copyReference' })
604
+ }
605
+ if (!isDir && file.canCopyText) {
606
+ transfer.push({ id: 'copyContents', label: t.copyContents, icon: 'clipboard', action: 'copyContents' })
607
+ }
608
+ if (!isDir) {
609
+ transfer.push({ id: 'saveAs', label: t.saveAs, icon: 'download', action: 'saveAs' })
610
+ }
611
+ sections.push(transfer)
612
+
613
+ const kindLabel = t.kinds[file.kind] || t.kinds.binary
614
+ const locationLabel = formatLocation(input.location, t)
615
+ const metaParts = locationLabel ? [locationLabel, kindLabel] : [kindLabel]
616
+ if (!isDir && bytes) metaParts.push(formatBytes(bytes))
617
+ // Copy/save-as read through the Host, which also serves paths outside the
618
+ // session workspace. Say so instead of letting the absolute path be the only
619
+ // clue that the file is not part of this project.
620
+ if (input.outsideWorkspace) metaParts.push(t.outsideWorkspace)
621
+ const meta = metaParts.join(' · ')
622
+
623
+ return {
624
+ title: basename(absolutePath) || absolutePath,
625
+ subtitle: absolutePath,
626
+ // Inside the workspace the relative path answers "which one"; outside it,
627
+ // the absolute path is the only thing that does.
628
+ pathLine: input.outsideWorkspace
629
+ ? absolutePath
630
+ : (relativeTo(input.cwd, absolutePath) || displayPath || absolutePath),
631
+ sizeLabel: !isDir && bytes ? formatBytes(bytes) : '',
632
+ kindLabel,
633
+ locationLabel,
634
+ meta,
635
+ kind: file.kind,
636
+ preview: file.kind === 'image' || extname(absolutePath) === 'svg',
637
+ outsideWorkspace: input.outsideWorkspace === true,
638
+ sections: sections.filter((section) => section.length > 0),
639
+ }
640
+ }
641
+
642
+ /**
643
+ * The same menu shape for a web link: the header names the site and the path,
644
+ * and the rows act on the URL instead of a file.
645
+ */
646
+ function buildLinkMenuModel(input) {
647
+ const t = input.t || ZH
648
+ let host = input.url
649
+ let pathLine = input.url
650
+ try {
651
+ const url = new URL(input.url)
652
+ host = url.hostname.replace(/^www\./i, '') || input.url
653
+ const path = `${url.pathname}${url.search}`.replace(/\/$/, '')
654
+ pathLine = path === '' ? '/' : path
655
+ } catch { /* keep the raw URL */ }
656
+ const copy = [
657
+ { id: 'copyLink', label: t.copyLink, icon: 'copy', action: 'copyLink' },
658
+ { id: 'copyLinkText', label: t.copyLinkText, icon: 'clipboard', action: 'copyLinkText' },
659
+ ]
660
+ // `[text](url)` only exists when the link has visible text to label it.
661
+ if (String(input.text || '').trim() !== '') {
662
+ copy.push({ id: 'copyMarkdown', label: t.copyMarkdown, icon: 'markdown', action: 'copyMarkdown' })
663
+ }
664
+ return {
665
+ title: host,
666
+ subtitle: input.url,
667
+ pathLine,
668
+ sizeLabel: '',
669
+ kindLabel: t.kinds.link,
670
+ meta: t.kinds.link,
671
+ kind: 'link',
672
+ outsideWorkspace: false,
673
+ sections: [
674
+ [{ id: 'openLink', label: t.openLink, icon: 'globe', action: 'openLink' }],
675
+ copy,
676
+ ],
677
+ }
678
+ }
679
+
680
+ /* ─────────────────────────────── icons ─────────────────────────────── */
681
+
682
+ const ICON_PATHS = {
683
+ open: '<path d="M9.2 2.6H4.4a1 1 0 0 0-1 1v8.8a1 1 0 0 0 1 1h7.2a1 1 0 0 0 1-1V6.2z"/><path d="M9.2 2.6v3.6h3.4"/>',
684
+ folder: '<path d="M2.5 5.5A1.5 1.5 0 0 1 4 4h3l1.4 1.5H12a1.5 1.5 0 0 1 1.5 1.5v4.5A1.5 1.5 0 0 1 12 13H4a1.5 1.5 0 0 1-1.5-1.5z"/>',
685
+ editor: '<path d="M5.8 4.6 2.8 8l3 3.4"/><path d="M10.2 4.6 13.2 8l-3 3.4"/>',
686
+ chevron: '<path d="M6.5 4.5 10 8l-3.5 3.5"/>',
687
+ reveal: '<path d="M2.5 4.5h5l1.3 1.6H13a1 1 0 0 1 1 1v5.4a1 1 0 0 1-1 1H3.5a1 1 0 0 1-1-1z"/><path d="M6 10.4h4"/>',
688
+ copy: '<rect x="5.5" y="5.5" width="7" height="8" rx="1.2"/><path d="M10.5 3.6V3a1 1 0 0 0-1-1H3.9a1 1 0 0 0-1 1v7.2a1 1 0 0 0 1 1h.6"/>',
689
+ clipboard: '<rect x="4.5" y="3.5" width="7" height="10" rx="1.3"/><path d="M6.6 3.5V2.8h2.8v.7"/><path d="M6.4 7h3.2"/><path d="M6.4 9.4h3.2"/>',
690
+ download: '<path d="M8 2.8v6.4"/><path d="M5.6 7.2 8 9.6l2.4-2.4"/><path d="M3.2 11.4v1.2a1 1 0 0 0 1 1h7.6a1 1 0 0 0 1-1v-1.2"/>',
691
+ globe: '<circle cx="8" cy="8" r="5.4"/><path d="M2.8 8h10.4"/><path d="M8 2.6c1.6 1.6 2.4 3.4 2.4 5.4S9.6 11.8 8 13.4C6.4 11.8 5.6 10 5.6 8S6.4 4.2 8 2.6"/>',
692
+ check: '<path d="M3.6 8.4 6.6 11.4 12.4 4.9"/>',
693
+ link: '<path d="M6.6 9.4a2.6 2.6 0 0 0 3.7 0l1.9-1.9a2.6 2.6 0 0 0-3.7-3.7l-1 1"/><path d="M9.4 6.6a2.6 2.6 0 0 0-3.7 0L3.8 8.5a2.6 2.6 0 0 0 3.7 3.7l1-1"/>',
694
+ // File-type glyphs for the header tile: shape carries the kind, the tile hue
695
+ // only reinforces it.
696
+ text: '<path d="M4 4.6h8"/><path d="M4 7.6h8"/><path d="M4 10.6h5"/>',
697
+ markdown: '<path d="M2.8 10.6V5.4l2.1 2.5 2.1-2.5v5.2"/><path d="M11.1 5.6v3.4"/><path d="M9.6 7.6l1.5 1.5 1.5-1.5"/>',
698
+ image: '<rect x="2.6" y="3.6" width="10.8" height="8.8" rx="1.4"/><circle cx="6" cy="6.6" r="1"/><path d="M3.6 11.2 6.9 8.3l1.9 1.7 1.8-1.4 1.9 2.1"/>',
699
+ web: '<rect x="2.6" y="3.4" width="10.8" height="9.2" rx="1.4"/><path d="M2.6 6.2h10.8"/><circle cx="4.6" cy="4.8" r=".5" fill="currentColor" stroke="none"/><circle cx="6.2" cy="4.8" r=".5" fill="currentColor" stroke="none"/>',
700
+ doc: '<path d="M9.2 2.6H4.4a1 1 0 0 0-1 1v8.8a1 1 0 0 0 1 1h7.2a1 1 0 0 0 1-1V6.2z"/><path d="M9.2 2.6v3.6h3.4"/>',
701
+ office: '<rect x="3.4" y="2.8" width="9.2" height="10.4" rx="1.3"/><path d="M6 6h4"/><path d="M6 8.3h4"/><path d="M6 10.6h2.4"/>',
702
+ archive: '<rect x="3" y="4.8" width="10" height="7.4" rx="1.2"/><path d="M3 7.5h10"/><path d="M6.6 4.8V3.4h2.8v1.4"/>',
703
+ binary: '<rect x="3" y="3.4" width="10" height="9.2" rx="1.2"/><path d="M5.6 6.6h1.1v3.2"/><path d="M9.3 6.6h1.5l-1.5 3.2h1.5"/>',
704
+ }
705
+
706
+ /** The header tile glyph for one file kind. */
707
+ const KIND_ICONS = {
708
+ text: 'text',
709
+ markdown: 'markdown',
710
+ html: 'web',
711
+ pdf: 'doc',
712
+ image: 'image',
713
+ office: 'office',
714
+ archive: 'archive',
715
+ binary: 'binary',
716
+ dir: 'folder',
717
+ link: 'link',
718
+ }
719
+
720
+ function iconSvg(name) {
721
+ const body = ICON_PATHS[name] || ICON_PATHS.open
722
+ return `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`
723
+ }
724
+
725
+ /* ─────────────────────────────── styles ─────────────────────────────── */
726
+
727
+ const CSS = `
728
+ .dfa-pin{flex:none;margin-left:6px;border:0;border-radius:4px;background:transparent;color:inherit;
729
+ font:inherit;cursor:pointer;padding:0 5px;line-height:1.6}
730
+ .dfa-pin:hover,.dfa-pin:focus-visible{background:rgba(127,127,127,.22);outline:1px solid currentColor}
731
+ .dfa-pin[aria-pressed="true"]{color:#5b8cff}
732
+
733
+ .dfa-menu{position:fixed;z-index:2147483000;width:min(24em,calc(100vw - 16px));min-width:0;max-width:calc(100vw - 16px);box-sizing:border-box;
734
+ display:flex;flex-direction:column;max-height:min(78vh,560px);
735
+ border-radius:var(--dsw-radius-lg,10px);border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.32));
736
+ /* Readability floor: an almost-opaque base colour sits under the themed layer.
737
+ A translucent theme (liquid glass over a wallpaper) keeps its tint, but text
738
+ never lands on the wallpaper itself. The exact colour is computed at open
739
+ time from the theme's own background; Canvas is the pre-JS fallback. */
740
+ background-color:var(--dfa-floor,Canvas);
741
+ background-image:linear-gradient(var(--dsw-alias-bg-layer-1,transparent),var(--dsw-alias-bg-layer-1,transparent));
742
+ color:var(--dsw-alias-label-primary,#e8e8e8);
743
+ box-shadow:0 1px 2px rgba(0,0,0,.14),0 14px 38px rgba(0,0,0,.32);
744
+ font-family:var(--dsw-font-family,system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif);
745
+ font-size:max(13px,.875rem);line-height:1.5;-webkit-font-smoothing:antialiased;user-select:none;outline:none;
746
+ animation:dfa-in 90ms cubic-bezier(.2,.7,.3,1) both}
747
+ /* An explicit display on a row beats the UA [hidden] rule, so filtered rows need
748
+ this to actually disappear. */
749
+ .dfa-menu [hidden]{display:none!important}
750
+ @keyframes dfa-in{from{opacity:0}to{opacity:1}}
751
+ @media (prefers-reduced-motion:reduce){.dfa-menu{animation:none}.dfa-item{transition:none}}
752
+ /* The body is the scroll container so the fades can sit on its edges without
753
+ fading the menu's own border and shadow. */
754
+ .dfa-body{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;scrollbar-gutter:stable;
755
+ padding:5px;scrollbar-width:thin;scrollbar-color:var(--dsw-alias-border-l2,rgba(127,127,127,.4)) transparent}
756
+ .dfa-body[data-fade="both"]{mask-image:linear-gradient(to bottom,transparent 0,#000 16px,#000 calc(100% - 16px),transparent 100%)}
757
+ .dfa-body[data-fade="top"]{mask-image:linear-gradient(to bottom,transparent 0,#000 16px)}
758
+ .dfa-body[data-fade="bottom"]{mask-image:linear-gradient(to bottom,#000 calc(100% - 16px),transparent 100%)}
759
+ .dfa-thumb{height:118px;box-sizing:content-box;color:var(--dsw-alias-label-tertiary,#888);font-size:.85em;margin:-5px -5px 6px;padding:9px;display:grid;place-items:center;
760
+ background:color-mix(in srgb,var(--dsw-static-neutral-00,#fff) 6%,transparent);
761
+ border-bottom:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.28))}
762
+ .dfa-thumb img{max-width:100%;max-height:118px;display:block;object-fit:contain;border-radius:6px}
763
+ .dfa-head{display:flex;align-items:center;gap:9px;padding:6px 7px 8px;min-width:0;
764
+ border-bottom:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.28));margin-bottom:3px}
765
+ .dfa-tile{flex:none;width:28px;height:28px;border-radius:8px;display:grid;place-items:center;
766
+ color:var(--dfa-hue,#5b8cff);
767
+ background:color-mix(in srgb,var(--dfa-hue,#5b8cff) 13%,transparent);
768
+ box-shadow:inset 0 0 0 .5px color-mix(in srgb,var(--dfa-hue,#5b8cff) 30%,transparent)}
769
+ .dfa-tile svg{width:16px;height:16px}
770
+ .dfa-tile[data-kind="text"]{--dfa-hue:#5b8cff}
771
+ .dfa-tile[data-kind="markdown"]{--dfa-hue:#7b8cff}
772
+ .dfa-tile[data-kind="html"]{--dfa-hue:#e0714a}
773
+ .dfa-tile[data-kind="pdf"]{--dfa-hue:#e5484d}
774
+ .dfa-tile[data-kind="image"]{--dfa-hue:#2fa46b}
775
+ .dfa-tile[data-kind="office"]{--dfa-hue:#2f6fdb}
776
+ .dfa-tile[data-kind="archive"]{--dfa-hue:#c9971f}
777
+ .dfa-tile[data-kind="binary"]{--dfa-hue:#8b8d98}
778
+ .dfa-tile[data-kind="dir"]{--dfa-hue:#c9971f}
779
+ .dfa-tile[data-kind="link"]{--dfa-hue:#4f8cff}
780
+ .dfa-headtext{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}
781
+ .dfa-name{font-weight:600;font-size:1em;line-height:1.4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
782
+ .dfa-path{font-family:var(--ds-font-family-code,Consolas,monospace);font-size:.85em;line-height:1.4;
783
+ color:var(--dsw-alias-label-tertiary,rgba(160,160,160,.95));overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
784
+ .dfa-loc{flex:none;align-self:flex-start;margin-top:2px;padding:0 6px;border-radius:999px;font-size:.85em;line-height:1.4;
785
+ background:color-mix(in srgb,var(--dsw-alias-label-primary,#e8e8e8) 10%,transparent);
786
+ color:var(--dsw-alias-label-secondary,#a9adb5)}
787
+ .dfa-size{flex:none;align-self:flex-start;padding-top:2px;font-size:.85em;line-height:1.4;
788
+ color:var(--dsw-alias-label-tertiary,rgba(160,160,160,.95));font-variant-numeric:tabular-nums}
789
+ .dfa-aside{flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:2px}
790
+ .dfa-badge{flex:none;align-self:flex-start;margin-top:2px;font-size:.85em;line-height:1.4;padding:0 6px;border-radius:999px;
791
+ border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.4));color:var(--dsw-alias-label-tertiary,rgba(160,160,160,.95))}
792
+ .dfa-sep{height:1px;margin:5px 7px;background:var(--dsw-alias-border-l2,rgba(127,127,127,.28))}
793
+ .dfa-item{display:flex;align-items:center;gap:10px;width:100%;box-sizing:border-box;min-height:30px;padding:4px 8px;
794
+ border:0;border-radius:6px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;
795
+ transition:background-color 70ms ease}
796
+ .dfa-item[data-focus="true"]{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.18))}
797
+ .dfa-menu[data-keyboard="true"] .dfa-item[data-focus="true"]{
798
+ box-shadow:inset 0 0 0 1.5px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4f8cff))}
799
+ .dfa-item[aria-disabled="true"]{opacity:.45;cursor:default}
800
+ .dfa-ico{flex:none;width:16px;height:16px;display:flex;align-items:center;justify-content:center;
801
+ color:var(--dsw-alias-label-secondary,rgba(190,190,190,.95))}
802
+ .dfa-ico img{width:16px;height:16px;object-fit:contain;display:block;border-radius:3px}
803
+ .dfa-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
804
+ .dfa-tail{flex:none;display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-tertiary,rgba(160,160,160,.95));font-size:.85em}
805
+ .dfa-default{font-size:.85em;line-height:1.4;padding:0 6px;border-radius:999px;
806
+ color:var(--dsw-alias-state-business-primary,#4f8cff);
807
+ background:color-mix(in srgb,var(--dsw-alias-state-business-primary,#4f8cff) 14%,transparent)}
808
+ .dfa-sub{position:fixed;z-index:2147483001;max-height:min(64vh,440px)}
809
+ .dfa-subhead{position:sticky;top:-5px;z-index:2;display:flex;align-items:center;justify-content:space-between;gap:8px;
810
+ margin:-5px -5px 0;padding:6px 13px 5px;background:var(--dsw-alias-bg-layer-1,#24262b);
811
+ border-bottom:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.28));
812
+ font-size:.85em;line-height:1.4;color:var(--dsw-alias-label-tertiary,rgba(160,160,160,.95))}
813
+ .dfa-searchwrap{position:sticky;top:27px;z-index:1;margin:0 -5px 4px;padding:5px 8px 6px;
814
+ background:var(--dsw-alias-bg-layer-1,#24262b);border-bottom:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.2))}
815
+ .dfa-search{width:100%;box-sizing:border-box;font:inherit;font-size:1em;line-height:1.4;padding:3px 7px;
816
+ border-radius:5px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.4));
817
+ background:var(--dsw-alias-bg-layer-2,transparent);color:inherit;outline:none}
818
+ .dfa-search:focus{border-color:var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4f8cff))}
819
+ .dfa-search::placeholder{color:var(--dsw-alias-label-tertiary,rgba(160,160,160,.95))}
820
+ .dfa-empty{padding:8px 10px;font-size:.85em;color:var(--dsw-alias-label-tertiary,rgba(160,160,160,.95))}
821
+ .dfa-foot{display:flex;justify-content:center;gap:12px;margin-top:5px;padding:6px 8px 2px;
822
+ border-top:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.28));
823
+ font-size:.85em;line-height:1.4;color:var(--dsw-alias-label-tertiary,rgba(160,160,160,.95))}
824
+ .dfa-toast{position:fixed;left:50%;bottom:32px;transform:translateX(-50%);z-index:2147483002;
825
+ padding:7px 14px;border-radius:8px;font-size:.875rem;line-height:1.4;pointer-events:none;
826
+ background-color:var(--dfa-floor,Canvas);
827
+ background-image:linear-gradient(var(--dsw-alias-bg-layer-2,transparent),var(--dsw-alias-bg-layer-2,transparent));
828
+ color:var(--dsw-alias-label-primary,#eee);
829
+ box-shadow:0 2px 6px rgba(0,0,0,.18),0 10px 28px rgba(0,0,0,.28);
830
+ opacity:0;transition:opacity .16s ease}
831
+ .dfa-toast[data-show="true"]{opacity:1}
832
+ .dfa-toast[data-error="true"]{color:var(--dsw-alias-state-error-primary,#f85149)}
833
+ `
834
+
835
+ function ensureCss() {
836
+ if (typeof document === 'undefined') return
837
+ if (document.querySelector('style[data-plugin-css="dsh-file-actions"]')) return
838
+ const tag = document.createElement('style')
839
+ tag.dataset.plugin = 'dsh-file-actions'
840
+ tag.dataset.pluginCss = 'dsh-file-actions'
841
+ tag.textContent = CSS
842
+ document.head.appendChild(tag)
843
+ }
844
+
845
+ /**
846
+ * A near-opaque colour derived from the theme's own page background. A theme
847
+ * that makes every surface translucent (liquid glass over a wallpaper) would
848
+ * otherwise let the wallpaper through and drop the menu's contrast. Recomputed
849
+ * per open so a theme switch is picked up without a reload.
850
+ */
851
+ function menuFloor() {
852
+ if (typeof document === 'undefined') return ''
853
+ try {
854
+ const probe = document.createElement('div')
855
+ // Derive the floor from the same surface colour the menu paints, so a dark
856
+ // glass theme gets a dark floor instead of a white one.
857
+ probe.style.cssText = 'position:fixed;left:-9999px;top:0;width:1px;height:1px;background:var(--dsw-alias-bg-layer-1,#24262b)'
858
+ document.body.appendChild(probe)
859
+ const raw = getComputedStyle(probe).backgroundColor
860
+ probe.remove()
861
+ const match = /rgba?\(([^)]+)\)/.exec(raw)
862
+ if (!match) return ''
863
+ const parts = match[1].split(',').map((value) => parseFloat(value))
864
+ const [r, g, b] = parts
865
+ const alpha = parts.length > 3 ? parts[3] : 1
866
+ if (![r, g, b, alpha].every((value) => Number.isFinite(value))) return ''
867
+ // Flatten the theme colour against black or white by its own luminance, then
868
+ // keep a hint of transparency so a glass theme still reads as glass.
869
+ const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
870
+ const base = luminance < 0.5 ? 0 : 255
871
+ const blend = (channel) => Math.round(channel * alpha + base * (1 - alpha))
872
+ return `rgba(${blend(r)}, ${blend(g)}, ${blend(b)}, 0.94)`
873
+ } catch {
874
+ return ''
875
+ }
876
+ }
877
+
878
+ /* ─────────────────────────────── toast ─────────────────────────────── */
879
+
880
+ let toastTimer = null
881
+ function toast(text, options) {
882
+ if (typeof document === 'undefined' || !text) return
883
+ let node = document.querySelector('.dfa-toast')
884
+ if (!node) {
885
+ node = document.createElement('div')
886
+ node.className = 'dfa-toast'
887
+ const floor = menuFloor()
888
+ if (floor) node.style.setProperty('--dfa-floor', floor)
889
+ document.body.appendChild(node)
890
+ }
891
+ node.textContent = text
892
+ node.dataset.error = options && options.error ? 'true' : 'false'
893
+ // Force a frame so a repeated message re-runs the fade-in.
894
+ node.dataset.show = 'false'
895
+ requestAnimationFrame(() => {
896
+ node.dataset.show = 'true'
897
+ })
898
+ if (toastTimer) clearTimeout(toastTimer)
899
+ toastTimer = setTimeout(() => {
900
+ node.dataset.show = 'false'
901
+ }, options && options.error ? 3200 : 1800)
902
+ }
903
+
904
+ /* ─────────────────────────────── bridge ─────────────────────────────── */
905
+
906
+ /** Every Remote call answers `{ ok, value }`; unwrap or return null. */
907
+ function unwrap(result) {
908
+ if (!result || typeof result !== 'object') return null
909
+ if (result.ok === true) return result.value
910
+ return null
911
+ }
912
+
913
+ function probeError(error) {
914
+ const code = String(error?.code || '')
915
+ if (/not-found|ENOENT/.test(code)) return 'notFound'
916
+ if (/denied|forbidden|EACCES|EPERM/.test(code)) return 'denied'
917
+ return 'probeFailed'
918
+ }
919
+
920
+ async function boundedCall(call, milliseconds = 8000) {
921
+ let timer
922
+ try {
923
+ return await Promise.race([Promise.resolve().then(call), new Promise((_, reject) => {
924
+ timer = setTimeout(() => reject(new Error('Remote request timed out')), milliseconds)
925
+ })])
926
+ } finally { clearTimeout(timer) }
927
+ }
928
+
929
+ function createBridge(ctx) {
930
+ const remote = ctx.remote
931
+ const sessions = ctx.sessions
932
+ // Both namespaces are optional so a Host without one still gets the rows it
933
+ // can serve: no workspaceFiles means no verification and no read actions.
934
+ // Cordis throws on an undeclared sub-service, so both are declared in
935
+ // `inject` and every access stays guarded.
936
+ let files = null
937
+ let sessionApi = null
938
+ try { files = remote && remote.workspaceFiles ? remote.workspaceFiles : null } catch { files = null }
939
+ try { sessionApi = remote && remote.session ? remote.session : null } catch { sessionApi = null }
940
+
941
+ function activeSession(sessionId) {
942
+ try {
943
+ const snapshot = sessions && sessions.list && sessions.list.getSnapshot
944
+ ? sessions.list.getSnapshot()
945
+ : null
946
+ const rows = snapshot && snapshot.byId ? Object.values(snapshot.byId) : []
947
+ if (sessionId) {
948
+ const exact = rows.find((row) => row && row.id === sessionId)
949
+ // The Host can resolve a Session header even when the Client has not
950
+ // retained it. Leave relative paths for that Session's Remote.
951
+ return { id: sessionId, cwd: exact?.cwd }
952
+ }
953
+ if (rows.length === 0) return null
954
+ const retained = rows.find((row) => row && row.retainedBy && row.retainedBy.mainView > 0)
955
+ const pick = retained || rows.find((row) => row && typeof row.cwd === 'string' && row.cwd !== '')
956
+ return pick ? { id: pick.id, cwd: pick.cwd } : null
957
+ } catch {
958
+ return null
959
+ }
960
+ }
961
+
962
+ return {
963
+ activeSession,
964
+ /** Resolve one path to a real file or directory, or null when it is neither. */
965
+ async probe(sessionId, path) {
966
+ if (!files) return { error: 'unavailable' }
967
+ let failure = 'probeFailed'
968
+ try {
969
+ const result = await boundedCall(() => files.stat(sessionId, path))
970
+ const stat = unwrap(result)
971
+ if (!result?.ok) failure = probeError(result?.error)
972
+ if (stat && typeof stat.absolutePath === 'string') {
973
+ return { isDir: false, absolutePath: stat.absolutePath, bytes: stat.bytes, version: stat.version }
974
+ }
975
+ } catch (error) { failure = probeError(error) }
976
+ try {
977
+ const listing = unwrap(await boundedCall(() => files.list(sessionId, path)))
978
+ if (listing && Array.isArray(listing.entries)) {
979
+ return { isDir: true, absolutePath: path, bytes: undefined }
980
+ }
981
+ } catch { /* not a directory either */ }
982
+ return { error: failure }
983
+ },
984
+ async applications(path) {
985
+ if (!sessionApi) return { state: 'failed', apps: [] }
986
+ try {
987
+ const result = await boundedCall(() => sessionApi.workspacePathApplications({ path }))
988
+ const value = unwrap(result)
989
+ return result?.ok && Array.isArray(value) ? { state: 'ready', apps: value } : { state: 'failed', apps: [] }
990
+ } catch {
991
+ return { state: 'failed', apps: [] }
992
+ }
993
+ },
994
+ async open(path, appId) {
995
+ if (!sessionApi) return false
996
+ try {
997
+ const request = appId === undefined ? { path } : { path, application: appId }
998
+ return unwrap(await sessionApi.openWorkspacePath(request)) !== null
999
+ } catch {
1000
+ return false
1001
+ }
1002
+ },
1003
+ async reveal(path) {
1004
+ if (!sessionApi) return false
1005
+ try {
1006
+ return unwrap(await sessionApi.openWorkspacePath({ path, action: 'reveal' })) !== null
1007
+ } catch {
1008
+ return false
1009
+ }
1010
+ },
1011
+ async readBytes(sessionId, path, options) {
1012
+ if (!files) return null
1013
+ try {
1014
+ return unwrap(await files.readBytes(sessionId, path, options || {}))
1015
+ } catch {
1016
+ return null
1017
+ }
1018
+ },
1019
+ }
1020
+ }
1021
+
1022
+ /* ───────────────────────────── detection ───────────────────────────── */
1023
+
1024
+ const PATH_ATTRS = ['data-file-actions-path', 'data-files-path', 'data-review-file']
1025
+
1026
+ function isEditable(node) {
1027
+ if (!node || !node.closest) return false
1028
+ return node.closest('input, textarea, [contenteditable]:not([contenteditable="false"])') !== null
1029
+ }
1030
+
1031
+ /** Expand the text under the pointer to one path-like token. */
1032
+ function tokenAtPoint(doc, x, y) {
1033
+ if (!doc || typeof doc.caretRangeFromPoint !== 'function') return ''
1034
+ let range = null
1035
+ try {
1036
+ range = doc.caretRangeFromPoint(x, y)
1037
+ } catch {
1038
+ return ''
1039
+ }
1040
+ if (!range || !range.startContainer || range.startContainer.nodeType !== 3) return ''
1041
+ const text = range.startContainer.nodeValue || ''
1042
+ const at = Math.min(Math.max(range.startOffset, 0), text.length)
1043
+ const isPathChar = (ch) => /[\w.@()+\-\[\]{}~#$%&',=!\\/:]/.test(ch)
1044
+ let start = at
1045
+ let end = at
1046
+ while (start > 0 && isPathChar(text[start - 1])) start -= 1
1047
+ while (end < text.length && isPathChar(text[end])) end += 1
1048
+ return text.slice(start, end).replace(/^[`'"(<[]+/, '').replace(/[`'")>\],.;:!?]+$/, '')
1049
+ }
1050
+
1051
+ /**
1052
+ * A soft source (a tooltip, an element's text, the caret token) only counts when
1053
+ * it looks like an actual file: an absolute path or a name with an extension.
1054
+ * "2026/10/22 12:00", "2/3 files" and "src/app.ts · 2.1 KB" all contain a slash
1055
+ * but are prose, and swallowing the native menu for them is worse than missing
1056
+ * an exotic path.
1057
+ */
1058
+ function softPath(value) {
1059
+ const text = String(value || '').trim()
1060
+ if (text === '') return false
1061
+ if (isAbsolutePath(text)) return true
1062
+ return /\.[A-Za-z0-9]{1,10}$/.test(text)
1063
+ }
1064
+
1065
+ /**
1066
+ * A delivery table prefixes the name with an icon glyph ("📄发布文案.md"), and a
1067
+ * chip may start with a bullet or arrow. Strip that decoration before the name
1068
+ * is tested as a path — never strip a leading dot, slash or drive letter.
1069
+ */
1070
+ function stripDecorations(value) {
1071
+ return String(value || '').replace(/^[^\p{L}\p{N}._\\/-]+/u, '').trim()
1072
+ }
1073
+
1074
+ /**
1075
+ * Find the file a right-click points at. Structured carriers (a title or a
1076
+ * data attribute the GUI already writes) win; a bare text token is the
1077
+ * fallback for surfaces that only render the path as text.
1078
+ */
1079
+ function candidateFromEvent(event, doc) {
1080
+ const target = event.target
1081
+ if (!target || !target.closest) return null
1082
+ if (isEditable(target)) return null
1083
+
1084
+ // A resource address owns its Session and path, even when its child has a
1085
+ // different title. Do not reinterpret it against the selected chat.
1086
+ const resourceAnchor = target.closest('a[href^="dsh-resource://file/"]')
1087
+ if (resourceAnchor) {
1088
+ const href = resourceAnchor.getAttribute('href')
1089
+ const resourcePath = pathFromHref(href)
1090
+ const session = /^dsh-resource:\/\/file\/session\/([^/]+)\//i.exec(href)
1091
+ if (!resourcePath) return null
1092
+ let sessionId = session?.[1]
1093
+ try { if (sessionId) sessionId = decodeURIComponent(sessionId) } catch { return null }
1094
+ return { path: resourcePath, sessionId, source: 'href', element: resourceAnchor }
1095
+ }
1096
+
1097
+ for (const attr of PATH_ATTRS) {
1098
+ const holder = target.closest(`[${attr}]`)
1099
+ if (holder) {
1100
+ const value = holder.getAttribute(attr)
1101
+ if (value && looksLikePath(value)) return { path: value, source: attr, element: holder }
1102
+ }
1103
+ }
1104
+
1105
+ const card = target.closest('[data-presented-file]')
1106
+ if (card) {
1107
+ const titled = card.querySelector('[title]')
1108
+ const value = titled && titled.getAttribute('title')
1109
+ if (value && looksLikePath(value)) return { path: value, source: 'presented', element: card }
1110
+ }
1111
+
1112
+ let node = target
1113
+ for (let depth = 0; depth < 5 && node && node !== doc.body; depth += 1, node = node.parentElement) {
1114
+ const value = node.getAttribute && node.getAttribute('title')
1115
+ if (value && looksLikePath(value) && softPath(value)) return { path: value, source: 'title', element: node }
1116
+ }
1117
+
1118
+ const anchor = target.closest('a[href]')
1119
+ if (anchor) {
1120
+ const href = String(anchor.getAttribute('href') || '')
1121
+ const fromHref = pathFromHref(href)
1122
+ if (fromHref) return { path: fromHref, source: 'href', element: anchor }
1123
+ // A blue reference can also be an ordinary web link (a GitHub release, a
1124
+ // discussion). It gets the same menu shape with link actions instead of
1125
+ // file actions, so the two kinds of blue text behave alike.
1126
+ const link = linkFromHref(href)
1127
+ if (link) return { link, source: 'href', element: anchor }
1128
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return null
1129
+ }
1130
+
1131
+ // The element's own text is often exactly the path (a tool row's file link,
1132
+ // a chip). Cheaper and steadier than caret hit-testing. Delivery-table cells
1133
+ // arrive as "📄发布文案.md", so the decoration goes first.
1134
+ const own = stripDecorations(target.textContent)
1135
+ if (own && own.length <= 512 && looksLikePath(own) && softPath(own)) {
1136
+ return { path: own, source: 'text', element: target }
1137
+ }
1138
+
1139
+ const token = stripDecorations(tokenAtPoint(doc, event.clientX, event.clientY))
1140
+ if (token && looksLikePath(token) && softPath(token)) return { path: token, source: 'text', element: target }
1141
+ return null
1142
+ }
1143
+
1144
+ /* ───────────────────────────── menu widget ───────────────────────────── */
1145
+
1146
+ function makeNode(tag, props, children) {
1147
+ const node = document.createElement(tag)
1148
+ if (props) {
1149
+ for (const [key, value] of Object.entries(props)) {
1150
+ if (value === undefined || value === null || value === false) continue
1151
+ if (key === 'html') node.innerHTML = value
1152
+ else if (key === 'text') node.textContent = value
1153
+ else if (key === 'class') node.className = value
1154
+ else node.setAttribute(key, String(value))
1155
+ }
1156
+ }
1157
+ for (const child of children || []) {
1158
+ if (child === null || child === undefined) continue
1159
+ node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child)
1160
+ }
1161
+ return node
1162
+ }
1163
+
1164
+ class FileMenu {
1165
+ constructor(options) {
1166
+ this.model = options.model
1167
+ this.x = options.x
1168
+ this.y = options.y
1169
+ this.onPick = options.onPick
1170
+ this.onClose = options.onClose
1171
+ this.keyboard = options.keyboard === true
1172
+ this.anchor = options.anchor || null
1173
+ this.t = options.t || ZH
1174
+ this.root = null
1175
+ this.body = null
1176
+ this.thumbUrl = null
1177
+ this.submenu = null
1178
+ this.submenuFor = null
1179
+ this.subItems = []
1180
+ this.subVisible = []
1181
+ this.subIndex = -1
1182
+ this.searchInput = null
1183
+ this.subBody = null
1184
+ this.subEmpty = null
1185
+ this.items = []
1186
+ this.index = -1
1187
+ this.closed = false
1188
+ this.handleKeyDown = this.handleKeyDown.bind(this)
1189
+ this.handlePointerDown = this.handlePointerDown.bind(this)
1190
+ }
1191
+
1192
+ open() {
1193
+ const root = makeNode('div', {
1194
+ class: 'dfa-menu',
1195
+ role: 'menu',
1196
+ tabindex: '-1',
1197
+ [MENU_ATTR]: '1',
1198
+ 'aria-label': this.model.title,
1199
+ })
1200
+ this.root = root
1201
+ this.body = makeNode('div', { class: 'dfa-body' })
1202
+ root.appendChild(this.body)
1203
+ const floor = menuFloor()
1204
+ if (floor) root.style.setProperty('--dfa-floor', floor)
1205
+ this.previousFocus = document.activeElement
1206
+ root.setAttribute('data-keyboard', this.keyboard ? 'true' : 'false')
1207
+ root.addEventListener('mousedown', (event) => { if (!event.target.closest('input,button')) event.preventDefault() })
1208
+ this.body.addEventListener('scroll', () => this.updateFade(), { passive: true })
1209
+ document.body.appendChild(root)
1210
+ this.render()
1211
+ this.place(root, this.x, this.y)
1212
+ document.addEventListener('keydown', this.handleKeyDown, true)
1213
+ document.addEventListener('pointerdown', this.handlePointerDown, true)
1214
+ window.addEventListener('resize', this.handlePointerDown, true)
1215
+ window.addEventListener('blur', this.handlePointerDown, true)
1216
+ // Move focus into the menu so the composer does not keep receiving the
1217
+ // keystrokes meant for the rows; close() hands focus back. The window
1218
+ // blur listener ignores descendant blurs, so this cannot close the menu.
1219
+ try { root.focus({ preventScroll: true }) } catch { /* older engine */ }
1220
+ }
1221
+
1222
+ /** Fade the scroll edges so a clipped list reads as scrollable. */
1223
+ updateFade() {
1224
+ const body = this.body
1225
+ if (!body) return
1226
+ const top = body.scrollTop > 2
1227
+ const bottom = body.scrollTop + body.clientHeight < body.scrollHeight - 2
1228
+ const mode = top && bottom ? 'both' : top ? 'top' : bottom ? 'bottom' : 'none'
1229
+ if (body.dataset.fade !== mode) body.dataset.fade = mode
1230
+ }
1231
+
1232
+ /**
1233
+ * Paint the current model into the existing body. File associations arrive a
1234
+ * few seconds after the first right-click, so the same menu is re-rendered in
1235
+ * place instead of opening late or flickering through a close/reopen.
1236
+ */
1237
+ render() {
1238
+ if (!this.root || !this.body) return
1239
+ this.closeSubmenu()
1240
+ const scrollTop = this.body.scrollTop
1241
+ const position = this.root.getBoundingClientRect()
1242
+ const painted = this.items.length > 0
1243
+ const keep = this.items[this.index] ? this.items[this.index].item.id : null
1244
+ this.body.textContent = ''
1245
+ this.items = []
1246
+ this.index = -1
1247
+ if (this.model.preview) this.body.appendChild(this.renderThumb())
1248
+ this.body.appendChild(this.renderHead())
1249
+ this.model.sections.forEach((section, at) => {
1250
+ if (at > 0) this.body.appendChild(makeNode('div', { class: 'dfa-sep' }))
1251
+ for (const item of section) this.body.appendChild(this.renderItem(item))
1252
+ })
1253
+ this.body.appendChild(makeNode('div', { class: 'dfa-foot', text: this.t.nativeHint }))
1254
+ if (this.keyboard) {
1255
+ this.body.appendChild(makeNode('div', { class: 'dfa-foot' }, [
1256
+ makeNode('span', { text: this.t.keysMove }),
1257
+ makeNode('span', { text: this.t.keysRun }),
1258
+ makeNode('span', { text: this.t.keysClose }),
1259
+ ]))
1260
+ }
1261
+ const restored = keep === null ? -1 : this.items.findIndex((entry) => entry.item.id === keep)
1262
+ // A pointer-opened menu keeps no row highlighted; the keyboard opener and
1263
+ // the post-enrichment repaint both restore a real selection.
1264
+ if (restored >= 0 && !this.items[restored].disabled) this.focus(restored)
1265
+ else if (this.keyboard) this.focus(this.items.findIndex((entry) => !entry.disabled))
1266
+ this.body.scrollTop = scrollTop
1267
+ if (this.root.parentNode) {
1268
+ // Keep the chosen side of the pointer; only clamp when the viewport ends.
1269
+ this.place(this.root, painted ? position.left : this.x, painted ? position.top : this.y, painted)
1270
+ }
1271
+ this.root.setAttribute('aria-label', this.model.title)
1272
+ requestAnimationFrame(() => this.updateFade())
1273
+ }
1274
+
1275
+ renderThumb() {
1276
+ const wrap = makeNode('div', { class: 'dfa-thumb' })
1277
+ if (this.thumbUrl) wrap.appendChild(makeNode('img', { src: this.thumbUrl, alt: this.model.title }))
1278
+ else wrap.textContent = this.thumbnailDone ? this.t.previewUnavailable : this.t.previewLoading
1279
+ return wrap
1280
+ }
1281
+
1282
+ /** Drop an image preview above the header once its bytes have arrived. */
1283
+ setThumbnail(url) {
1284
+ if (this.closed) return
1285
+ this.thumbUrl = url
1286
+ this.thumbnailDone = true
1287
+ const previous = this.body?.querySelector('.dfa-thumb')
1288
+ if (previous) previous.replaceWith(this.renderThumb())
1289
+ }
1290
+
1291
+ /**
1292
+ * The header is the one deliberate element: a tinted file-type tile, the name,
1293
+ * and the path that answers "which file is this" without leaving the menu.
1294
+ */
1295
+ renderHead() {
1296
+ const model = this.model
1297
+ const tile = makeNode('span', { class: 'dfa-tile', 'data-kind': model.kind || 'binary' })
1298
+ tile.innerHTML = iconSvg(KIND_ICONS[model.kind] || 'doc')
1299
+ const text = makeNode('span', { class: 'dfa-headtext' }, [
1300
+ makeNode('span', { class: 'dfa-name', text: model.title, title: model.subtitle }),
1301
+ makeNode('span', { class: 'dfa-path', text: model.pathLine || model.subtitle, title: model.subtitle }),
1302
+ ])
1303
+ const aside = makeNode('span', { class: 'dfa-aside' })
1304
+ if (model.outsideWorkspace) aside.appendChild(makeNode('span', { class: 'dfa-badge', text: this.t.outsideWorkspace }))
1305
+ // A reference like `file.ts:42` names a line the Host cannot jump to, so the
1306
+ // header states it instead of silently dropping the suffix.
1307
+ if (model.locationLabel) aside.appendChild(makeNode('span', { class: 'dfa-loc', text: model.locationLabel }))
1308
+ if (model.sizeLabel) aside.appendChild(makeNode('span', { class: 'dfa-size', text: model.sizeLabel }))
1309
+ return makeNode('div', { class: 'dfa-head' }, [tile, text, aside])
1310
+ }
1311
+
1312
+ /** Replace the model (rows only) and repaint without moving the menu. */
1313
+ setModel(model) {
1314
+ if (this.closed) return
1315
+ this.model = model
1316
+ this.render()
1317
+ }
1318
+
1319
+ renderItem(item) {
1320
+ const row = makeNode('div', {
1321
+ class: 'dfa-item',
1322
+ role: 'menuitem',
1323
+ tabindex: '-1',
1324
+ id: `dfa-row-${this.items.length}`,
1325
+ [ITEM_ATTR]: item.id,
1326
+ 'aria-disabled': item.disabled ? 'true' : 'false',
1327
+ 'aria-haspopup': item.children ? 'menu' : 'false',
1328
+ 'aria-expanded': item.children ? 'false' : undefined,
1329
+ })
1330
+ const icon = makeNode('span', { class: 'dfa-ico' })
1331
+ if (item.iconData) {
1332
+ icon.appendChild(makeNode('img', { src: item.iconData, alt: '' }))
1333
+ } else {
1334
+ icon.innerHTML = iconSvg(item.icon)
1335
+ }
1336
+ row.appendChild(icon)
1337
+ row.appendChild(makeNode('span', { class: 'dfa-label', text: item.label }))
1338
+ if (item.children) {
1339
+ row.appendChild(makeNode('span', { class: 'dfa-tail', html: iconSvg('chevron') }))
1340
+ }
1341
+ const entry = { element: row, item, disabled: item.disabled === true }
1342
+ this.items.push(entry)
1343
+ row.addEventListener('mouseenter', () => {
1344
+ this.focus(this.items.indexOf(entry))
1345
+ if (item.children) this.openSubmenu(entry)
1346
+ else this.closeSubmenu()
1347
+ })
1348
+ row.addEventListener('click', (event) => {
1349
+ event.preventDefault()
1350
+ event.stopPropagation()
1351
+ if (entry.disabled) return
1352
+ if (item.children) {
1353
+ this.openSubmenu(entry)
1354
+ return
1355
+ }
1356
+ if (!item.keepOpen) this.close()
1357
+ this.onPick(item)
1358
+ })
1359
+ return row
1360
+ }
1361
+
1362
+ openSubmenu(entry) {
1363
+ if (this.submenuFor === entry.element) return
1364
+ this.closeSubmenu()
1365
+ const submenu = makeNode('div', {
1366
+ class: 'dfa-menu dfa-sub',
1367
+ role: 'menu',
1368
+ [MENU_ATTR]: 'submenu',
1369
+ })
1370
+ const body = makeNode('div', { class: 'dfa-body' })
1371
+ submenu.appendChild(body)
1372
+ submenu.addEventListener('mousedown', (event) => { if (!event.target.closest('input,button')) event.preventDefault() })
1373
+ this.subItems = []
1374
+ this.subVisible = []
1375
+ this.subIndex = -1
1376
+ this.subBody = body
1377
+ // A long association list needs a header that stays while it scrolls.
1378
+ body.appendChild(makeNode('div', { class: 'dfa-subhead' }, [
1379
+ makeNode('span', { text: this.t.openWith }),
1380
+ makeNode('span', { text: `${entry.item.children.length} ${this.t.appsUnit}` }),
1381
+ ]))
1382
+ body.appendChild(makeNode('div', { class: 'dfa-foot', text: this.t.pinHint }))
1383
+ const needsSearch = entry.item.children.length > 7
1384
+ if (needsSearch) {
1385
+ const wrap = makeNode('div', { class: 'dfa-searchwrap' })
1386
+ this.searchInput = makeNode('input', {
1387
+ class: 'dfa-search',
1388
+ type: 'text',
1389
+ placeholder: this.t.searchApps,
1390
+ 'aria-label': this.t.searchApps,
1391
+ spellcheck: 'false',
1392
+ autocomplete: 'off',
1393
+ })
1394
+ this.searchInput.addEventListener('input', () => this.filterSubmenu())
1395
+ this.searchInput.addEventListener('keydown', (event) => event.stopPropagation())
1396
+ wrap.appendChild(this.searchInput)
1397
+ body.appendChild(wrap)
1398
+ }
1399
+ for (const item of entry.item.children) {
1400
+ const row = makeNode('div', {
1401
+ class: 'dfa-item',
1402
+ role: 'menuitem',
1403
+ tabindex: '-1',
1404
+ id: `dfa-subrow-${this.subItems.length}`,
1405
+ [ITEM_ATTR]: item.id,
1406
+ title: item.label,
1407
+ })
1408
+ const icon = makeNode('span', { class: 'dfa-ico' })
1409
+ if (item.iconData) icon.appendChild(makeNode('img', { src: item.iconData, alt: '' }))
1410
+ else icon.innerHTML = iconSvg('open')
1411
+ row.appendChild(icon)
1412
+ row.appendChild(makeNode('span', { class: 'dfa-label', text: item.label }))
1413
+ if (item.checked || item.preferred) {
1414
+ row.appendChild(makeNode('span', { class: 'dfa-tail' }, [
1415
+ makeNode('span', { class: 'dfa-default', text: item.preferred ? this.t.preferred : this.t.defaultApp }),
1416
+ ]))
1417
+ }
1418
+ if (item.pinnable) {
1419
+ const pin = makeNode('button', { class: 'dfa-pin', type: 'button', tabindex: '-1',
1420
+ 'aria-label': `${item.preferred ? this.t.unpinApp : this.t.pinApp}: ${item.label}`,
1421
+ 'aria-pressed': item.preferred ? 'true' : 'false',
1422
+ title: item.preferred ? this.t.unpinApp : this.t.pinApp,
1423
+ text: item.preferred ? '★' : '☆' })
1424
+ pin.addEventListener('click', (event) => {
1425
+ event.preventDefault(); event.stopPropagation()
1426
+ this.close()
1427
+ this.onPick({ ...item, action: 'preferApp' })
1428
+ })
1429
+ row.appendChild(pin)
1430
+ }
1431
+ const index = this.subItems.length
1432
+ const record = { element: row, item, label: String(item.label).toLowerCase() }
1433
+ this.subItems.push(record)
1434
+ this.subVisible.push(record)
1435
+ row.addEventListener('mouseenter', () => this.focusSub(index))
1436
+ row.addEventListener('click', (event) => {
1437
+ event.preventDefault()
1438
+ event.stopPropagation()
1439
+ this.close()
1440
+ this.onPick(item)
1441
+ })
1442
+ body.appendChild(row)
1443
+ }
1444
+ this.subEmpty = makeNode('div', { class: 'dfa-empty', text: this.t.noMatch })
1445
+ this.subEmpty.hidden = true
1446
+ body.appendChild(this.subEmpty)
1447
+ const floor = menuFloor()
1448
+ if (floor) submenu.style.setProperty('--dfa-floor', floor)
1449
+ document.body.appendChild(submenu)
1450
+ this.placeSubmenu(submenu, entry.element)
1451
+ this.submenu = submenu
1452
+ this.submenuFor = entry.element
1453
+ entry.element.setAttribute('aria-expanded', 'true')
1454
+ body.addEventListener('scroll', () => this.updateSubFade(), { passive: true })
1455
+ requestAnimationFrame(() => this.updateSubFade())
1456
+ if (this.keyboard && this.searchInput) {
1457
+ try { this.searchInput.focus({ preventScroll: true }) } catch { /* older engine */ }
1458
+ }
1459
+ }
1460
+
1461
+ /** Filter the association rows by a case-insensitive substring. */
1462
+ filterSubmenu() {
1463
+ const query = String(this.searchInput ? this.searchInput.value : '').trim().toLowerCase()
1464
+ this.subVisible = []
1465
+ for (const record of this.subItems) {
1466
+ const match = query === '' || record.label.includes(query)
1467
+ record.element.hidden = !match
1468
+ if (match) this.subVisible.push(record)
1469
+ }
1470
+ this.subEmpty.hidden = this.subVisible.length > 0
1471
+ this.subIndex = -1
1472
+ for (const record of this.subItems) record.element.dataset.focus = 'false'
1473
+ if (this.submenu) this.submenu.removeAttribute('aria-activedescendant')
1474
+ if (this.subVisible.length > 0) this.focusSub(0)
1475
+ this.updateSubFade()
1476
+ }
1477
+
1478
+ updateSubFade() {
1479
+ const body = this.subBody
1480
+ if (!body) return
1481
+ const top = body.scrollTop > 2
1482
+ const bottom = body.scrollTop + body.clientHeight < body.scrollHeight - 2
1483
+ const mode = top && bottom ? 'both' : top ? 'top' : bottom ? 'bottom' : 'none'
1484
+ if (body.dataset.fade !== mode) body.dataset.fade = mode
1485
+ }
1486
+
1487
+ /** Roving focus inside the submenu; the parent menu keeps its own index. */
1488
+ focusSub(index) {
1489
+ const rows = this.subVisible
1490
+ if (rows.length === 0) return
1491
+ const next = Math.min(Math.max(index, 0), rows.length - 1)
1492
+ const target = rows[next]
1493
+ for (const record of this.subItems) record.element.dataset.focus = 'false'
1494
+ target.element.dataset.focus = 'true'
1495
+ // Keep the parent menu's own row highlighted while the submenu is open.
1496
+ this.subIndex = this.subItems.indexOf(target)
1497
+ if (this.submenu) this.submenu.setAttribute('aria-activedescendant', target.element.id)
1498
+ if (target.element.scrollIntoView) target.element.scrollIntoView({ block: 'nearest' })
1499
+ }
1500
+
1501
+ moveSub(delta) {
1502
+ const rows = this.subVisible
1503
+ if (rows.length === 0) return
1504
+ const current = this.subVisible.length > 0 ? this.subVisible.indexOf(this.subItems[this.subIndex]) : this.subIndex
1505
+ const from = current < 0 ? (delta > 0 ? -1 : 0) : current
1506
+ const next = (from + delta + rows.length) % rows.length
1507
+ this.focusSub(next)
1508
+ }
1509
+
1510
+ activateSub(prefer = false) {
1511
+ const rows = this.subVisible
1512
+ const current = this.subItems[this.subIndex]
1513
+ const entry = rows.includes(current) ? current : rows[0]
1514
+ if (!entry) return
1515
+ this.close()
1516
+ this.onPick(prefer && entry.item.pinnable ? { ...entry.item, action: 'preferApp' } : entry.item)
1517
+ }
1518
+
1519
+ /**
1520
+ * Anchor the submenu to its row, flipping to the parent menu's left side when
1521
+ * the right edge would clip — flipping relative to the row would cover the
1522
+ * menu it belongs to.
1523
+ */
1524
+ placeSubmenu(submenu, row) {
1525
+ submenu.style.left = '0px'
1526
+ submenu.style.top = '0px'
1527
+ const box = row.getBoundingClientRect()
1528
+ const rect = submenu.getBoundingClientRect()
1529
+ const parent = this.root ? this.root.getBoundingClientRect() : box
1530
+ const vw = window.innerWidth
1531
+ const vh = window.innerHeight
1532
+ let left = parent.right - 4
1533
+ if (left + rect.width > vw - 8) left = parent.left - rect.width + 4
1534
+ if (left < 8) left = 8
1535
+ let top = box.top - 4
1536
+ if (top + rect.height > vh - 8) top = vh - rect.height - 8
1537
+ if (top < 8) top = 8
1538
+ submenu.style.left = `${Math.round(left)}px`
1539
+ submenu.style.top = `${Math.round(top)}px`
1540
+ }
1541
+
1542
+ closeSubmenu() {
1543
+ if (this.submenu) {
1544
+ this.submenuFor?.setAttribute('aria-expanded', 'false')
1545
+ this.submenu.remove()
1546
+ this.submenu = null
1547
+ this.submenuFor = null
1548
+ }
1549
+ this.subItems = []
1550
+ this.subVisible = []
1551
+ this.subIndex = -1
1552
+ this.searchInput = null
1553
+ this.subBody = null
1554
+ this.subEmpty = null
1555
+ }
1556
+
1557
+ place(menu, x, y, preserveSide = false) {
1558
+ menu.style.left = '0px'
1559
+ menu.style.top = '0px'
1560
+ const rect = menu.getBoundingClientRect()
1561
+ const vw = window.innerWidth
1562
+ const vh = window.innerHeight
1563
+ let left = x
1564
+ let top = y
1565
+ if (left + rect.width > vw - 8) left = Math.max(8, preserveSide ? vw - rect.width - 8 : x - rect.width)
1566
+ if (left < 8) left = 8
1567
+ if (top + rect.height > vh - 8) top = Math.max(8, vh - rect.height - 8)
1568
+ if (top < 8) top = 8
1569
+ menu.style.left = `${Math.round(left)}px`
1570
+ menu.style.top = `${Math.round(top)}px`
1571
+ }
1572
+
1573
+ focus(index) {
1574
+ if (this.items.length === 0) return
1575
+ const next = Math.min(Math.max(index, 0), this.items.length - 1)
1576
+ this.items.forEach((entry, at) => {
1577
+ entry.element.dataset.focus = at === next ? 'true' : 'false'
1578
+ })
1579
+ this.index = next
1580
+ const entry = this.items[next]
1581
+ if (entry && this.root) this.root.setAttribute('aria-activedescendant', entry.element.id)
1582
+ if (entry && entry.element.scrollIntoView) entry.element.scrollIntoView({ block: 'nearest' })
1583
+ }
1584
+
1585
+ move(delta) {
1586
+ if (this.items.length === 0) return
1587
+ let next = this.index
1588
+ for (let step = 0; step < this.items.length; step += 1) {
1589
+ next = (next + delta + this.items.length) % this.items.length
1590
+ if (!this.items[next].disabled) break
1591
+ }
1592
+ this.focus(next)
1593
+ }
1594
+
1595
+ activate(index) {
1596
+ const entry = this.items[index]
1597
+ if (!entry || entry.disabled) return
1598
+ if (entry.item.children) {
1599
+ this.openSubmenu(entry)
1600
+ if (this.keyboard) this.focusSub(0)
1601
+ return
1602
+ }
1603
+ if (!entry.item.keepOpen) this.close()
1604
+ this.onPick(entry.item)
1605
+ }
1606
+
1607
+ handleKeyDown(event) {
1608
+ if (!this.root) return
1609
+ const typing = this.searchInput && event.target === this.searchInput
1610
+ // While the submenu is open it owns the arrows, Enter and Escape, so a
1611
+ // keyboard user can actually pick an application.
1612
+ if (this.submenu) {
1613
+ if (event.key === 'ArrowDown') {
1614
+ event.preventDefault()
1615
+ this.moveSub(1)
1616
+ return
1617
+ }
1618
+ if (event.key === 'ArrowUp') {
1619
+ event.preventDefault()
1620
+ this.moveSub(-1)
1621
+ return
1622
+ }
1623
+ if (event.key === 'Enter' || (event.key === ' ' && !typing)) {
1624
+ event.preventDefault()
1625
+ this.activateSub(event.shiftKey && event.key === 'Enter')
1626
+ return
1627
+ }
1628
+ if (event.key === 'ArrowLeft' || event.key === 'Escape') {
1629
+ event.preventDefault()
1630
+ event.stopPropagation()
1631
+ // Escape in a non-empty filter clears it first, then closes.
1632
+ if (event.key === 'Escape' && this.searchInput && this.searchInput.value !== '') {
1633
+ this.searchInput.value = ''
1634
+ this.filterSubmenu()
1635
+ return
1636
+ }
1637
+ this.closeSubmenu()
1638
+ return
1639
+ }
1640
+ }
1641
+ if (event.key === 'Escape') {
1642
+ event.preventDefault()
1643
+ event.stopPropagation()
1644
+ this.close()
1645
+ return
1646
+ }
1647
+ if (event.key === 'ArrowDown') {
1648
+ event.preventDefault()
1649
+ this.move(1)
1650
+ return
1651
+ }
1652
+ if (event.key === 'ArrowUp') {
1653
+ event.preventDefault()
1654
+ this.move(-1)
1655
+ return
1656
+ }
1657
+ if (event.key === 'Home') {
1658
+ event.preventDefault()
1659
+ this.focus(0)
1660
+ return
1661
+ }
1662
+ if (event.key === 'End') {
1663
+ event.preventDefault()
1664
+ this.focus(this.items.length - 1)
1665
+ return
1666
+ }
1667
+ if (event.key === 'ArrowRight') {
1668
+ const entry = this.items[this.index]
1669
+ if (entry && entry.item.children) {
1670
+ event.preventDefault()
1671
+ this.openSubmenu(entry)
1672
+ this.focusSub(0)
1673
+ }
1674
+ return
1675
+ }
1676
+ if (event.key === 'ArrowLeft') {
1677
+ if (this.submenu) {
1678
+ event.preventDefault()
1679
+ this.closeSubmenu()
1680
+ }
1681
+ return
1682
+ }
1683
+ if (event.key === 'Enter' || event.key === ' ') {
1684
+ event.preventDefault()
1685
+ this.activate(this.index)
1686
+ }
1687
+ }
1688
+
1689
+ handlePointerDown(event) {
1690
+ if (!this.root) return
1691
+ // `blur` does not bubble, but a capture listener on window still sees the
1692
+ // blur of any descendant — including the element that loses focus to this
1693
+ // menu when it opens. Only a real window blur may close it.
1694
+ if (event.type === 'blur' && event.target !== window) return
1695
+ const target = event.target
1696
+ if (target instanceof Node && (this.root.contains(target) || (this.submenu && this.submenu.contains(target)))) return
1697
+ this.close()
1698
+ }
1699
+
1700
+ /**
1701
+ * The chat transcript streams, so the page scrolls under an open menu. Follow
1702
+ * the anchor row instead of vanishing; close only once it leaves the viewport.
1703
+ */
1704
+ followAnchor() {
1705
+ if (!this.root) return
1706
+ const anchor = this.anchor
1707
+ if (!anchor || !anchor.isConnected) {
1708
+ this.close()
1709
+ return
1710
+ }
1711
+ const rect = anchor.getBoundingClientRect()
1712
+ if ((rect.width === 0 && rect.height === 0) || rect.bottom < 0 || rect.top > window.innerHeight) {
1713
+ this.close()
1714
+ return
1715
+ }
1716
+ this.closeSubmenu()
1717
+ this.x = rect.left
1718
+ this.y = rect.bottom
1719
+ this.place(this.root, this.x, this.y)
1720
+ }
1721
+
1722
+ close() {
1723
+ if (this.closed) return
1724
+ this.closed = true
1725
+ this.closeSubmenu()
1726
+ document.removeEventListener('keydown', this.handleKeyDown, true)
1727
+ document.removeEventListener('pointerdown', this.handlePointerDown, true)
1728
+ window.removeEventListener('resize', this.handlePointerDown, true)
1729
+ window.removeEventListener('blur', this.handlePointerDown, true)
1730
+ if (this.root) this.root.remove()
1731
+ this.root = null
1732
+ this.body = null
1733
+ this.thumbUrl = null
1734
+ this.items = []
1735
+ const previous = this.previousFocus
1736
+ this.previousFocus = null
1737
+ if (previous && previous !== document.body && typeof previous.focus === 'function' && previous.isConnected) {
1738
+ try { previous.focus({ preventScroll: true }) } catch { /* the element may have unmounted */ }
1739
+ }
1740
+ if (this.onClose) this.onClose()
1741
+ }
1742
+ }
1743
+
1744
+ /* ───────────────────────────── clipboard ───────────────────────────── */
1745
+
1746
+ async function copyText(text) {
1747
+ try {
1748
+ await navigator.clipboard.writeText(text)
1749
+ return true
1750
+ } catch { /* fall through to the legacy path */ }
1751
+ try {
1752
+ const area = document.createElement('textarea')
1753
+ area.value = text
1754
+ area.setAttribute('readonly', '')
1755
+ area.style.position = 'fixed'
1756
+ area.style.top = '-1000px'
1757
+ area.style.opacity = '0'
1758
+ document.body.appendChild(area)
1759
+ area.select()
1760
+ const ok = document.execCommand('copy')
1761
+ area.remove()
1762
+ return ok
1763
+ } catch {
1764
+ return false
1765
+ }
1766
+ }
1767
+
1768
+ function downloadBytes(bytes, name, mime) {
1769
+ const blob = new Blob([bytes], { type: mime || 'application/octet-stream' })
1770
+ const url = URL.createObjectURL(blob)
1771
+ const link = document.createElement('a')
1772
+ link.href = url
1773
+ link.download = name || 'download'
1774
+ link.style.display = 'none'
1775
+ document.body.appendChild(link)
1776
+ link.click()
1777
+ link.remove()
1778
+ setTimeout(() => URL.revokeObjectURL(url), 60000)
1779
+ }
1780
+
1781
+ /** Hand a URL to the app's own link handling (a new window/tab). */
1782
+ function openExternal(url) {
1783
+ try {
1784
+ const anchor = document.createElement('a')
1785
+ anchor.href = url
1786
+ anchor.target = '_blank'
1787
+ anchor.rel = 'noopener noreferrer'
1788
+ anchor.style.display = 'none'
1789
+ document.body.appendChild(anchor)
1790
+ anchor.click()
1791
+ anchor.remove()
1792
+ return true
1793
+ } catch {
1794
+ return false
1795
+ }
1796
+ }
1797
+
1798
+ /** Fill the window opened synchronously by the click with the file's bytes. */
1799
+ function fillBrowserWindow(win, bytes, mime) {
1800
+ try {
1801
+ const blob = new Blob([bytes], { type: mime })
1802
+ const url = URL.createObjectURL(blob)
1803
+ // File HTML/SVG may contain scripts. Keep working previews, but give
1804
+ // them an opaque origin so they cannot read the DSH page or its cookies.
1805
+ win.opener = null
1806
+ const frame = win.document.createElement('iframe')
1807
+ frame.setAttribute('sandbox', 'allow-scripts allow-forms allow-downloads allow-popups')
1808
+ frame.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;border:0'
1809
+ frame.src = url
1810
+ win.document.body.replaceChildren(frame)
1811
+ setTimeout(() => URL.revokeObjectURL(url), 60000)
1812
+ return true
1813
+ } catch {
1814
+ try { win.close() } catch { /* already gone */ }
1815
+ return false
1816
+ }
1817
+ }
1818
+
1819
+ /* ─────────────────────────────── actions ─────────────────────────────── */
1820
+
1821
+ const MAX_COPY_BYTES = 2 * 1024 * 1024
1822
+
1823
+ async function dispatch(item, target, context) {
1824
+ const { bridge, t, sessionId } = context
1825
+ const path = target.absolutePath
1826
+ if (item.action === 'openLink' || item.action === 'copyLink' || item.action === 'copyLinkText') {
1827
+ if (item.action === 'openLink') {
1828
+ if (!openExternal(target.link)) toast(t.openFailed, { error: true })
1829
+ return
1830
+ }
1831
+ const value = item.action === 'copyLink' ? target.link : target.text
1832
+ const ok = await copyText(value || target.link)
1833
+ toast(ok ? (item.action === 'copyLink' ? t.copiedLink : t.copiedLinkText) : t.copyFailed, { error: !ok })
1834
+ return
1835
+ }
1836
+ if (item.action === 'open') {
1837
+ const ok = await bridge.open(path, item.appId)
1838
+ if (!ok) toast(t.openFailed, { error: true })
1839
+ return
1840
+ }
1841
+ if (item.action === 'reveal') {
1842
+ const ok = await bridge.reveal(path)
1843
+ if (!ok) toast(t.revealFailed, { error: true })
1844
+ return
1845
+ }
1846
+ if (item.action === 'copyPath' || item.action === 'copyRelPath' || item.action === 'copyFileName') {
1847
+ const value = item.action === 'copyPath' ? path
1848
+ : item.action === 'copyRelPath' ? target.displayPath : basename(path)
1849
+ const message = item.action === 'copyPath' ? t.copiedPath
1850
+ : item.action === 'copyRelPath' ? t.copiedRelPath : t.copiedFileName
1851
+ const ok = await copyText(value)
1852
+ toast(ok ? message : t.copyFailed, { error: !ok })
1853
+ return
1854
+ }
1855
+ if (item.action === 'copyReference') {
1856
+ const ok = await copyText(target.reference || path)
1857
+ toast(ok ? t.copiedReference : t.copyFailed, { error: !ok })
1858
+ return
1859
+ }
1860
+ if (item.action === 'copyMarkdown') {
1861
+ const label = String(target.text || '').replace(/([\[\]])/g, '\\$1')
1862
+ const ok = await copyText(`[${label || target.link}](${target.link})`)
1863
+ toast(ok ? t.copiedMarkdown : t.copyFailed, { error: !ok })
1864
+ return
1865
+ }
1866
+ if (item.action === 'copyContents' && typeof target.bytes === 'number' && target.bytes > MAX_COPY_BYTES) {
1867
+ // The size is already known from the stat; never pull a huge file across
1868
+ // the wire just to refuse it.
1869
+ toast(t.tooLarge, { error: true })
1870
+ return
1871
+ }
1872
+ if (item.action === 'browser') {
1873
+ // Must happen inside the click's gesture, before any await, or the popup
1874
+ // blocker eats it.
1875
+ const win = window.open('', '_blank')
1876
+ if (!win) {
1877
+ toast(t.popupBlocked, { error: true })
1878
+ return
1879
+ }
1880
+ win.opener = null
1881
+ const result = await bridge.readBytes(sessionId, path)
1882
+ if (!result || !(result.data instanceof Uint8Array) || result.eof === false) {
1883
+ try { win.close() } catch { /* already gone */ }
1884
+ toast(t.readFailed, { error: true })
1885
+ return
1886
+ }
1887
+ if (!fillBrowserWindow(win, result.data, mimeOf(path))) toast(t.popupBlocked, { error: true })
1888
+ return
1889
+ }
1890
+ if (item.action === 'copyContents' || item.action === 'saveAs') {
1891
+ // Chat text can plant absolute paths from outside the workspace; require
1892
+ // an explicit confirmation before its bytes reach the clipboard.
1893
+ if (item.action === 'copyContents' && target.outsideWorkspace === true) {
1894
+ const ask = context.confirm || (typeof confirm === 'function' ? confirm : null)
1895
+ let allowed = false
1896
+ try { allowed = ask ? ask(t.confirmOutsideCopy.replace('{path}', path)) : true } catch { allowed = true }
1897
+ if (!allowed) return
1898
+ }
1899
+ const options = item.action === 'copyContents'
1900
+ // The Host refuses a window above its own maxBytes (2 MB by default), so
1901
+ // ask for exactly the cap and read `eof === false` as "too large".
1902
+ ? { range: { offset: 0, length: MAX_COPY_BYTES } }
1903
+ : {}
1904
+ const result = await bridge.readBytes(sessionId, path, options)
1905
+ if (!result || !(result.data instanceof Uint8Array) || (item.action === 'saveAs' && result.eof === false)) {
1906
+ toast(t.readFailed, { error: true })
1907
+ return
1908
+ }
1909
+ const bytes = result.data
1910
+ if (item.action === 'saveAs') {
1911
+ downloadBytes(bytes, basename(path), mimeOf(path))
1912
+ return
1913
+ }
1914
+ if (bytes.byteLength > MAX_COPY_BYTES || result.eof === false) {
1915
+ toast(t.tooLarge, { error: true })
1916
+ return
1917
+ }
1918
+ let text = ''
1919
+ try {
1920
+ text = new TextDecoder('utf-8', { fatal: false }).decode(bytes)
1921
+ } catch {
1922
+ toast(t.readFailed, { error: true })
1923
+ return
1924
+ }
1925
+ if (text.includes('\u0000')) {
1926
+ toast(t.binary, { error: true })
1927
+ return
1928
+ }
1929
+ const ok = await copyText(text)
1930
+ toast(ok ? t.copiedContents : t.copyFailed, { error: !ok })
1931
+ }
1932
+ }
1933
+
1934
+ /* ─────────────────────────────── plugin ─────────────────────────────── */
1935
+
1936
+ function detectLanguage(ctx) {
1937
+ try {
1938
+ const service = ctx.locale
1939
+ const candidates = [
1940
+ service && service.language && service.language.getSnapshot ? service.language.getSnapshot() : null,
1941
+ service && service.locale && service.locale.getSnapshot ? service.locale.getSnapshot() : null,
1942
+ service && service.current && service.current.getSnapshot ? service.current.getSnapshot() : null,
1943
+ ]
1944
+ for (const value of candidates) {
1945
+ if (typeof value === 'string' && value) return value
1946
+ if (value && typeof value.language === 'string') return value.language
1947
+ }
1948
+ } catch { /* fall back to the browser */ }
1949
+ try {
1950
+ return navigator.language || 'zh-CN'
1951
+ } catch {
1952
+ return 'zh-CN'
1953
+ }
1954
+ }
1955
+
1956
+ function start(ctx) {
1957
+ if (typeof document === 'undefined' || typeof window === 'undefined') return
1958
+ ensureCss()
1959
+ const bridge = createBridge(ctx)
1960
+ const t = messages(detectLanguage(ctx))
1961
+
1962
+ let menu = null
1963
+ let generation = 0
1964
+ let disposed = false
1965
+
1966
+ function closeMenu() {
1967
+ generation++
1968
+ if (menu) {
1969
+ menu.close()
1970
+ menu = null
1971
+ }
1972
+ }
1973
+
1974
+ // Guessed text must be verified before cancelling the browser's menu. Warm
1975
+ // it on hover/focus; a cold right-click keeps the native menu for that click.
1976
+ const probeCache = new Map()
1977
+ const probePending = new Map()
1978
+ function locate(candidate) {
1979
+ const session = bridge.activeSession(candidate.sessionId)
1980
+ const located = stripLocationSuffix(candidate.path)
1981
+ const path = isAbsolutePath(located.path) ? located.path : joinPath(session?.cwd, located.path)
1982
+ return { session, located, path, key: JSON.stringify([session?.id, path]) }
1983
+ }
1984
+ function cachedProbe(candidate) {
1985
+ const cached = probeCache.get(locate(candidate).key)
1986
+ return cached && Date.now() - cached.at < 5000 ? cached.value : null
1987
+ }
1988
+ function warmCandidate(candidate) {
1989
+ if (!candidate || candidate.link || !['text', 'title'].includes(candidate.source)) return
1990
+ const { session, path, key } = locate(candidate)
1991
+ if (!session || !path || cachedProbe(candidate) || probePending.has(key) || probePending.size >= 4) return
1992
+ const pending = bridge.probe(session.id, path).then((value) => {
1993
+ if (disposed) return
1994
+ if (probeCache.size >= 64) probeCache.delete(probeCache.keys().next().value)
1995
+ probeCache.set(key, { value, at: Date.now() })
1996
+ }).finally(() => probePending.delete(key))
1997
+ probePending.set(key, pending)
1998
+ }
1999
+ let preferenceStorage
2000
+ try { preferenceStorage = window.localStorage } catch { /* private storage may be disabled */ }
2001
+ const preferences = applicationPreferences(preferenceStorage)
2002
+
2003
+ async function openAt(candidate, x, y, keyboard) {
2004
+ if (disposed) return
2005
+ const attempt = ++generation
2006
+ let handle
2007
+ try {
2008
+ if (candidate.link) {
2009
+ const text = String(candidate.element?.textContent || '').trim().slice(0, 512)
2010
+ const target = { link: candidate.link, text }
2011
+ handle = new FileMenu({ model: buildLinkMenuModel({ url: candidate.link, text, t }),
2012
+ x, y, keyboard, anchor: candidate.element, t,
2013
+ onClose: () => { if (menu === handle) menu = null },
2014
+ onPick: (item) => { void dispatch(item, target, { bridge, t }) },
2015
+ })
2016
+ menu = handle
2017
+ handle.open()
2018
+ return
2019
+ }
2020
+ const { session, located, path } = locate(candidate)
2021
+ const modelInput = { absolutePath: path, displayPath: located.path, apps: [], appsState: 'loading',
2022
+ location: located, reference: candidate.path, t }
2023
+ let target
2024
+ handle = new FileMenu({ model: buildStatusModel(path, t.checking, t),
2025
+ x, y, keyboard, anchor: candidate.element, t,
2026
+ onClose: () => { if (menu === handle) menu = null },
2027
+ onPick: (item) => {
2028
+ if (item.action === 'retry') { void openAt(candidate, x, y, keyboard); return }
2029
+ if (item.action === 'retryApps') {
2030
+ modelInput.appsState = 'loading'
2031
+ handle.setModel(buildMenuModel(modelInput))
2032
+ void enrich(handle, modelInput)
2033
+ return
2034
+ }
2035
+ if (item.action === 'preferApp') {
2036
+ const appId = preferences.get(path) === item.appId ? '' : item.appId
2037
+ const saved = preferences.set(path, appId)
2038
+ toast(saved ? (appId ? t.preferenceSaved : t.preferenceCleared) : t.preferenceFailed, { error: !saved })
2039
+ return
2040
+ }
2041
+ if (target) void dispatch(item, target, { bridge, t, sessionId: session.id })
2042
+ },
2043
+ })
2044
+ menu = handle
2045
+ handle.open()
2046
+ if (!session) {
2047
+ handle.setModel(buildStatusModel(path, t.noSession, t, true))
2048
+ return
2049
+ }
2050
+ const probe = cachedProbe(candidate) || await bridge.probe(session.id, path)
2051
+ if (disposed || attempt !== generation || handle.closed) return
2052
+ if (!probe || probe.error) {
2053
+ handle.setModel(buildStatusModel(path, t[probe?.error] || t.probeFailed, t, true))
2054
+ return
2055
+ }
2056
+ Object.assign(modelInput, { absolutePath: probe.absolutePath, isDir: probe.isDir,
2057
+ bytes: probe.bytes, outsideWorkspace: !isInside(session.cwd, probe.absolutePath),
2058
+ cwd: session.cwd, preferredId: preferences.get(probe.absolutePath) })
2059
+ target = { absolutePath: probe.absolutePath, displayPath: located.path,
2060
+ isDir: probe.isDir, line: located.line, column: located.column, bytes: probe.bytes,
2061
+ reference: candidate.path, outsideWorkspace: !isInside(session.cwd, probe.absolutePath) }
2062
+ handle.setModel(buildMenuModel(modelInput))
2063
+ if (!probe.isDir) void enrich(handle, modelInput)
2064
+ if (!probe.isDir && (classify(probe.absolutePath, false).kind === 'image' || extname(probe.absolutePath) === 'svg')) {
2065
+ void loadThumb(handle, session.id, probe.absolutePath, probe.bytes, probe.version)
2066
+ }
2067
+ } catch (error) {
2068
+ if (!disposed && attempt === generation && handle && !handle.closed) {
2069
+ handle.setModel(buildStatusModel(candidate.path, t.probeFailed, t, true))
2070
+ }
2071
+ console.warn('[dsh-file-actions] context menu failed:', error)
2072
+ }
2073
+ }
2074
+
2075
+ // Associations are per file type, so one query per extension serves the page.
2076
+ // Ten minutes keeps a newly installed editor visible without re-asking the
2077
+ // OS on every right-click.
2078
+ const APPS_TTL_MS = 10 * 60 * 1000
2079
+ const appsCache = new Map()
2080
+ async function enrich(handle, input) {
2081
+ const key = extname(input.absolutePath).toLowerCase()
2082
+ const cached = appsCache.get(key)
2083
+ const result = cached && Date.now() - cached.at < cached.ttl
2084
+ ? cached.result : await bridge.applications(input.absolutePath)
2085
+ if (disposed) return
2086
+ // Successful empty answers are different from a failed Remote query.
2087
+ if (result.state === 'ready') appsCache.set(key, { result, at: Date.now(), ttl: result.apps.length ? APPS_TTL_MS : 15000 })
2088
+ if (menu !== handle || handle.closed) return
2089
+ Object.assign(input, { apps: result.apps, appsState: result.state })
2090
+ handle.setModel(buildMenuModel(input))
2091
+ }
2092
+
2093
+ // A thumbnail is worth one bounded read; bigger files just keep the tile.
2094
+ const THUMB_MAX_BYTES = 8 * 1024 * 1024
2095
+ const thumbCache = new Map()
2096
+ async function loadThumb(handle, sessionId, absolutePath, bytes, version) {
2097
+ try {
2098
+ if (typeof bytes === 'number' && bytes > THUMB_MAX_BYTES) { handle.setThumbnail(null); return }
2099
+ const key = JSON.stringify([sessionId, absolutePath, version])
2100
+ const cached = version && thumbCache.get(key)
2101
+ if (cached) {
2102
+ if (menu === handle && !handle.closed) handle.setThumbnail(cached.url)
2103
+ return
2104
+ }
2105
+ const result = await bridge.readBytes(sessionId, absolutePath)
2106
+ if (disposed || menu !== handle || handle.closed) return
2107
+ if (!result || !(result.data instanceof Uint8Array) || result.eof === false || result.data.byteLength > THUMB_MAX_BYTES) {
2108
+ handle.setThumbnail(null)
2109
+ return
2110
+ }
2111
+ const url = URL.createObjectURL(new Blob([result.data], { type: mimeOf(absolutePath) }))
2112
+ const previous = thumbCache.get(key)
2113
+ if (previous) URL.revokeObjectURL(previous.url)
2114
+ thumbCache.set(key, { url, at: Date.now() })
2115
+ if (thumbCache.size > 12) {
2116
+ const oldest = [...thumbCache.entries()].sort((a, b) => a[1].at - b[1].at)[0]
2117
+ if (oldest) {
2118
+ URL.revokeObjectURL(oldest[1].url)
2119
+ thumbCache.delete(oldest[0])
2120
+ }
2121
+ }
2122
+ if (menu === handle && !handle.closed) handle.setThumbnail(url)
2123
+ } catch (error) {
2124
+ if (menu === handle && !handle.closed) handle.setThumbnail(null)
2125
+ console.warn('[dsh-file-actions] thumbnail failed:', error)
2126
+ }
2127
+ }
2128
+
2129
+ function onContextMenu(event) {
2130
+ if (event.defaultPrevented) return
2131
+ if (event.shiftKey) { closeMenu(); return }
2132
+ const target = event.target
2133
+ // A right-click on the menu itself must not open a second menu over it.
2134
+ if (target && target.closest && target.closest(`[${MENU_ATTR}]`)) {
2135
+ event.preventDefault()
2136
+ event.stopPropagation()
2137
+ return
2138
+ }
2139
+ closeMenu()
2140
+ const candidate = candidateFromEvent(event, document)
2141
+ if (!candidate) return
2142
+ if (['text', 'title'].includes(candidate.source)) {
2143
+ const probe = cachedProbe(candidate)
2144
+ if (!probe || probe.error) { warmCandidate(candidate); return }
2145
+ }
2146
+ event.preventDefault()
2147
+ event.stopPropagation()
2148
+ void openAt(candidate, event.clientX, event.clientY, false)
2149
+ }
2150
+
2151
+ function onKeyDown(event) {
2152
+ const wantsMenu = (event.key === 'F10' && event.shiftKey) || event.key === 'ContextMenu'
2153
+ if (!wantsMenu) return
2154
+ const active = document.activeElement
2155
+ if (!active || !active.getBoundingClientRect) return
2156
+ const rect = active.getBoundingClientRect()
2157
+ const candidate = candidateFromEvent({
2158
+ target: active,
2159
+ clientX: rect.left + rect.width / 2,
2160
+ clientY: rect.top + rect.height / 2,
2161
+ }, document)
2162
+ if (!candidate) return
2163
+ if (['text', 'title'].includes(candidate.source)) {
2164
+ const probe = cachedProbe(candidate)
2165
+ if (!probe || probe.error) { warmCandidate(candidate); return }
2166
+ }
2167
+ event.preventDefault()
2168
+ void openAt(candidate, rect.left, rect.bottom, true)
2169
+ }
2170
+
2171
+ function onWarm(event) {
2172
+ if (event.shiftKey || event.target?.closest?.(`[${MENU_ATTR}]`)) return
2173
+ const rect = event.target?.getBoundingClientRect?.()
2174
+ warmCandidate(candidateFromEvent({ target: event.target,
2175
+ clientX: event.clientX ?? rect?.left, clientY: event.clientY ?? rect?.top }, document))
2176
+ }
2177
+
2178
+ function onPointerDown(event) {
2179
+ const target = event.target
2180
+ if (target && target.closest && target.closest(`[${MENU_ATTR}]`)) return
2181
+ closeMenu()
2182
+ }
2183
+
2184
+ function onViewportChange(event) {
2185
+ // Scrolling the menu's own long lists (or the "open with" submenu) is not
2186
+ // the page moving; closing on it would make those lists unusable.
2187
+ const target = event && event.target
2188
+ if (target && target.closest && target.closest(`[${MENU_ATTR}]`)) return
2189
+ if (!menu) return
2190
+ if (event && event.type === 'scroll') {
2191
+ menu.followAnchor()
2192
+ return
2193
+ }
2194
+ closeMenu()
2195
+ }
2196
+
2197
+ const install = () => {
2198
+ if (activeCleanup) activeCleanup()
2199
+ document.addEventListener('contextmenu', onContextMenu, true)
2200
+ document.addEventListener('pointerover', onWarm, true)
2201
+ document.addEventListener('focusin', onWarm, true)
2202
+ document.addEventListener('keydown', onKeyDown, true)
2203
+ document.addEventListener('pointerdown', onPointerDown, true)
2204
+ window.addEventListener('resize', onViewportChange)
2205
+ document.addEventListener('scroll', onViewportChange, true)
2206
+ // A DOM marker survives page-side identity quirks and gives the e2e driver
2207
+ // one stable readiness signal instead of guessing at boot timing.
2208
+ document.documentElement.setAttribute('data-dfa-ready', '1')
2209
+ const cleanup = () => {
2210
+ disposed = true
2211
+ document.documentElement.removeAttribute('data-dfa-ready')
2212
+ document.removeEventListener('contextmenu', onContextMenu, true)
2213
+ document.removeEventListener('pointerover', onWarm, true)
2214
+ document.removeEventListener('focusin', onWarm, true)
2215
+ probeCache.clear()
2216
+ probePending.clear()
2217
+ document.removeEventListener('keydown', onKeyDown, true)
2218
+ document.removeEventListener('pointerdown', onPointerDown, true)
2219
+ window.removeEventListener('resize', onViewportChange)
2220
+ document.removeEventListener('scroll', onViewportChange, true)
2221
+ closeMenu()
2222
+ for (const value of thumbCache.values()) URL.revokeObjectURL(value.url)
2223
+ thumbCache.clear()
2224
+ appsCache.clear()
2225
+ if (activeCleanup === cleanup) activeCleanup = null
2226
+ }
2227
+ activeCleanup = cleanup
2228
+ return cleanup
2229
+ }
2230
+
2231
+ if (typeof ctx.effect === 'function') {
2232
+ ctx.effect(install, 'dsh-file-actions: context menu')
2233
+ } else {
2234
+ install()
2235
+ }
2236
+ }
2237
+
2238
+ /**
2239
+ * The loader records one failed row as a boot-audit failure with no message, so
2240
+ * log the real stack before rethrowing.
2241
+ */
2242
+ function apply(ctx) {
2243
+ try {
2244
+ start(ctx)
2245
+ } catch (error) {
2246
+ console.error('[dsh-file-actions] activation failed:', (error && error.stack) || error)
2247
+ throw error
2248
+ }
2249
+ }
2250
+
2251
+ module.exports = {
2252
+ name: 'dsh-file-actions',
2253
+ inject: ['sessions', 'remote', 'remote.session', 'remote.workspaceFiles'],
2254
+ apply,
2255
+ // Exposed for the Node unit tests: the bundle is a classic script, so the
2256
+ // test harness evaluates it with a fake `window` and reads these directly.
2257
+ __internals: {
2258
+ messages,
2259
+ isAbsolutePath,
2260
+ normalizeSlashes,
2261
+ basename,
2262
+ extname,
2263
+ formatBytes,
2264
+ stripLocationSuffix,
2265
+ joinPath,
2266
+ looksLikePath,
2267
+ pathFromHref,
2268
+ classify,
2269
+ isTextName,
2270
+ isInside,
2271
+ relativeTo,
2272
+ softPath,
2273
+ stripDecorations,
2274
+ dispatch,
2275
+ mimeOf,
2276
+ pickEditor,
2277
+ orderApplications,
2278
+ applicationPreferences,
2279
+ buildStatusModel,
2280
+ FileMenu,
2281
+ buildMenuModel,
2282
+ buildLinkMenuModel,
2283
+ formatLocation,
2284
+ linkFromHref,
2285
+ candidateFromEvent,
2286
+ createBridge,
2287
+ fillBrowserWindow,
2288
+ tokenAtPoint,
2289
+ unwrap,
2290
+ menuFloor,
2291
+ MAX_COPY_BYTES,
2292
+ },
2293
+ }
2294
+ return module.exports
2295
+ }
2296
+
2297
+ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
2298
+ window.__ModuleLoader__.load({ id: '@jidekaixin2dian/dsh-file-actions', factory })
2299
+ }
2300
+ })()