dsh-multi-folder 0.3.1 → 0.3.3
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/README.md +24 -5
- package/README.zh.md +24 -5
- package/SECURITY.md +21 -19
- package/docs/design.md +184 -29
- package/lib/client.js +290 -32
- package/lib/index.js +654 -30
- package/lib/native-picker.ps1 +290 -0
- package/package.json +5 -2
package/lib/client.js
CHANGED
|
@@ -27,17 +27,25 @@
|
|
|
27
27
|
* (`ctx.connection.rpc.call('/api', endpoint, { args })`), keyed by workspace
|
|
28
28
|
* path instead of sessionId.
|
|
29
29
|
*
|
|
30
|
-
* "Add directory"
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
30
|
+
* "Add directory" calls `multiFolder/pick`. A `native` host picker may fall back
|
|
31
|
+
* to an OS dialog; a `browse` capability opens this plugin's browser through
|
|
32
|
+
* `multiFolder/browse` and `multiFolder/makeDir`. Closing the panel cancels a
|
|
33
|
+
* pending pick. Dismissing a system dialog adds nothing.
|
|
34
|
+
*
|
|
35
|
+
* Every configured row also carries "open in the host file manager"
|
|
36
|
+
* (`multiFolder/reveal`), which puts the directory in Explorer/Finder where the
|
|
37
|
+
* user's own shell integrations live.
|
|
38
|
+
*
|
|
39
|
+
* `@` discovery for the configured directories. The shipped `@` menu is
|
|
40
|
+
* SINGLE-ROOT: `ui-reference` feeds it from `remote.fileReferences.list`, whose
|
|
41
|
+
* provider walks the addressed session's cwd and refuses every candidate
|
|
42
|
+
* outside that root, so a secondary directory can never appear there. This
|
|
43
|
+
* bundle therefore registers a COMPANION source on the same trigger (see "@
|
|
44
|
+
* discovery for secondaries" in `apply`) and answers it from
|
|
45
|
+
* `multiFolder/listFiles`. Sources are keyed by `(trigger, name)` and rendered
|
|
46
|
+
* one group each, so the shipped files/sessions group is untouched. The
|
|
47
|
+
* registration rides `ctx.inject` — a shell composing no input-trigger service
|
|
48
|
+
* keeps every panel surface and simply gains no `@` group.
|
|
41
49
|
*
|
|
42
50
|
* Layout shape: an entry in the session header action row, one chip row above
|
|
43
51
|
* the composer card on the session-creation page, and a frame-wide overlay
|
|
@@ -96,6 +104,10 @@ window.__ModuleLoader__.load({
|
|
|
96
104
|
browseDraft: '',
|
|
97
105
|
/** "new folder" name draft. */
|
|
98
106
|
newDirName: '',
|
|
107
|
+
/** A system pick is in flight. */
|
|
108
|
+
pickBusy: false,
|
|
109
|
+
/** Muted reason shown when the system picker FAILED and the browser took over. */
|
|
110
|
+
notice: null,
|
|
99
111
|
/** Bumped on every workspaceCache write so chips re-read their count. */
|
|
100
112
|
cacheRev: 0,
|
|
101
113
|
};
|
|
@@ -221,6 +233,10 @@ window.__ModuleLoader__.load({
|
|
|
221
233
|
'browse.newDirPlaceholder': '新建文件夹名称',
|
|
222
234
|
'browse.newDirCreate': '创建',
|
|
223
235
|
'browse.hint': '浏览宿主机文件系统;选中的目录将作为副工作目录添加。',
|
|
236
|
+
'panel.addBusy': '等待系统对话框…',
|
|
237
|
+
'panel.addBrowse': '改用内置浏览器选择…',
|
|
238
|
+
'panel.reveal': '在资源管理器中打开(宿主机)',
|
|
239
|
+
'panel.pickFailed': '系统目录选择器调用失败,已改用内置浏览器:{message}',
|
|
224
240
|
};
|
|
225
241
|
/** English dictionary — checked complete against the zh key set. */
|
|
226
242
|
var enDict = {
|
|
@@ -258,6 +274,10 @@ window.__ModuleLoader__.load({
|
|
|
258
274
|
'browse.newDirPlaceholder': 'New folder name',
|
|
259
275
|
'browse.newDirCreate': 'Create',
|
|
260
276
|
'browse.hint': 'Browses the host filesystem; the chosen directory is added as a secondary working directory.',
|
|
277
|
+
'panel.addBusy': 'Waiting for the system dialog…',
|
|
278
|
+
'panel.addBrowse': 'Use the built-in browser instead…',
|
|
279
|
+
'panel.reveal': 'Open in the host file manager',
|
|
280
|
+
'panel.pickFailed': 'The system picker failed; using the built-in browser: {message}',
|
|
261
281
|
};
|
|
262
282
|
|
|
263
283
|
// ------------------------------------------------------------- plugin
|
|
@@ -286,16 +306,37 @@ window.__ModuleLoader__.load({
|
|
|
286
306
|
* the `t` seat the renderer synthesizes from the declared
|
|
287
307
|
* `locale:` namespace (which also re-renders them on locale switch). */
|
|
288
308
|
var t = locale.bind(NS);
|
|
309
|
+
var activePick = null;
|
|
310
|
+
|
|
311
|
+
function cancelPick() {
|
|
312
|
+
if (!activePick) return;
|
|
313
|
+
activePick.abort();
|
|
314
|
+
activePick = null;
|
|
315
|
+
patch({ pickBusy: false });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function closePanel() {
|
|
319
|
+
cancelPick();
|
|
320
|
+
patch({ open: false });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
ctx.effect(function () {
|
|
324
|
+
return function () {
|
|
325
|
+
var controller = activePick;
|
|
326
|
+
activePick = null;
|
|
327
|
+
if (controller) controller.abort();
|
|
328
|
+
};
|
|
329
|
+
}, 'dsh-multi-folder: cancel picker on unload');
|
|
289
330
|
|
|
290
331
|
// -------------------------------------------------- sessionless RPC
|
|
291
332
|
/** Call one `multiFolder/*` endpoint over the shared /api channel.
|
|
292
333
|
* The Host gateway answers with the same `{ ok, value }` envelope as
|
|
293
334
|
* the command remote; business errors surface as thrown Errors. */
|
|
294
|
-
function remoteCall(endpoint, args) {
|
|
335
|
+
function remoteCall(endpoint, args, signal) {
|
|
295
336
|
if (!connection || !connection.rpc || typeof connection.rpc.call !== 'function') {
|
|
296
337
|
return Promise.reject(new Error('multi-folder: the shared RPC channel (connection service) is unavailable'));
|
|
297
338
|
}
|
|
298
|
-
return connection.rpc.call('/api', endpoint, { args: args }).then(function (envelope) {
|
|
339
|
+
return connection.rpc.call('/api', endpoint, { args: args }, signal).then(function (envelope) {
|
|
299
340
|
if (!envelope || envelope.ok !== true) {
|
|
300
341
|
var message = envelope && envelope.error !== undefined
|
|
301
342
|
? String(envelope.error.message !== undefined ? envelope.error.message : envelope.error)
|
|
@@ -371,6 +412,7 @@ window.__ModuleLoader__.load({
|
|
|
371
412
|
* renders it as its own popover, everything else uses the overlay. */
|
|
372
413
|
function openForWorkspace(workspacePath, anchor) {
|
|
373
414
|
var seat = anchor || 'overlay';
|
|
415
|
+
cancelPick();
|
|
374
416
|
patch(browserReset());
|
|
375
417
|
if (!workspacePath) {
|
|
376
418
|
patch({ open: true, mode: 'workspace', sessionId: null, workspace: null, dirs: [], error: null, anchor: seat });
|
|
@@ -447,11 +489,8 @@ window.__ModuleLoader__.load({
|
|
|
447
489
|
}
|
|
448
490
|
|
|
449
491
|
// -------------------------------------------- owned directory browser
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
// through `multiFolder/makeDir`, so the interaction never depends on the
|
|
453
|
-
// host's composed directory picker — the native chooser, the browse
|
|
454
|
-
// backend and a shell with no picker at all all behave the same here.
|
|
492
|
+
// The owned browser reads levels over `multiFolder/browse` and creates
|
|
493
|
+
// directories through `multiFolder/makeDir`.
|
|
455
494
|
|
|
456
495
|
/** Read one level into the store. A blank path asks the host for its
|
|
457
496
|
* home directory, so the browser always has somewhere to start. */
|
|
@@ -475,9 +514,11 @@ window.__ModuleLoader__.load({
|
|
|
475
514
|
});
|
|
476
515
|
}
|
|
477
516
|
|
|
478
|
-
/** Open the browser at the panel's workspace (or the host home when
|
|
479
|
-
* panel has no workspace path yet) and read its first level.
|
|
480
|
-
|
|
517
|
+
/** Open the OWNED browser at the panel's workspace (or the host home when
|
|
518
|
+
* the panel has no workspace path yet) and read its first level. Reachable
|
|
519
|
+
* from the panel at all times, and the automatic destination whenever the
|
|
520
|
+
* system picker cannot answer. */
|
|
521
|
+
function openOwnedBrowser() {
|
|
481
522
|
var snapshot = getSnapshot();
|
|
482
523
|
patch({
|
|
483
524
|
browseOpen: true,
|
|
@@ -490,6 +531,35 @@ window.__ModuleLoader__.load({
|
|
|
490
531
|
browseTo(snapshot.workspace || '');
|
|
491
532
|
}
|
|
492
533
|
|
|
534
|
+
/** Pick a directory, or open the owned browser when no system picker answers. */
|
|
535
|
+
function addDirectory() {
|
|
536
|
+
var snapshot = getSnapshot();
|
|
537
|
+
if (snapshot.pickBusy) return Promise.resolve();
|
|
538
|
+
var controller = new AbortController();
|
|
539
|
+
activePick = controller;
|
|
540
|
+
patch({ pickBusy: true, error: null, notice: null });
|
|
541
|
+
return remoteCall('multiFolder/pick', { cwd: snapshot.workspace || '' }, controller.signal).then(
|
|
542
|
+
function (value) {
|
|
543
|
+
if (activePick !== controller) return undefined;
|
|
544
|
+
activePick = null;
|
|
545
|
+
patch({ pickBusy: false });
|
|
546
|
+
var picked = value && typeof value.path === 'string' && value.path !== '' ? value.path : null;
|
|
547
|
+
if (picked) return commitBrowsePath(picked);
|
|
548
|
+
if (value && (value.via === 'host-native' || value.via === 'os-dialog')) return undefined;
|
|
549
|
+
return openOwnedBrowser();
|
|
550
|
+
},
|
|
551
|
+
function (error) {
|
|
552
|
+
if (activePick !== controller) return undefined;
|
|
553
|
+
activePick = null;
|
|
554
|
+
patch({
|
|
555
|
+
pickBusy: false,
|
|
556
|
+
notice: t('panel.pickFailed', { message: String(error && error.message ? error.message : error) }),
|
|
557
|
+
});
|
|
558
|
+
return openOwnedBrowser();
|
|
559
|
+
},
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
493
563
|
/** Leave the browser without adding anything. */
|
|
494
564
|
function closeBrowser() {
|
|
495
565
|
patch(browserReset());
|
|
@@ -499,7 +569,7 @@ window.__ModuleLoader__.load({
|
|
|
499
569
|
* fresh open of the panel (header button, chip, launcher) starts there,
|
|
500
570
|
* so a browser left open by a dismissed panel never comes back stale. */
|
|
501
571
|
function browserReset() {
|
|
502
|
-
return { browseOpen: false, browse: null, browseBusy: false, browseError: null, newDirName: '' };
|
|
572
|
+
return { browseOpen: false, browse: null, browseBusy: false, browseError: null, newDirName: '', notice: null };
|
|
503
573
|
}
|
|
504
574
|
|
|
505
575
|
/** Adopt one path as a secondary working directory through the channel
|
|
@@ -542,6 +612,7 @@ window.__ModuleLoader__.load({
|
|
|
542
612
|
function openFor(sessionId) {
|
|
543
613
|
if (!sessionId) return;
|
|
544
614
|
var cached = sessionCache[sessionId];
|
|
615
|
+
cancelPick();
|
|
545
616
|
patch(browserReset());
|
|
546
617
|
patch({
|
|
547
618
|
open: true,
|
|
@@ -583,7 +654,7 @@ window.__ModuleLoader__.load({
|
|
|
583
654
|
var current = getSnapshot();
|
|
584
655
|
var isOpen = current.open && current.sessionId === sessionId;
|
|
585
656
|
if (isOpen) {
|
|
586
|
-
|
|
657
|
+
closePanel();
|
|
587
658
|
} else {
|
|
588
659
|
openFor(sessionId);
|
|
589
660
|
}
|
|
@@ -677,7 +748,7 @@ window.__ModuleLoader__.load({
|
|
|
677
748
|
{
|
|
678
749
|
type: 'button',
|
|
679
750
|
title: t('title.close'),
|
|
680
|
-
onClick: function () {
|
|
751
|
+
onClick: function () { closePanel(); },
|
|
681
752
|
style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: TOKEN.inkMuted, cursor: 'pointer' },
|
|
682
753
|
},
|
|
683
754
|
'✕',
|
|
@@ -742,6 +813,9 @@ window.__ModuleLoader__.load({
|
|
|
742
813
|
store.browseError
|
|
743
814
|
? React.createElement('div', { style: { marginBottom: 8, color: TOKEN.danger, whiteSpace: 'pre-wrap', fontSize: 12 } }, String(store.browseError))
|
|
744
815
|
: null,
|
|
816
|
+
store.notice
|
|
817
|
+
? React.createElement('div', { style: { marginBottom: 8, color: TOKEN.inkMuted, whiteSpace: 'pre-wrap', fontSize: 11 } }, String(store.notice))
|
|
818
|
+
: null,
|
|
745
819
|
React.createElement(
|
|
746
820
|
'div',
|
|
747
821
|
{ style: { maxHeight: 200, overflowY: 'auto', marginBottom: 6 } },
|
|
@@ -824,6 +898,22 @@ window.__ModuleLoader__.load({
|
|
|
824
898
|
},
|
|
825
899
|
dir,
|
|
826
900
|
),
|
|
901
|
+
React.createElement(
|
|
902
|
+
'button',
|
|
903
|
+
{
|
|
904
|
+
type: 'button',
|
|
905
|
+
title: t('panel.reveal'),
|
|
906
|
+
disabled: !usable,
|
|
907
|
+
onClick: function () {
|
|
908
|
+
patch({ error: null });
|
|
909
|
+
remoteCall('multiFolder/reveal', { path: dir }).catch(function (e) {
|
|
910
|
+
patch({ error: String(e && e.message ? e.message : e) });
|
|
911
|
+
});
|
|
912
|
+
},
|
|
913
|
+
style: { padding: '2px 6px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: TOKEN.inkMuted, cursor: 'pointer' },
|
|
914
|
+
},
|
|
915
|
+
'📂',
|
|
916
|
+
),
|
|
827
917
|
React.createElement(
|
|
828
918
|
'button',
|
|
829
919
|
{
|
|
@@ -853,7 +943,7 @@ window.__ModuleLoader__.load({
|
|
|
853
943
|
{
|
|
854
944
|
type: 'button',
|
|
855
945
|
title: t('title.close'),
|
|
856
|
-
onClick: function () {
|
|
946
|
+
onClick: function () { closePanel(); },
|
|
857
947
|
style: { padding: '2px 8px', borderRadius: 6, border: '1px solid transparent', background: 'transparent', color: TOKEN.inkMuted, cursor: 'pointer' },
|
|
858
948
|
},
|
|
859
949
|
'✕',
|
|
@@ -885,7 +975,7 @@ window.__ModuleLoader__.load({
|
|
|
885
975
|
'button',
|
|
886
976
|
{
|
|
887
977
|
type: 'button',
|
|
888
|
-
disabled: !!store.busy || !usable,
|
|
978
|
+
disabled: !!store.busy || !!store.pickBusy || !usable,
|
|
889
979
|
onClick: function () { addDirectory(); },
|
|
890
980
|
style: {
|
|
891
981
|
flex: 1,
|
|
@@ -894,11 +984,11 @@ window.__ModuleLoader__.load({
|
|
|
894
984
|
border: '1px solid ' + TOKEN.border,
|
|
895
985
|
background: TOKEN.fill,
|
|
896
986
|
color: TOKEN.ink,
|
|
897
|
-
cursor: store.busy || !usable ? 'default' : 'pointer',
|
|
898
|
-
opacity: store.busy || !usable ? 0.6 : 1,
|
|
987
|
+
cursor: store.busy || store.pickBusy || !usable ? 'default' : 'pointer',
|
|
988
|
+
opacity: store.busy || store.pickBusy || !usable ? 0.6 : 1,
|
|
899
989
|
},
|
|
900
990
|
},
|
|
901
|
-
store.busy ? t('panel.adding') : t('panel.add'),
|
|
991
|
+
store.busy ? t('panel.adding') : (store.pickBusy ? t('panel.addBusy') : t('panel.add')),
|
|
902
992
|
),
|
|
903
993
|
React.createElement(
|
|
904
994
|
'button',
|
|
@@ -925,6 +1015,26 @@ window.__ModuleLoader__.load({
|
|
|
925
1015
|
t('panel.refresh'),
|
|
926
1016
|
),
|
|
927
1017
|
),
|
|
1018
|
+
React.createElement(
|
|
1019
|
+
'button',
|
|
1020
|
+
{
|
|
1021
|
+
type: 'button',
|
|
1022
|
+
disabled: !!store.busy || !!store.pickBusy || !usable,
|
|
1023
|
+
onClick: function () { openOwnedBrowser(); },
|
|
1024
|
+
style: {
|
|
1025
|
+
marginBottom: 6,
|
|
1026
|
+
padding: 0,
|
|
1027
|
+
border: '1px solid transparent',
|
|
1028
|
+
background: 'transparent',
|
|
1029
|
+
color: TOKEN.inkMuted,
|
|
1030
|
+
fontSize: 11,
|
|
1031
|
+
textAlign: 'left',
|
|
1032
|
+
cursor: store.busy || store.pickBusy || !usable ? 'default' : 'pointer',
|
|
1033
|
+
opacity: store.busy || store.pickBusy || !usable ? 0.5 : 1,
|
|
1034
|
+
},
|
|
1035
|
+
},
|
|
1036
|
+
t('panel.addBrowse'),
|
|
1037
|
+
),
|
|
928
1038
|
React.createElement(
|
|
929
1039
|
'div',
|
|
930
1040
|
{
|
|
@@ -1019,7 +1129,7 @@ window.__ModuleLoader__.load({
|
|
|
1019
1129
|
/** Click-outside catcher for an open popover. */
|
|
1020
1130
|
function Backdrop() {
|
|
1021
1131
|
return React.createElement('div', {
|
|
1022
|
-
onClick: function () {
|
|
1132
|
+
onClick: function () { closePanel(); },
|
|
1023
1133
|
style: { position: 'fixed', inset: 0, zIndex: 30 },
|
|
1024
1134
|
});
|
|
1025
1135
|
}
|
|
@@ -1190,7 +1300,7 @@ window.__ModuleLoader__.load({
|
|
|
1190
1300
|
label: count !== null && count > 0 ? t('label.withCount', { count: count }) : t('label'),
|
|
1191
1301
|
onClick: function () {
|
|
1192
1302
|
if (open) {
|
|
1193
|
-
|
|
1303
|
+
closePanel();
|
|
1194
1304
|
} else {
|
|
1195
1305
|
openForWorkspace(workspacePath, 'dock');
|
|
1196
1306
|
}
|
|
@@ -1304,7 +1414,7 @@ window.__ModuleLoader__.load({
|
|
|
1304
1414
|
label: count !== null && count > 0 ? t('label.withCount', { count: count }) : t('label'),
|
|
1305
1415
|
onClick: function () {
|
|
1306
1416
|
if (open) {
|
|
1307
|
-
|
|
1417
|
+
closePanel();
|
|
1308
1418
|
} else {
|
|
1309
1419
|
openForWorkspace(workspacePath, 'extras');
|
|
1310
1420
|
}
|
|
@@ -1361,6 +1471,154 @@ window.__ModuleLoader__.load({
|
|
|
1361
1471
|
release();
|
|
1362
1472
|
};
|
|
1363
1473
|
});
|
|
1474
|
+
|
|
1475
|
+
// ------------------------------------- @ discovery for secondaries
|
|
1476
|
+
// The shipped `@` menu is SINGLE-ROOT: `ui-reference` feeds it from
|
|
1477
|
+
// `remote.fileReferences.list`, whose provider walks the addressed
|
|
1478
|
+
// session's cwd and refuses any candidate outside that root. A configured
|
|
1479
|
+
// secondary directory is by definition outside it, so this plugin
|
|
1480
|
+
// registers a COMPANION source on the same trigger and answers it from
|
|
1481
|
+
// `multiFolder/listFiles`.
|
|
1482
|
+
//
|
|
1483
|
+
// The trigger registry keys sources by `(trigger, name)` and renders one
|
|
1484
|
+
// group per source, so the shipped files/sessions group is untouched and
|
|
1485
|
+
// this group appears beside it (order 10 = below the shipped group, which
|
|
1486
|
+
// defaults to 0). `showGroupTitle: false` is deliberate: the menu derives
|
|
1487
|
+
// a group's title by looking its SOURCE NAME up in its own dictionary, so
|
|
1488
|
+
// a visible title would read "multi-folder", and a group whose query found
|
|
1489
|
+
// nothing would otherwise render that heading over an empty list. Every
|
|
1490
|
+
// row instead carries a `section`, which heads the rows by directory.
|
|
1491
|
+
//
|
|
1492
|
+
// `ctx.inject` rather than a hard `inject` entry: a shell that composes
|
|
1493
|
+
// no input-trigger service must keep its panel, and on such a shell this
|
|
1494
|
+
// contribution simply never activates.
|
|
1495
|
+
|
|
1496
|
+
/** Last path segment, tolerant of either separator. */
|
|
1497
|
+
function lastSegment(path) {
|
|
1498
|
+
var parts = String(path).replace(/[\\/]+$/, '').split(/[\\/]/);
|
|
1499
|
+
return parts.length === 0 ? '' : parts[parts.length - 1];
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/** Drop the host home prefix from a heading when the path sits under it. */
|
|
1503
|
+
function abbreviate(path) {
|
|
1504
|
+
var home = remote && remote.$host ? remote.$host.home : undefined;
|
|
1505
|
+
if (!home) return path;
|
|
1506
|
+
var flat = String(path).replace(/\\/g, '/');
|
|
1507
|
+
var prefix = String(home).replace(/\\/g, '/');
|
|
1508
|
+
if (flat.toLowerCase().indexOf(prefix.toLowerCase()) !== 0) return path;
|
|
1509
|
+
return '~' + flat.slice(prefix.length);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/** Mention text for one discovered path. Mirrors the shipped
|
|
1513
|
+
* `formatFileMention` grammar — whitespace quotes the path, a quoted
|
|
1514
|
+
* directory keeps its quote open so completion can descend, and a path
|
|
1515
|
+
* with control characters or an embedded quote is unrepresentable.
|
|
1516
|
+
* Reimplemented here because this bundle is standalone (no build step)
|
|
1517
|
+
* and cannot import that package's module. */
|
|
1518
|
+
function formatMention(path, kind, preserveQuote) {
|
|
1519
|
+
var text = kind === 'directory' ? path + '/' : path;
|
|
1520
|
+
if (/[\u0000-\u001f\u007f-\u009f"]/.test(text)) return undefined;
|
|
1521
|
+
if (!preserveQuote && !/\s/.test(text)) return '@' + text;
|
|
1522
|
+
return kind === 'directory' ? '@"' + text : '@"' + text + '"';
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/** The source-owned payload one menu row carries. */
|
|
1526
|
+
function rowValue(value) {
|
|
1527
|
+
if (typeof value !== 'string') return undefined;
|
|
1528
|
+
try {
|
|
1529
|
+
var parsed = JSON.parse(value);
|
|
1530
|
+
return parsed && typeof parsed.mention === 'string' ? parsed : undefined;
|
|
1531
|
+
} catch (e) {
|
|
1532
|
+
return undefined;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/** Project one host candidate onto a menu row (or nothing when the path
|
|
1537
|
+
* cannot be written back as mention text). */
|
|
1538
|
+
function fileRow(candidate, preserveQuote, heading) {
|
|
1539
|
+
var path = String(candidate.path || '').replace(/\\/g, '/');
|
|
1540
|
+
var kind = candidate.kind === 'directory' ? 'directory' : 'file';
|
|
1541
|
+
var mention = formatMention(path, kind, preserveQuote);
|
|
1542
|
+
if (mention === undefined) return [];
|
|
1543
|
+
var rel = String(candidate.rel || '');
|
|
1544
|
+
var name = rel === '' ? lastSegment(path) : lastSegment(rel);
|
|
1545
|
+
var slash = rel.lastIndexOf('/');
|
|
1546
|
+
var parent = slash < 0 ? '' : rel.slice(0, slash);
|
|
1547
|
+
return [{
|
|
1548
|
+
name: kind === 'directory' ? name + '/' : name,
|
|
1549
|
+
// The in-directory location only: the heading above the row already
|
|
1550
|
+
// names the directory, so repeating it there says nothing.
|
|
1551
|
+
...(parent === '' ? {} : { description: parent }),
|
|
1552
|
+
icon: kind === 'directory' ? 'folder' : 'file',
|
|
1553
|
+
section: heading,
|
|
1554
|
+
value: JSON.stringify({ kind: kind, path: path, mention: mention }),
|
|
1555
|
+
...(kind === 'directory' ? { drill: true } : {}),
|
|
1556
|
+
}];
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
ctx.inject(['inputTriggers'], function (scope) {
|
|
1560
|
+
var registry = scope.inputTriggers;
|
|
1561
|
+
if (!registry || typeof registry.registerSource !== 'function') return;
|
|
1562
|
+
scope.effect(function () {
|
|
1563
|
+
return registry.registerSource({
|
|
1564
|
+
trigger: '@',
|
|
1565
|
+
name: NS,
|
|
1566
|
+
order: 10,
|
|
1567
|
+
// The menu titles a group from its source name via its OWN
|
|
1568
|
+
// dictionary (an unknown key renders verbatim), so the title row is
|
|
1569
|
+
// switched off: rows are headed by their directory section instead,
|
|
1570
|
+
// and an empty result renders nothing at all.
|
|
1571
|
+
showGroupTitle: false,
|
|
1572
|
+
candidates: function (session, request) {
|
|
1573
|
+
var summary = sessions.list.getSnapshot().byId[session.sessionId];
|
|
1574
|
+
var workspace = summary && summary.cwd;
|
|
1575
|
+
if (!workspace) return Promise.resolve([]);
|
|
1576
|
+
var preserveQuote = request.quoted === true;
|
|
1577
|
+
return remoteCall('multiFolder/listFiles', { workspace: workspace, query: request.query }).then(
|
|
1578
|
+
function (value) {
|
|
1579
|
+
var items = value && Array.isArray(value.candidates) ? value.candidates : [];
|
|
1580
|
+
var rows = [];
|
|
1581
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
1582
|
+
var item = items[i];
|
|
1583
|
+
rows = rows.concat(fileRow(item, preserveQuote, abbreviate(String(item.dir || ''))));
|
|
1584
|
+
}
|
|
1585
|
+
return rows;
|
|
1586
|
+
},
|
|
1587
|
+
// Autocomplete is advisory: a discovery failure is an empty
|
|
1588
|
+
// group, never a broken composer and never a failed send.
|
|
1589
|
+
function () { return []; },
|
|
1590
|
+
);
|
|
1591
|
+
},
|
|
1592
|
+
onPick: function (pick) {
|
|
1593
|
+
var chosen = rowValue(pick.candidate.value);
|
|
1594
|
+
if (chosen === undefined) return undefined;
|
|
1595
|
+
var name = lastSegment(chosen.path);
|
|
1596
|
+
// Tab / the row chevron refines the query in place instead of
|
|
1597
|
+
// settling the pick, so a directory descends one level: the
|
|
1598
|
+
// mention text becomes the live query, and the host resolves the
|
|
1599
|
+
// absolute spelling it just produced.
|
|
1600
|
+
if (chosen.kind === 'directory' && pick.action === 'drill') {
|
|
1601
|
+
return { text: chosen.mention, continue: true };
|
|
1602
|
+
}
|
|
1603
|
+
return {
|
|
1604
|
+
insert: {
|
|
1605
|
+
source: NS,
|
|
1606
|
+
ref: chosen.mention,
|
|
1607
|
+
label: chosen.kind === 'directory' ? name + '/' : name,
|
|
1608
|
+
appearance: chosen.kind === 'directory' ? 'folder' : 'file',
|
|
1609
|
+
clipboardText: chosen.mention,
|
|
1610
|
+
},
|
|
1611
|
+
};
|
|
1612
|
+
},
|
|
1613
|
+
// The mention IS the model form (an `@path` token the prompt
|
|
1614
|
+
// grammar already defines), so serialization is the identity.
|
|
1615
|
+
codec: {
|
|
1616
|
+
clipboardText: function (ref) { return ref; },
|
|
1617
|
+
serialize: function (ref) { return Promise.resolve(ref); },
|
|
1618
|
+
},
|
|
1619
|
+
});
|
|
1620
|
+
}, 'dsh-multi-folder: @ discovery source');
|
|
1621
|
+
});
|
|
1364
1622
|
}
|
|
1365
1623
|
|
|
1366
1624
|
exports.name = name;
|