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/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" never asks the host for a picker: the plugin draws its own
31
- * browser (an editable path field, one level of child directories, an optional
32
- * "new folder") on the `multiFolder/browse` + `multiFolder/makeDir` endpoints
33
- * over the shared RPC channel. One interaction serves every deployment, which
34
- * the host's own picker cannot: `uiWorkspace.pickDirectory()` is native-only
35
- * (it answers `directory-picker/unavailable` whenever the host composed the
36
- * browse backend — a LAN bind, a remote client, a desktop shell), while its
37
- * `listDirectory`/`createDirectory` twins are refused under the native
38
- * composition. Listing this way also stays clear of the DSH Windows native
39
- * chooser worker, whose crash surfaces as `directory picker failed: … worker
40
- * exited …`.
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
- // "Add directory" is served entirely by this plugin (host item 8): the
451
- // browser reads levels over `multiFolder/browse` and may create one child
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 the
479
- * panel has no workspace path yet) and read its first level. */
480
- function addDirectory() {
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
- patch({ open: false });
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 () { closeBrowser(); patch({ open: false }); },
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 () { patch({ open: false }); },
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 () { patch({ open: false }); },
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
- patch({ open: false });
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
- patch({ open: false });
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;