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/LICENSE +21 -0
- package/README.en-US.md +444 -0
- package/README.md +226 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +1222 -0
- package/lib/index.js +753 -0
- package/package.json +40 -0
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
|
+
});
|