dsh-plugin-file-actions 0.1.7-alpha.2

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 ADDED
@@ -0,0 +1,1222 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-plugin-file-actions',
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+
7
+ var React = require('react');
8
+ var ReactDOMClient = require('react-dom/client');
9
+ var ui = require('@deepseek-ai/dsh-client-ui-primitives');
10
+
11
+ var Menu = ui.Menu;
12
+ // dsh 0.1.7 renamed the primitives icon exports from size-suffixed names
13
+ // (`…Outline16` / `…Outline14`) to stroke-weight variants (`…OutlineRegular`
14
+ // / `…OutlineMedium`; per the upstream design note, Regular is the same
15
+ // one-pixel artwork the numeric exports rendered and both accept `size`).
16
+ // Resolve the current export with the legacy name as a fallback so one
17
+ // bundle keeps working across dsh versions instead of rendering an
18
+ // undefined component type (which abdicates the whole slot entry).
19
+ var IconFolderOpen = ui.IconFolderOpenOutlineRegular ?? ui.IconFolderOpenOutline16;
20
+ var IconCopy = ui.IconCopyOutlineRegular ?? ui.IconCopyOutline16;
21
+ var IconCheck = ui.IconCheckOutlineRegular ?? ui.IconCheckOutline16;
22
+ var IconCode = ui.IconCodeOutlineRegular ?? ui.IconCodeOutline16;
23
+ // The menu affordance of the official open-in-app control. This cell
24
+ // replaces that control, so its trigger must read as a dropdown the same
25
+ // way the official chevron half did — the IconCode glyph this trigger
26
+ // carried during the coexistence round is literally a `#` (four strokes:
27
+ // two slanted verticals, two horizontals) and reads as neither a menu nor
28
+ // an opener.
29
+ var IconChevronDown = ui.IconChevronDownOutlineRegular ?? ui.IconChevronDownOutline14;
30
+ var IconLink = ui.IconLinkOutlineRegular ?? ui.IconLinkOutline16;
31
+ var IconBrowse = ui.IconBrowseOutlineRegular ?? ui.IconBrowseOutline16;
32
+ var IconRightUp = ui.IconRightUpOutlineRegular ?? ui.IconRightUpOutline16;
33
+ var IconSend = ui.IconSendOutlineRegular ?? ui.IconSendOutline14;
34
+ var IconDownload = ui.IconDownloadOutlineRegular ?? ui.IconDownloadOutline16;
35
+ var writeClipboard = ui.writeClipboard;
36
+
37
+ var e = React.createElement;
38
+
39
+ /** Locale namespace owned by this plugin. */
40
+ var NS = 'fileActions';
41
+
42
+ var zh = {
43
+ 'moreActions': '文件操作',
44
+ 'openWithDefault': '用默认应用打开',
45
+ 'openWithApp': '用 {app} 打开',
46
+ 'appDefault': '{app}(默认)',
47
+ 'revealFile': '显示文件位置',
48
+ 'copyRelativePath': '复制相对路径',
49
+ 'copyAbsolutePath': '复制绝对路径',
50
+ 'runFile': '在终端运行该文件',
51
+ 'openDirectory': '在终端打开所在目录',
52
+ 'runFileWith': '在 {app} 中运行该文件',
53
+ 'openDirectoryWith': '在 {app} 中打开所在目录',
54
+ 'copyEmailAddress': '复制邮箱地址',
55
+ 'composeEmail': '写邮件',
56
+ 'copyLink': '复制链接',
57
+ 'openInBuiltInBrowser': '在内置浏览器打开',
58
+ 'openInSystemBrowser': '在浏览器打开',
59
+ 'cloneTo': '克隆到…',
60
+ 'checkoutTo': '检出到…',
61
+ 'app.vscode': 'VS Code',
62
+ 'app.vscodeinsiders': 'VS Code Insiders',
63
+ 'app.cursor': 'Cursor',
64
+ 'app.windsurf': 'Windsurf',
65
+ 'app.zed': 'Zed',
66
+ 'app.sublimetext': 'Sublime Text',
67
+ 'app.androidstudio': 'Android Studio',
68
+ 'app.intellij': 'IntelliJ IDEA',
69
+ 'app.pycharm': 'PyCharm',
70
+ 'app.webstorm': 'WebStorm',
71
+ 'app.phpstorm': 'PhpStorm',
72
+ 'app.goland': 'GoLand',
73
+ 'app.rider': 'Rider',
74
+ 'app.rustrover': 'RustRover',
75
+ 'app.finder': '访达',
76
+ 'app.explorer': '文件资源管理器',
77
+ 'app.filemanager': '文件管理器',
78
+ 'app.ghostty': 'Ghostty',
79
+ 'app.terminal': '终端',
80
+ 'app.gitbash': 'Git Bash',
81
+ 'app.windowsterminal': 'Windows 终端',
82
+ 'app.gnometerminal': 'GNOME 终端',
83
+ 'app.konsole': 'Konsole',
84
+ 'error.noCommand': '无法确定该文件的运行命令',
85
+ 'error.unavailableApp': '该应用在本机不可用',
86
+ 'error.unavailableTerminal': '该终端在本机不可用',
87
+ 'error.appsUnavailable': '无法获取应用列表',
88
+ 'error.openFailed': '打开失败,请重试',
89
+ 'error.revealFailed': '无法显示文件位置,请重试',
90
+ 'error.launchFailed': '打开 {app} 失败',
91
+ 'error.badUrl': '无法识别的仓库地址',
92
+ 'error.targetExists': '目标目录已存在',
93
+ 'error.noPicker': '此主机没有可用的目录选择器',
94
+ 'error.cloneFailed': '克隆失败,请重试',
95
+ 'error.generic': '操作失败,请重试',
96
+ };
97
+
98
+ var en = {
99
+ 'moreActions': 'File actions',
100
+ 'openWithDefault': 'Open with the default app',
101
+ 'openWithApp': 'Open with {app}',
102
+ 'appDefault': '{app} (default)',
103
+ 'revealFile': 'Show file location',
104
+ 'copyRelativePath': 'Copy relative path',
105
+ 'copyAbsolutePath': 'Copy absolute path',
106
+ 'runFile': 'Run this file in terminal',
107
+ 'openDirectory': 'Open containing folder in terminal',
108
+ 'runFileWith': 'Run this file in {app}',
109
+ 'openDirectoryWith': 'Open containing folder in {app}',
110
+ 'copyEmailAddress': 'Copy email address',
111
+ 'composeEmail': 'Compose email',
112
+ 'copyLink': 'Copy link',
113
+ 'openInBuiltInBrowser': 'Open in the built-in browser',
114
+ 'openInSystemBrowser': 'Open in browser',
115
+ 'cloneTo': 'Clone to…',
116
+ 'checkoutTo': 'Check out to…',
117
+ 'app.vscode': 'VS Code',
118
+ 'app.vscodeinsiders': 'VS Code Insiders',
119
+ 'app.cursor': 'Cursor',
120
+ 'app.windsurf': 'Windsurf',
121
+ 'app.zed': 'Zed',
122
+ 'app.sublimetext': 'Sublime Text',
123
+ 'app.androidstudio': 'Android Studio',
124
+ 'app.intellij': 'IntelliJ IDEA',
125
+ 'app.pycharm': 'PyCharm',
126
+ 'app.webstorm': 'WebStorm',
127
+ 'app.phpstorm': 'PhpStorm',
128
+ 'app.goland': 'GoLand',
129
+ 'app.rider': 'Rider',
130
+ 'app.rustrover': 'RustRover',
131
+ 'app.finder': 'Finder',
132
+ 'app.explorer': 'File Explorer',
133
+ 'app.filemanager': 'Files',
134
+ 'app.ghostty': 'Ghostty',
135
+ 'app.terminal': 'Terminal',
136
+ 'app.gitbash': 'Git Bash',
137
+ 'app.windowsterminal': 'Windows Terminal',
138
+ 'app.gnometerminal': 'GNOME Terminal',
139
+ 'app.konsole': 'Konsole',
140
+ 'error.noCommand': 'No run command is known for this file type',
141
+ 'error.unavailableApp': 'This app is not available on this machine',
142
+ 'error.unavailableTerminal': 'This terminal is not available on this machine',
143
+ 'error.appsUnavailable': 'Could not load applications',
144
+ 'error.openFailed': 'Could not open. Try again.',
145
+ 'error.revealFailed': 'Could not show the file location. Try again.',
146
+ 'error.launchFailed': 'Could not open {app}',
147
+ 'error.badUrl': 'Unrecognized repository URL',
148
+ 'error.targetExists': 'The target directory already exists',
149
+ 'error.noPicker': 'No directory picker is available on this host',
150
+ 'error.cloneFailed': 'The clone failed. Try again.',
151
+ 'error.generic': 'The action failed. Try again.',
152
+ };
153
+
154
+ /** Official open-in-app catalog ids this plugin can launch at file level, with labels. */
155
+ var EDITOR_IDS = [
156
+ 'cursor', 'vscode', 'vscodeinsiders', 'windsurf', 'zed', 'sublimetext',
157
+ 'androidstudio', 'intellij', 'pycharm', 'webstorm', 'phpstorm',
158
+ 'goland', 'rider', 'rustrover',
159
+ ];
160
+ // The official probe decides which of these exist on the host platform, so
161
+ // the full catalog list is safe: macOS resolves ghostty/terminal, Windows
162
+ // gitbash/windowsterminal, Linux ghostty/gnometerminal/konsole.
163
+ var TERMINAL_IDS = ['ghostty', 'terminal', 'gitbash', 'windowsterminal', 'gnometerminal', 'konsole'];
164
+
165
+ /**
166
+ * Official file-manager catalog ids — first in the official catalog's menu
167
+ * order. Unlike editors/terminals these ride the official probe alone:
168
+ * their launch is the official POST /open-in-app/open with the file's
169
+ * directory, the exact call the session-header split button makes, so the
170
+ * official route itself guarantees "menu shows it, click works" and there
171
+ * is nothing for the plugin's own resolution to confirm.
172
+ */
173
+ var FILE_MANAGER_IDS = ['finder', 'explorer', 'filemanager'];
174
+
175
+ function isWindowsStylePath(value) {
176
+ return /^[A-Za-z]:[/\\]/.test(value) || value.indexOf('\\\\') === 0;
177
+ }
178
+
179
+ /** Browser-safe workspace path resolution, mirroring @deepseek-ai/dsh-util-workspace-path. */
180
+ function resolveWorkspacePath(cwd, path) {
181
+ if (path.indexOf('/') === 0 || isWindowsStylePath(path)) return path;
182
+ if (cwd === undefined || cwd === '') return path;
183
+ var separator = isWindowsStylePath(cwd) && cwd.indexOf('\\') >= 0 ? '\\' : '/';
184
+ var base = cwd.replace(/[/\\]+$/, '');
185
+ var relative = path.replace(/^[/\\]+/, '');
186
+ return base + separator + relative;
187
+ }
188
+
189
+ /** Strip the workspace root off an absolute path, mirroring the official
190
+ * relativizeToCwd plus the separator normalization its own fileAddressFor
191
+ * applies: newer hosts present presented-file paths absolutely, and old
192
+ * sessions may record the root in the other separator spelling, so the
193
+ * prefix check runs on slash-normalized forms while the slice keeps the
194
+ * original spelling of the remainder. */
195
+ function relativizeToCwd(text, cwd) {
196
+ if (cwd === undefined || cwd === '') return text;
197
+ var root = cwd.replace(/[/\\]+$/, '').replace(/\\/g, '/');
198
+ var normalized = text.replace(/\\/g, '/');
199
+ if (normalized.indexOf(root + '/') === 0) return text.slice(root.length + 1);
200
+ return text;
201
+ }
202
+
203
+ /** Directory portion of an absolute POSIX (or Windows-style) path. */
204
+ function dirnameOf(path) {
205
+ var normalized = path.replace(/\\/g, '/');
206
+ var index = normalized.lastIndexOf('/');
207
+ if (index <= 0) return index === 0 ? '/' : normalized;
208
+ if (index === 2 && normalized[1] === ':') return normalized.slice(0, 3);
209
+ return normalized.slice(0, index);
210
+ }
211
+
212
+ /** Lowercase extension of a workspace path, '' when the basename has none. */
213
+ function extensionOf(path) {
214
+ var base = (path.split('/').pop() || '').split('\\').pop() || '';
215
+ var index = base.lastIndexOf('.');
216
+ return index <= 0 ? '' : base.slice(index + 1).toLowerCase();
217
+ }
218
+
219
+ /**
220
+ * Touch-primary pointer (phones, tablets). The Menu primitive opens
221
+ * submenus to the RIGHT of the parent row (`left: calc(100% + 10px)`,
222
+ * Menu.module.css) with no viewport clamp, so beside a right-aligned
223
+ * 218px card the ~163px submenu lands off-screen on a phone — the rows
224
+ * look dead. Coarse pointers get the terminal actions flattened into the
225
+ * top level instead (verified at 390px: the submenu rendered at
226
+ * x 362..540 against a 390px viewport).
227
+ */
228
+ function coarsePointer() {
229
+ try {
230
+ if (typeof window === 'undefined' || window.matchMedia === null || window.matchMedia === undefined) return false;
231
+ return window.matchMedia('(pointer: coarse)').matches === true;
232
+ } catch (error) {
233
+ return false;
234
+ }
235
+ }
236
+
237
+ /** Fetch one JSON body with its status; never rejects. */
238
+ function fetchJson(url, options) {
239
+ return fetch(url, options).then(function (res) {
240
+ return res.json().then(
241
+ function (data) { return { ok: res.ok, status: res.status, data: data }; },
242
+ function () { return { ok: false, status: res.status, data: null }; },
243
+ );
244
+ }, function () {
245
+ return { ok: false, status: 0, data: null };
246
+ });
247
+ }
248
+
249
+ function postJson(url, body) {
250
+ return fetchJson(url, {
251
+ method: 'POST',
252
+ headers: { 'content-type': 'application/json' },
253
+ body: JSON.stringify(body),
254
+ });
255
+ }
256
+
257
+ /** One application's real bundle icon with a generic-glyph fallback (official route). */
258
+ function AppIcon(props) {
259
+ var failedState = React.useState(false);
260
+ var failed = failedState[0];
261
+ var setFailed = failedState[1];
262
+ if (failed) {
263
+ return e('svg', { width: props.size, height: props.size, viewBox: '0 0 24 24', fill: 'none',
264
+ stroke: 'currentColor', strokeWidth: 1.8, 'aria-hidden': true },
265
+ e('rect', { x: 3, y: 3, width: 18, height: 18, rx: 5 }));
266
+ }
267
+ return e('img', {
268
+ src: '/open-in-app/icon/' + props.id,
269
+ width: props.size, height: props.size, alt: '', 'aria-hidden': true, draggable: false,
270
+ onError: function () { setFailed(true); },
271
+ });
272
+ }
273
+
274
+ /**
275
+ * One OS-associated application's icon. Unlike the catalog ids above, the
276
+ * per-file association query embeds the real bundle artwork in the
277
+ * response itself (the official `NativeFileApplication` contract: a PNG or
278
+ * SVG data URL, or null when the desktop supplies none), so the row shows
279
+ * the OS image directly and falls back to the official generic glyph for a
280
+ * null or broken icon.
281
+ */
282
+ function AssocIcon(props) {
283
+ var failedState = React.useState(false);
284
+ var failed = failedState[0];
285
+ var setFailed = failedState[1];
286
+ if (props.source === null || props.source === undefined || failed) {
287
+ return IconRightUp === undefined ? null : e(IconRightUp, { size: 16 });
288
+ }
289
+ return e('img', {
290
+ src: props.source,
291
+ width: 16, height: 16, alt: '', 'aria-hidden': true, draggable: false,
292
+ onError: function () { setFailed(true); },
293
+ });
294
+ }
295
+
296
+ /** Association-list lifecycle markers; `null` would collide with a real value. */
297
+ var ASSOC_LOADING = 'loading';
298
+ var ASSOC_FAILED = 'failed';
299
+
300
+ /** The embedded app-icon form the official native-file-application contract allows. */
301
+ var APP_ICON_DATA_URL_RE = /^data:image\/(?:png|svg\+xml);base64,[A-Za-z0-9+/=]+$/;
302
+
303
+ /**
304
+ * Validate one association response without trusting it. The official
305
+ * browser-side validator throws on the first malformed entry (the whole
306
+ * control then degrades); this mirror is deliberately forgiving instead —
307
+ * a hostile or drifted Host loses only the rows it could not describe.
308
+ * Icons must be the embedded base64 PNG/SVG data URLs the official
309
+ * contract promises, anything else becomes the generic glyph.
310
+ * @returns the usable entries, or null when the body is not a list at all.
311
+ */
312
+ function parseApplications(value) {
313
+ if (!Array.isArray(value)) return null;
314
+ var applications = [];
315
+ for (var index = 0; index < value.length; index++) {
316
+ var entry = value[index];
317
+ if (entry === null || typeof entry !== 'object') continue;
318
+ if (typeof entry.id !== 'string' || entry.id === '') continue;
319
+ if (typeof entry.name !== 'string') continue;
320
+ var icon = typeof entry.icon === 'string' && APP_ICON_DATA_URL_RE.test(entry.icon) ? entry.icon : null;
321
+ applications.push({ id: entry.id, name: entry.name, 'default': entry['default'] === true, icon: icon });
322
+ }
323
+ return applications;
324
+ }
325
+
326
+ /**
327
+ * The rows this cell absorbed from the official open-in-app control it
328
+ * shadows: the OS default application, the per-file association list (with
329
+ * the desktop's own icons), and reveal. Their launch goes through the
330
+ * seat's `onAction`, never a plugin route — the owner's `actionUrl` is the
331
+ * only authorized address for these coordinates.
332
+ *
333
+ * `available:false` means the serving Host has no desktop at all: the
334
+ * official control rendered nothing in that state, so these rows vanish
335
+ * too and the plugin's terminal/copy sections carry the card alone.
336
+ * `pending` (the owner is already opening/revealing this file) and the
337
+ * association read in flight both gray the rows out rather than removing
338
+ * them, so the menu never changes height under the cursor.
339
+ */
340
+ function absorbedRows(native, t) {
341
+ if (native === undefined || native === null || native.available !== true) return [];
342
+ var busy = native.pending === true || native.loading === true;
343
+ var applications = Array.isArray(native.applications) ? native.applications : [];
344
+ var preferred = null;
345
+ applications.forEach(function (app) {
346
+ if (preferred === null && app['default'] === true) preferred = app;
347
+ });
348
+ var rows = [{
349
+ id: 'fa:open',
350
+ icon: e(AssocIcon, { source: preferred === null ? null : preferred.icon }),
351
+ label: preferred === null ? t('openWithDefault') : t('openWithApp', { app: preferred.name }),
352
+ disabled: busy,
353
+ }];
354
+ if (native.failed === true) {
355
+ // The association read failed: the default still opens (the owner
356
+ // resolves it), but there is no list to offer — the official
357
+ // placeholder row, localized by this plugin's dictionary.
358
+ rows.push({ id: 'fa:osapp-error', label: t('error.appsUnavailable'), disabled: true });
359
+ }
360
+ applications.forEach(function (app) {
361
+ rows.push({
362
+ id: 'fa:osapp:' + app.id,
363
+ icon: e(AssocIcon, { source: app.icon }),
364
+ label: app['default'] === true ? t('appDefault', { app: app.name }) : app.name,
365
+ disabled: busy,
366
+ });
367
+ });
368
+ rows.push({ id: 'fa:reveal', icon: e(IconFolderOpen, { size: 16 }), label: t('revealFile'), disabled: busy });
369
+ return rows;
370
+ }
371
+
372
+ /**
373
+ * Build one menu entry from the shared plugin state. `cardProps` is the
374
+ * presented-file card's props, or a `{ file: { path }, cwd }` stand-in read
375
+ * from a message file link. `mode` picks the surface:
376
+ *
377
+ * - `'slot'` — the deliverable card's `deliverables.file.actions` cell.
378
+ * This cell TAKES OVER the shipped `open-in-app` cell (same id, lower
379
+ * priority), so it owns the card's single dropdown end to end: the
380
+ * absorbed official rows (default application, per-file OS association
381
+ * list, reveal) lead, the plugin's own terminal rows follow, and the
382
+ * browser-side copy entries close. `native` carries the owner's seat
383
+ * contract (`available` / `pending` plus the association list read from
384
+ * the owner's own authorized `actionUrl`).
385
+ * - full (anything else) — the message-link context menu. It keeps every
386
+ * section, the plugin's own editor/file-manager catalog included: a
387
+ * message link has no official control behind it.
388
+ */
389
+ function buildItems(cardProps, state, t, actions, mode, native) {
390
+ var file = cardProps.file;
391
+ var full = mode !== 'slot';
392
+ var items = [];
393
+ // The absorbed official rows only exist on the card: the link menu never
394
+ // has an owner-provided actionUrl or desktop availability.
395
+ var absorbed = full ? [] : absorbedRows(native, t);
396
+ absorbed.forEach(function (item) { items.push(item); });
397
+ // File managers ride the official probe alone (see FILE_MANAGER_IDS);
398
+ // editors and terminals wait for the plugin info and then intersect the
399
+ // official probe with it.
400
+ var fileManagers = full
401
+ ? (state.officialApps || []).filter(function (id) {
402
+ return FILE_MANAGER_IDS.indexOf(id) >= 0;
403
+ })
404
+ : [];
405
+ var editors = [];
406
+ var terminals = [];
407
+ var runnable = false;
408
+ if (state.info !== null && state.info !== undefined) {
409
+ var runExtensions = state.info.runExtensions;
410
+ // Show an app only when BOTH the official probe and this plugin's own
411
+ // resolution verified it: the two resolver copies (the host dsh's and
412
+ // the plugin's pinned one) may differ in version, and this
413
+ // intersection makes the "menu shows it, click 400s" failure
414
+ // impossible. Older hosts without `available` keep the official
415
+ // intersection only.
416
+ var available = state.info.available;
417
+ var pick = function (whitelist) {
418
+ var matched = (state.officialApps || []).filter(function (id) {
419
+ return whitelist.indexOf(id) >= 0;
420
+ });
421
+ if (available === null || available === undefined) return matched;
422
+ return matched.filter(function (id) { return available.indexOf(id) >= 0; });
423
+ };
424
+ editors = full ? pick(EDITOR_IDS) : [];
425
+ terminals = pick(TERMINAL_IDS);
426
+ var extension = extensionOf(file.path);
427
+ runnable = extension === '' || (runExtensions !== undefined && runExtensions.indexOf(extension) >= 0);
428
+ }
429
+ var hasLeads = absorbed.length > 0 || fileManagers.length > 0 || editors.length > 0;
430
+ fileManagers.forEach(function (id) {
431
+ items.push({
432
+ id: 'fa:fm:' + id,
433
+ icon: e(AppIcon, { id: id, size: 16 }),
434
+ label: t('app.' + id),
435
+ });
436
+ });
437
+ editors.forEach(function (id) {
438
+ items.push({
439
+ id: 'fa:app:' + id,
440
+ icon: e(AppIcon, { id: id, size: 16 }),
441
+ label: t('app.' + id),
442
+ });
443
+ });
444
+ if (terminals.length > 0 && hasLeads) items.push({ type: 'separator', id: 'fa:sep-terms' });
445
+ var flattenTerminals = coarsePointer();
446
+ terminals.forEach(function (id) {
447
+ if (flattenTerminals) {
448
+ // Touch: no hover to open a side card, and the side card would be
449
+ // clamped off the narrow viewport anyway — same dispatch ids, one
450
+ // row per action, named by the terminal.
451
+ var terminal = t('app.' + id);
452
+ items.push({
453
+ id: 'fa:run:' + id,
454
+ icon: e(IconCode, { size: 16 }),
455
+ label: t('runFileWith', { app: terminal }),
456
+ disabled: !runnable,
457
+ });
458
+ items.push({
459
+ id: 'fa:opendir:' + id,
460
+ icon: e(IconFolderOpen, { size: 16 }),
461
+ label: t('openDirectoryWith', { app: terminal }),
462
+ });
463
+ return;
464
+ }
465
+ items.push({
466
+ id: 'fa:term:' + id,
467
+ icon: e(AppIcon, { id: id, size: 16 }),
468
+ label: t('app.' + id),
469
+ submenu: [
470
+ { id: 'fa:run:' + id, icon: e(IconCode, { size: 16 }), label: t('runFile'), disabled: !runnable },
471
+ { id: 'fa:opendir:' + id, icon: e(IconFolderOpen, { size: 16 }), label: t('openDirectory') },
472
+ ],
473
+ });
474
+ });
475
+ if (hasLeads || terminals.length > 0) items.push({ type: 'separator', id: 'fa:sep-copies' });
476
+ items.push(
477
+ { id: 'fa:copy-rel', icon: e(IconCopy, { size: 16 }), label: t('copyRelativePath') },
478
+ { id: 'fa:copy-abs', icon: e(IconCopy, { size: 16 }), label: t('copyAbsolutePath') },
479
+ );
480
+ if (actions.error !== null && actions.error !== undefined) {
481
+ items.push({ type: 'separator', id: 'fa:sep-err' });
482
+ items.push({ id: 'fa:error', label: actions.error, disabled: true, danger: true });
483
+ }
484
+ return items;
485
+ }
486
+
487
+ /**
488
+ * One menu selection, shared by the card menu and the message-link context
489
+ * menu. `cardProps` is the real card props or the link stand-in; `deps`
490
+ * carries the surface's own close/error callbacks and the dictionary.
491
+ */
492
+ function dispatchSelection(id, cardProps, deps) {
493
+ var absolute = resolveWorkspacePath(cardProps.cwd, cardProps.file.path);
494
+ var t = deps.t;
495
+ var fail = function (result, app) {
496
+ var code = result.data !== null && result.data !== undefined && result.data.code !== undefined
497
+ ? result.data.code : '';
498
+ deps.setError(errorTextOf(code, t, app));
499
+ };
500
+ if (id === 'fa:copy-rel') {
501
+ deps.close();
502
+ writeClipboard(relativizeToCwd(cardProps.file.path, cardProps.cwd));
503
+ return;
504
+ }
505
+ if (id === 'fa:copy-abs') {
506
+ deps.close();
507
+ writeClipboard(absolute);
508
+ return;
509
+ }
510
+ if (id === 'fa:open' || id === 'fa:reveal' || id.indexOf('fa:osapp:') === 0) {
511
+ // The absorbed official rows. These coordinates are addressed by the
512
+ // owner's own authorized actionUrl, so the call must ride the seat's
513
+ // onAction (which also publishes the card's open/reveal status) and
514
+ // never a plugin route. The returned failure code lands in this
515
+ // plugin's own error row instead of the official toast.
516
+ if (typeof deps.onAction !== 'function') return;
517
+ var reveal = id === 'fa:reveal';
518
+ var application = id.indexOf('fa:osapp:') === 0 ? id.slice(9) : undefined;
519
+ var announce = function (failure) {
520
+ if (failure === null || failure === undefined) { deps.close(); return; }
521
+ deps.setError(t(failure === 'revealError' ? 'error.revealFailed' : 'error.openFailed'));
522
+ };
523
+ deps.onAction(reveal ? 'reveal' : 'open', application).then(announce, function () {
524
+ announce(reveal ? 'revealError' : 'openError');
525
+ });
526
+ return;
527
+ }
528
+ if (id.indexOf('fa:fm:') === 0) {
529
+ // The official launch: the session-header split button's exact call,
530
+ // with the file's directory standing in for the workspace directory.
531
+ var manager = id.slice(6);
532
+ postJson('/open-in-app/open', { app: manager, path: dirnameOf(absolute) }).then(function (result) {
533
+ if (result.ok) deps.close(); else fail(result, t('app.' + manager));
534
+ });
535
+ return;
536
+ }
537
+ if (id.indexOf('fa:app:') === 0) {
538
+ postJson('/api/file-actions/launch', { app: id.slice(7), path: absolute }).then(function (result) {
539
+ if (result.ok) deps.close(); else fail(result, t('app.' + id.slice(7)));
540
+ });
541
+ return;
542
+ }
543
+ if (id.indexOf('fa:run:') === 0) {
544
+ postJson('/api/file-actions/run', { app: id.slice(7), path: absolute }).then(function (result) {
545
+ if (result.ok) deps.close(); else fail(result, t('app.' + id.slice(7)));
546
+ });
547
+ return;
548
+ }
549
+ if (id.indexOf('fa:opendir:') === 0) {
550
+ var app = id.slice(11);
551
+ postJson('/open-in-app/open', { app: app, path: dirnameOf(absolute) }).then(function (result) {
552
+ if (result.ok) deps.close(); else fail(result, t('app.' + app));
553
+ });
554
+ }
555
+ }
556
+
557
+ /** The localized line for one route failure code; '' codes take the generic. */
558
+ function errorTextOf(code, t, app) {
559
+ if (code === 'no-command') return t('error.noCommand');
560
+ if (code === 'unavailable-app') return t('error.unavailableApp');
561
+ if (code === 'unavailable-terminal') return t('error.unavailableTerminal');
562
+ if (code === 'launch-failed') return t('error.launchFailed', { app: app });
563
+ if (code === 'bad-url') return t('error.badUrl');
564
+ if (code === 'target-exists') return t('error.targetExists');
565
+ if (code === 'no-picker') return t('error.noPicker');
566
+ if (code === 'clone-failed') return t('error.cloneFailed');
567
+ return t('error.generic');
568
+ }
569
+
570
+ // The official message sanitizer keeps only http/https/mailto hrefs, so
571
+ // svn:// and git:// links never render as anchors — repository URLs in
572
+ // those schemes surface as inline `code` text, which the classifier reads.
573
+ var GIT_SSH_URL_RE = /^(?:git|ssh):\/\/\S+$/i;
574
+ var GIT_SCP_RE = /^git@[A-Za-z0-9._-]+[:/]\S+$/i;
575
+ var GIT_HTTPS_RE = /^https?:\/\/\S+\.git$/i;
576
+ var SVN_URL_RE = /^(?:svn|svn\+ssh|svn\+https?):\/\/\S+$/i;
577
+ var MAILTO_HREF_RE = /^mailto:\S+/i;
578
+ var HTTP_HREF_RE = /^https?:\/\//i;
579
+ /**
580
+ * The message file links: the official markdown renders every file mention
581
+ * and every markdown file link as a button carrying the path in its
582
+ * `title` (shared hashed fileMention class; the input area's reference
583
+ * chips share the class but mark themselves with `data-ref-chip`, so they
584
+ * are excluded).
585
+ */
586
+ var FILE_LINK_SELECTOR = 'button[class*="fileMention"][title]:not([data-ref-chip])';
587
+
588
+ /**
589
+ * Classify one right-click target inside the message flow: file links keep
590
+ * their existing menu, http(s) anchors and inline `code` elements whose
591
+ * whole text is a repository URL gain the URL menus. Null means nothing
592
+ * applies and the native menu stays. Plain (non-code) text is deliberately
593
+ * out of scope — a text node has no boundary, so a bare git@host:path
594
+ * sentence fragment is not a menu target.
595
+ */
596
+ function classifyContextTarget(target) {
597
+ var fileButton = target.closest(FILE_LINK_SELECTOR);
598
+ if (fileButton !== null) {
599
+ var filePath = fileButton.getAttribute('title');
600
+ return filePath !== null && filePath !== '' ? { kind: 'file', path: filePath } : null;
601
+ }
602
+ var anchor = target.closest('a[href]');
603
+ if (anchor !== null) {
604
+ var href = anchor.getAttribute('href');
605
+ if (href === null || href === '') return null;
606
+ if (MAILTO_HREF_RE.test(href)) {
607
+ return { kind: 'email', value: href, address: href.slice(7).split('?')[0] };
608
+ }
609
+ if (HTTP_HREF_RE.test(href)) {
610
+ return { kind: GIT_HTTPS_RE.test(href) ? 'git' : 'http', value: href };
611
+ }
612
+ return null;
613
+ }
614
+ var code = target.closest('code');
615
+ if (code !== null) {
616
+ var text = (code.textContent || '').trim();
617
+ if (GIT_SSH_URL_RE.test(text) || GIT_SCP_RE.test(text) || GIT_HTTPS_RE.test(text)) {
618
+ return { kind: 'git', value: text };
619
+ }
620
+ if (SVN_URL_RE.test(text)) return { kind: 'svn', value: text };
621
+ }
622
+ return null;
623
+ }
624
+
625
+ /**
626
+ * One URL menu: entries per link kind. `capabilities` reports which
627
+ * optional host seats exist (the built-in browser tab, the directory
628
+ * picker) so an unavailable action never renders as a dead end.
629
+ */
630
+ function buildLinkItems(target, capabilities, t) {
631
+ if (target.kind === 'email') {
632
+ return [
633
+ { id: 'fa:link:copy-email', icon: e(IconCopy, { size: 16 }), label: t('copyEmailAddress') },
634
+ { id: 'fa:link:compose', icon: e(IconSend, { size: 16 }), label: t('composeEmail') },
635
+ ];
636
+ }
637
+ if (target.kind === 'http') {
638
+ var httpItems = [{ id: 'fa:link:copy', icon: e(IconLink, { size: 16 }), label: t('copyLink') }];
639
+ if (capabilities.builtInBrowser) {
640
+ httpItems.push({ id: 'fa:link:browse', icon: e(IconBrowse, { size: 16 }), label: t('openInBuiltInBrowser') });
641
+ }
642
+ httpItems.push({ id: 'fa:link:external', icon: e(IconRightUp, { size: 16 }), label: t('openInSystemBrowser') });
643
+ return httpItems;
644
+ }
645
+ return [
646
+ { id: 'fa:link:copy', icon: e(IconLink, { size: 16 }), label: t('copyLink') },
647
+ {
648
+ id: target.kind === 'git' ? 'fa:link:clone' : 'fa:link:checkout',
649
+ icon: e(IconDownload, { size: 16 }),
650
+ label: target.kind === 'git' ? t('cloneTo') : t('checkoutTo'),
651
+ disabled: !capabilities.picker,
652
+ },
653
+ ];
654
+ }
655
+
656
+ /**
657
+ * One URL-menu selection. Clone/checkout keep the menu open while the
658
+ * directory chooser and the host's VCS run — the outcome lands where the
659
+ * click happened (success closes, failure shows the error row). Cancelling
660
+ * the chooser closes quietly.
661
+ */
662
+ function dispatchLinkSelection(id, target, deps) {
663
+ if (id === 'fa:link:copy-email') {
664
+ deps.close();
665
+ writeClipboard(target.address);
666
+ return;
667
+ }
668
+ if (id === 'fa:link:compose') {
669
+ deps.close();
670
+ openExternalScheme(target.value);
671
+ return;
672
+ }
673
+ if (id === 'fa:link:copy') {
674
+ deps.close();
675
+ writeClipboard(target.value);
676
+ return;
677
+ }
678
+ if (id === 'fa:link:browse') {
679
+ deps.close();
680
+ deps.openBuiltInBrowser(target.value);
681
+ return;
682
+ }
683
+ if (id === 'fa:link:external') {
684
+ deps.close();
685
+ window.open(target.value, '_blank', 'noopener,noreferrer');
686
+ return;
687
+ }
688
+ if (id === 'fa:link:clone' || id === 'fa:link:checkout') {
689
+ var vcs = id === 'fa:link:clone' ? 'git' : 'svn';
690
+ deps.pickDirectory().then(function (parent) {
691
+ if (parent === null || parent === undefined || parent === '') {
692
+ deps.close();
693
+ return;
694
+ }
695
+ postJson('/api/file-actions/clone', { url: target.value, vcs: vcs, parent: parent }).then(function (result) {
696
+ if (result.ok) deps.close(); else deps.fail(result);
697
+ });
698
+ }, function () {
699
+ deps.fail({ data: { code: 'no-picker' } });
700
+ });
701
+ }
702
+ }
703
+
704
+ /** Hand one non-http URL (mailto) to the browser's external-protocol handler. */
705
+ function openExternalScheme(href) {
706
+ var anchor = document.createElement('a');
707
+ anchor.href = href;
708
+ anchor.style.display = 'none';
709
+ document.body.appendChild(anchor);
710
+ anchor.click();
711
+ anchor.remove();
712
+ }
713
+
714
+ /** Client half: own every presented-file card's single dropdown — the official rows it shadows plus the plugin's terminal and copy sections. */
715
+ async function apply(ctx) {
716
+ ctx.effect(function () { return ctx.locale.register(NS, { zh: zh, en: en }); }, 'file-actions: dictionaries');
717
+ var t = ctx.locale.bind(NS);
718
+
719
+ /** Shared plugin state; read at render time, refreshed after the initial fetches. */
720
+ var state = { officialApps: null, info: null };
721
+ /** Slot cells and the context menu subscribe; fetches notify on landing. */
722
+ var stateListeners = new Set();
723
+ function subscribeState(listener) {
724
+ stateListeners.add(listener);
725
+ return function () { stateListeners.delete(listener); };
726
+ }
727
+ function notifyState() {
728
+ stateListeners.forEach(function (listener) { listener(); });
729
+ }
730
+
731
+ /** The workspace-resolved path from one presented-file card: the preview
732
+ * button's title carries it (0.1.7 no longer hands the path down through
733
+ * the slot input). Null when the official DOM drifted. */
734
+ function cardPathOf(card) {
735
+ var preview = card.querySelector('button[title]');
736
+ var title = preview === null ? null : preview.getAttribute('title');
737
+ return title === null || title === '' ? null : title;
738
+ }
739
+
740
+ /**
741
+ * One card's dropdown menu, rendered as the official
742
+ * deliverables.file.actions slot cell (the 0.1.7 contributed-actions
743
+ * architecture) — and registered under the SHIPPED cell's own id, so it
744
+ * replaces that cell instead of stacking a second control beside it.
745
+ * The card therefore carries exactly one dropdown, and this cell owns
746
+ * the whole of it: the official default-application / OS association /
747
+ * reveal rows are absorbed into `buildItems`' slot mode (they dispatch
748
+ * through the seat's `onAction`, the owner's authorized route), the
749
+ * plugin's terminal rows follow, and the browser-side copy entries
750
+ * close the menu.
751
+ *
752
+ * The file path is read through the cell's own mounted host (the cell
753
+ * renders inside the card's actions row) rather than a React fiber walk:
754
+ * refs resolve before paint, so every open/re-render after mount sees
755
+ * the card. A first paint without a readable path renders the trigger
756
+ * with no items; the mount refresh then either fills the items or, when
757
+ * the official DOM drifted, degrades the cell to nothing.
758
+ */
759
+ function CardMenu(props) {
760
+ var hostRef = React.useRef(null);
761
+ var openState = React.useState(false);
762
+ var open = openState[0];
763
+ var setOpen = openState[1];
764
+ var errorState = React.useState(null);
765
+ var error = errorState[0];
766
+ var setError = errorState[1];
767
+ var tickState = React.useState(0);
768
+ var setTick = tickState[1];
769
+ // The OS association list for THIS card, read from the owner's own
770
+ // authorized actionUrl — the same GET the shipped open-in-app cell
771
+ // issued, so a per-file query never bypasses the owning Session's
772
+ // authorization. The answer is paired with the route that produced it:
773
+ // while the pair is stale (the card moved to another file) the rows
774
+ // fall back to the loading shape rather than showing a sibling file's
775
+ // applications.
776
+ var associationState = React.useState({ url: null, value: ASSOC_LOADING });
777
+ var association = associationState[0];
778
+ var setAssociation = associationState[1];
779
+
780
+ // The owning session's workspace directory through the seat's standard
781
+ // session props — the same hook the header cwd recorder consumes.
782
+ var cwd = props.useSessions(function (sessionState) {
783
+ var row = props.sessionId === undefined || props.sessionId === null
784
+ ? undefined : sessionState.byId[props.sessionId];
785
+ return row === null || row === undefined ? undefined : row.cwd;
786
+ });
787
+
788
+ // One refresh after mount (refs resolve) fills the items or degrades
789
+ // the cell; the shared-state subscription re-renders when the official
790
+ // probe or the plugin's own info lands after the cell mounted.
791
+ React.useEffect(function () {
792
+ setTick(function (value) { return value + 1; });
793
+ return subscribeState(function () { setTick(function (value) { return value + 1; }); });
794
+ }, []);
795
+
796
+ // The association read. It starts only for a desktop that can answer
797
+ // (an unavailable Host answers 409) and is cancelled on unmount or on
798
+ // a route change, so a re-rendered card never publishes a stale list.
799
+ React.useEffect(function () {
800
+ if (props.available !== true) return undefined;
801
+ var url = props.actionUrl;
802
+ if (url === undefined || url === null || url === '') return undefined;
803
+ var cancelled = false;
804
+ fetchJson(url).then(function (result) {
805
+ if (cancelled) return;
806
+ var applications = result.ok ? parseApplications(result.data) : null;
807
+ setAssociation({ url: url, value: applications === null ? ASSOC_FAILED : applications });
808
+ });
809
+ return function () { cancelled = true; };
810
+ }, [props.actionUrl, props.available]);
811
+
812
+ var host = hostRef.current;
813
+ var card = host === null || host === undefined || typeof host.closest !== 'function'
814
+ ? null : host.closest('[data-presented-file]');
815
+ var path = card === null ? null : cardPathOf(card);
816
+ if (path === null && host !== null && host !== undefined) {
817
+ // Mounted and still unreadable — the official DOM drifted; render
818
+ // nothing rather than a dead trigger (the degrade-invisible rule).
819
+ return null;
820
+ }
821
+
822
+ var associated = association.url === props.actionUrl ? association.value : ASSOC_LOADING;
823
+ // An owner that handed over no route at all can never be asked for the
824
+ // association list; report it the way a failed read is reported instead
825
+ // of leaving the rows gray forever.
826
+ var addressed = typeof props.actionUrl === 'string' && props.actionUrl !== '';
827
+ var items = path === null
828
+ ? []
829
+ : buildItems({ file: { path: path }, cwd: cwd }, state, props.t, { error: error }, 'slot', {
830
+ available: props.available === true,
831
+ pending: props.pending === true,
832
+ loading: addressed && associated === ASSOC_LOADING,
833
+ failed: !addressed || associated === ASSOC_FAILED,
834
+ applications: Array.isArray(associated) ? associated : [],
835
+ });
836
+
837
+ // Open ABOVE the trigger. The official menu opens downward and fits
838
+ // under the card, but this plugin's menu is far taller and deliverable
839
+ // cards render at the end of a turn where the space below is small:
840
+ // the official placement then hits the viewport clamp
841
+ // (y = vh - listHeight - 12) and the panel lands on the trigger button.
842
+ // side:'top' grows the panel upward from the trigger's top edge instead.
843
+ return e('span', {
844
+ ref: hostRef,
845
+ 'data-fa-slot': '1',
846
+ style: { display: 'inline-flex', flex: 'none', alignItems: 'center', alignSelf: 'center' },
847
+ }, e(Menu, {
848
+ open: open,
849
+ autoFocus: true,
850
+ portal: true,
851
+ align: 'end',
852
+ side: 'top',
853
+ items: items,
854
+ onSelect: function (id) {
855
+ dispatchSelection(id, { file: { path: path }, cwd: cwd }, {
856
+ t: props.t,
857
+ close: function () { setOpen(false); },
858
+ setError: setError,
859
+ onAction: props.onAction,
860
+ });
861
+ },
862
+ onClose: function () { setOpen(false); },
863
+ anchor: e('button', {
864
+ type: 'button',
865
+ 'data-fa-trigger': '1',
866
+ 'aria-haspopup': 'menu',
867
+ 'aria-expanded': open,
868
+ 'aria-label': props.t('moreActions'),
869
+ title: props.t('moreActions'),
870
+ onClick: function () { setOpen(function (value) { return !value; }); },
871
+ }, e(IconChevronDown, { size: 11 })),
872
+ }));
873
+ }
874
+
875
+ // The official deliverables card seat, TAKEN OVER: the plugin registers
876
+ // under the shipped cell's own id 'open-in-app' at priority -10. The
877
+ // slot ledger sorts each list slot by (priority, order) and keeps the
878
+ // first live entry per id, and register() only rejects a same-id
879
+ // collision at the SAME priority — naming the shadowing remedy itself
880
+ // ("register at a different priority to shadow it (lowest renders)").
881
+ // So the official FileRouteAction stops rendering and this cell is the
882
+ // card's only control; it re-offers what the official control was good
883
+ // at instead of leaving it to a second button. ctx.slots.inject runs the
884
+ // callback per declaration lifetime and unwinds the registration when
885
+ // this plugin's fiber unloads — the same lifecycle the session-header
886
+ // cwd recorder below rides.
887
+ ctx.slots.inject('deliverables.file.actions', function () {
888
+ return ctx.slots.register({
889
+ name: 'deliverables.file.actions',
890
+ id: 'open-in-app',
891
+ priority: -10,
892
+ locale: NS,
893
+ }, CardMenu);
894
+ });
895
+
896
+ /**
897
+ * Shared state of the message-link context menu; `cwd` is published by
898
+ * the recorder cell below, and `target` is the classified link under the
899
+ * cursor. The whole object is replaced on every open, and the render
900
+ * closures always read the variable, never a stale copy.
901
+ */
902
+ var contextState = { open: false, x: 0, y: 0, target: null, cwd: null, error: null };
903
+ var contextContainer = document.createElement('div');
904
+ contextContainer.setAttribute('data-fa-context', '1');
905
+ document.body.appendChild(contextContainer);
906
+ var contextRoot = ReactDOMClient.createRoot(contextContainer);
907
+
908
+ /** Optional seats the URL menus use, re-read on every menu render. The
909
+ * remote.namespace read rides the declared inject key; the try/catch
910
+ * keeps a deployment without the picker namespace from crashing the
911
+ * menu render — the entries degrade to disabled instead. */
912
+ function contextCapabilities() {
913
+ var right = ctx.get('sidebarRight');
914
+ var tabs = ctx.get('sidebarRightTabs');
915
+ var picker = undefined;
916
+ try {
917
+ var remote = ctx.get('remote');
918
+ picker = remote === null || remote === undefined ? undefined : remote.directoryPicker;
919
+ } catch (error) {
920
+ picker = undefined;
921
+ }
922
+ return {
923
+ builtInBrowser: right !== null && right !== undefined && tabs !== null && tabs !== undefined
924
+ && tabs.get('browser') !== undefined,
925
+ picker: picker !== null && picker !== undefined && typeof picker.pick === 'function',
926
+ };
927
+ }
928
+
929
+ /**
930
+ * The official directory picker's pick remote (the workspace flow's own
931
+ * call): resolves the chosen absolute directory, or null on cancel.
932
+ * Rejects when the deployment carries no picker capability.
933
+ */
934
+ function pickDirectory() {
935
+ var picker;
936
+ try {
937
+ var remote = ctx.get('remote');
938
+ picker = remote === null || remote === undefined ? undefined : remote.directoryPicker;
939
+ } catch (error) {
940
+ picker = undefined;
941
+ }
942
+ if (picker === null || picker === undefined || typeof picker.pick !== 'function') {
943
+ return Promise.reject(new Error('file-actions: no directory picker on this deployment'));
944
+ }
945
+ return picker.pick().then(function (result) {
946
+ if (result === null || result === undefined) return null;
947
+ if (result.ok !== true) throw new Error('file-actions: directory picker failed');
948
+ return result.value;
949
+ });
950
+ }
951
+
952
+ /** The built-in browser tab when the deployment ships it — the official
953
+ * openExternalLink behavior — else the system browser. */
954
+ function openBuiltInBrowser(url) {
955
+ var right = ctx.get('sidebarRight');
956
+ if (right === null || right === undefined) {
957
+ window.open(url, '_blank', 'noopener,noreferrer');
958
+ return;
959
+ }
960
+ right.openTab('browser', { params: { url: url } });
961
+ }
962
+
963
+ /** Publish the current context state into the context-menu root. */
964
+ function renderContextMenu() {
965
+ contextRoot.render(e(LinkMenu, {
966
+ menu: contextState,
967
+ state: state,
968
+ t: t,
969
+ capabilities: contextCapabilities,
970
+ pickDirectory: pickDirectory,
971
+ openBuiltInBrowser: openBuiltInBrowser,
972
+ close: function () { contextState.open = false; renderContextMenu(); },
973
+ setError: function (message) { contextState.error = message; if (contextState.open) renderContextMenu(); },
974
+ onClose: function () { contextState.open = false; renderContextMenu(); },
975
+ }));
976
+ }
977
+
978
+ /**
979
+ * The right-click context menu over one message link — a presented-file
980
+ * link (the full file menu) or a URL (email / http / git / svn menus) —
981
+ * anchored at the cursor through Menu's getAnchorRect (portal mode; the
982
+ * viewport clamp keeps the panel on screen).
983
+ */
984
+ function LinkMenu(props) {
985
+ var menu = props.menu;
986
+ var target = menu.target;
987
+ var open = menu.open === true && target !== null && target !== undefined;
988
+ var items = [];
989
+ if (open) {
990
+ items = target.kind === 'file'
991
+ ? buildItems({ file: { path: target.path }, cwd: menu.cwd }, props.state, props.t, { error: menu.error })
992
+ : buildLinkItems(target, props.capabilities(), props.t);
993
+ }
994
+ return e(Menu, {
995
+ open: open,
996
+ autoFocus: true,
997
+ portal: true,
998
+ align: 'start',
999
+ side: 'bottom',
1000
+ items: items,
1001
+ onSelect: function (id) {
1002
+ if (target === null || target === undefined) return;
1003
+ if (target.kind === 'file') dispatchSelection(id, { file: { path: target.path }, cwd: menu.cwd }, props);
1004
+ else dispatchLinkSelection(id, target, props);
1005
+ },
1006
+ onClose: props.onClose,
1007
+ getAnchorRect: function () {
1008
+ return { left: menu.x, right: menu.x, top: menu.y, bottom: menu.y };
1009
+ },
1010
+ anchor: e('span', { 'data-fa-context-anchor': '1' }),
1011
+ });
1012
+ }
1013
+
1014
+ /** Replace the context state with one open menu at x/y over `link`. */
1015
+ function openContextMenu(x, y, link) {
1016
+ if (contextState.open && sameContextLink(contextState.target, link)) {
1017
+ // Already showing this link's menu — a touch long-press may have
1018
+ // opened it just before the browser's own contextmenu event (the
1019
+ // Android hold fires one) arrived. Keep the open state.
1020
+ return;
1021
+ }
1022
+ contextState = {
1023
+ open: true,
1024
+ x: x,
1025
+ y: y,
1026
+ target: link,
1027
+ cwd: contextState.cwd,
1028
+ error: null,
1029
+ };
1030
+ renderContextMenu();
1031
+ }
1032
+
1033
+ /** The two classifications match when they name the same link. */
1034
+ function sameContextLink(a, b) {
1035
+ if (a === null || a === undefined || b === null || b === undefined) return false;
1036
+ if (a.kind !== b.kind) return false;
1037
+ if (a.kind === 'file') return a.path === b.path;
1038
+ return a.value === b.value;
1039
+ }
1040
+
1041
+ function onContextMenu(event) {
1042
+ var target = event.target;
1043
+ if (target === null || target === undefined || typeof target.closest !== 'function') return;
1044
+ var link = classifyContextTarget(target);
1045
+ if (link === null) return;
1046
+ event.preventDefault();
1047
+ openContextMenu(event.clientX, event.clientY, link);
1048
+ }
1049
+
1050
+ document.addEventListener('contextmenu', onContextMenu);
1051
+
1052
+ /**
1053
+ * Long-press opens the same menu on touch devices, where no right-click
1054
+ * exists: iOS never fires contextmenu for a hold (it shows the link
1055
+ * preview callout instead — suppressed by the plugin stylesheet below),
1056
+ * and Android fires its own contextmenu mid-hold, which the
1057
+ * openContextMenu guard deduplicates. A press that moves beyond the
1058
+ * slop is a scroll gesture and cancels; the release of a fired press is
1059
+ * preventDefault-ed so the browser does not synthesize a click that
1060
+ * would follow the link behind the just-opened menu.
1061
+ */
1062
+ var LONG_PRESS_MS = 500;
1063
+ var LONG_PRESS_SLOP = 10;
1064
+ var press = { timer: null, x: 0, y: 0, link: null, opened: false };
1065
+
1066
+ function cancelPress() {
1067
+ if (press.timer !== null) {
1068
+ clearTimeout(press.timer);
1069
+ press.timer = null;
1070
+ }
1071
+ press.link = null;
1072
+ press.opened = false;
1073
+ }
1074
+
1075
+ function onTouchStart(event) {
1076
+ cancelPress();
1077
+ if (event.touches.length !== 1) return;
1078
+ var touch = event.touches[0];
1079
+ var target = touch.target;
1080
+ if (target === null || target === undefined || typeof target.closest !== 'function') return;
1081
+ var link = classifyContextTarget(target);
1082
+ if (link === null) return;
1083
+ press.x = touch.clientX;
1084
+ press.y = touch.clientY;
1085
+ press.link = link;
1086
+ press.timer = setTimeout(function () {
1087
+ press.timer = null;
1088
+ press.opened = true;
1089
+ openContextMenu(press.x, press.y, press.link);
1090
+ }, LONG_PRESS_MS);
1091
+ }
1092
+
1093
+ function onTouchMove(event) {
1094
+ if (press.timer === null) return;
1095
+ var touch = event.touches[0];
1096
+ if (touch === null || touch === undefined) {
1097
+ cancelPress();
1098
+ return;
1099
+ }
1100
+ if (Math.abs(touch.clientX - press.x) > LONG_PRESS_SLOP
1101
+ || Math.abs(touch.clientY - press.y) > LONG_PRESS_SLOP) {
1102
+ cancelPress();
1103
+ }
1104
+ }
1105
+
1106
+ function onTouchEnd(event) {
1107
+ if (press.opened && event.cancelable !== false) event.preventDefault();
1108
+ cancelPress();
1109
+ }
1110
+
1111
+ document.addEventListener('touchstart', onTouchStart, { passive: true });
1112
+ document.addEventListener('touchmove', onTouchMove, { passive: true });
1113
+ document.addEventListener('touchend', onTouchEnd, { passive: false });
1114
+ document.addEventListener('touchcancel', cancelPress, { passive: true });
1115
+
1116
+ /**
1117
+ * The conversation-flow targets of the context menu must not summon the
1118
+ * iOS link-preview callout or the selection loupe on a hold — the
1119
+ * long-press is this plugin's menu gesture on touch. Inline code keeps
1120
+ * code blocks (pre) selectable; the plugin only menus inline code.
1121
+ */
1122
+ var styleTag = document.createElement('style');
1123
+ styleTag.setAttribute('data-plugin', 'dsh-plugin-file-actions');
1124
+ styleTag.textContent = [
1125
+ // The card menu trigger stands in for the official 0.1.7 compact
1126
+ // control, so it borrows that control's per-half geometry and colors
1127
+ // (24px tall, 9px radius, secondary label, hover fill) rather than a
1128
+ // bare icon box — the official classes are hashed, so the plugin
1129
+ // carries its own. The official pill's 0.5px `--dsw-alias-border-l4`
1130
+ // outline is deliberately not drawn: the plugin's trigger is the
1131
+ // card's single control, not one half of a split pair, and a border
1132
+ // around a lone chevron reads as an empty button.
1133
+ '[data-fa-trigger] {',
1134
+ 'display: inline-flex; align-items: center; justify-content: center;',
1135
+ 'height: 24px; padding: 0 6px; border: 0; border-radius: 9px;',
1136
+ 'background: none; color: var(--dsw-alias-label-secondary); cursor: pointer;',
1137
+ '}',
1138
+ '[data-fa-trigger]:hover, [data-fa-trigger]:focus-visible {',
1139
+ 'background: var(--dsw-alias-interactive-bg-hover);',
1140
+ '}',
1141
+ // The conversation-flow targets of the context menu must not summon
1142
+ // the iOS link-preview callout or the selection loupe on a hold —
1143
+ // the long-press is this plugin's menu gesture on touch. Inline code
1144
+ // keeps code blocks (pre) selectable; the plugin only menus inline
1145
+ // code.
1146
+ '[data-chat-turn] a[href],',
1147
+ '[data-chat-turn] :not(pre) > code {',
1148
+ '-webkit-touch-callout: none;',
1149
+ '-webkit-user-select: none;',
1150
+ 'user-select: none;',
1151
+ '}',
1152
+ ].join('\n');
1153
+ (document.head || document.body).appendChild(styleTag);
1154
+
1155
+ /**
1156
+ * Header utilities cell that publishes the viewed session's workspace
1157
+ * directory for the context menu. Renders nothing: the cell exists for
1158
+ * its standard props (sessionId + useSessions — the same seats the
1159
+ * official open-in-app button consumes). A subagent aside rendering its
1160
+ * own header last would win; aside sessions share the workspace in
1161
+ * practice.
1162
+ */
1163
+ function SessionCwdRecorder(props) {
1164
+ var cwd = props.useSessions(function (sessionState) {
1165
+ var row = props.sessionId === undefined || props.sessionId === null
1166
+ ? undefined : sessionState.byId[props.sessionId];
1167
+ return row === null || row === undefined ? undefined : row.cwd;
1168
+ });
1169
+ React.useEffect(function () {
1170
+ contextState.cwd = cwd === undefined || cwd === '' ? null : cwd;
1171
+ renderContextMenu();
1172
+ }, [cwd]);
1173
+ return null;
1174
+ }
1175
+
1176
+ // The official session-header utilities seat: a null cell registered for
1177
+ // its props. ctx.slots.inject runs the callback per declaration lifetime
1178
+ // and unwinds the registration when this plugin's fiber unloads — no
1179
+ // manual disposer (the official open-in-app registers the same way).
1180
+ ctx.slots.inject('conversation.session.header.utilities', function () {
1181
+ return ctx.slots.register({
1182
+ name: 'conversation.session.header.utilities',
1183
+ id: 'file-actions',
1184
+ order: 100,
1185
+ locale: NS,
1186
+ }, SessionCwdRecorder);
1187
+ });
1188
+
1189
+ var appsTask = fetchJson('/open-in-app/apps').then(function (result) {
1190
+ if (result.ok && result.data !== null && Array.isArray(result.data.apps)) {
1191
+ state.officialApps = result.data.apps;
1192
+ notifyState();
1193
+ renderContextMenu();
1194
+ }
1195
+ });
1196
+ var infoTask = fetchJson('/api/file-actions/info').then(function (result) {
1197
+ if (result.ok && result.data !== null && typeof result.data === 'object') {
1198
+ state.info = result.data;
1199
+ notifyState();
1200
+ renderContextMenu();
1201
+ }
1202
+ });
1203
+ void appsTask;
1204
+ void infoTask;
1205
+
1206
+ return async function dispose() {
1207
+ document.removeEventListener('contextmenu', onContextMenu);
1208
+ document.removeEventListener('touchstart', onTouchStart);
1209
+ document.removeEventListener('touchmove', onTouchMove);
1210
+ document.removeEventListener('touchend', onTouchEnd);
1211
+ document.removeEventListener('touchcancel', cancelPress);
1212
+ styleTag.remove();
1213
+ contextRoot.unmount();
1214
+ contextContainer.remove();
1215
+ };
1216
+ }
1217
+
1218
+ exports.inject = ['locale', 'slots', 'remote', 'remote.directoryPicker'];
1219
+ exports.apply = apply;
1220
+ return module.exports;
1221
+ },
1222
+ });