dsh-chat-link-actions 0.2.4 → 0.2.6

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.
Files changed (4) hide show
  1. package/README.md +1 -1
  2. package/client.js +285 -62
  3. package/index.js +151 -32
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  ## 认哪些链接
16
16
 
17
- 右键不是只认一种写法,常见渲染都走同一条解析:`href` / `src` 里的 `?path=`(图片、附件卡片)、`data-path` 之类的绝对路径属性、`title` 里的路径(宿主自绘的文件链接、文件夹链接)、以及直接写在正文里的绝对路径(光标落在路径上才算)。只显示「图标 + 文件名」、DOM 里没有路径的芯片(图片链接、Markdown 图片)也能认:路径从渲染它的组件属性里取。Windows 的 `C:\...` 与含空格、中文的路径同样能认。
17
+ 右键不是只认一种写法,常见渲染都走同一条解析:`href` / `src` 里的 `?path=`(图片、附件卡片)、`data-path` 之类的绝对路径属性、`title` 里的路径(宿主自绘的文件链接、文件夹链接)、以及直接写在正文里的绝对路径(光标落在路径上才算)。只显示「图标 + 文件名」、DOM 里没有路径的芯片(图片链接、Markdown 图片)也能认:路径从渲染它的组件属性里取。Windows 的 `C:\...`、UNC 共享路径、含空格与中文的路径同样能认;正文里带空格的路径整条引用,芯片写作 `@"…"`。目录链接插入后是文件夹芯片(名字以 `/` 收尾),即使链接末尾没有斜杠。
18
18
 
19
19
  ## 装
20
20
 
package/client.js CHANGED
@@ -88,6 +88,16 @@ window.__ModuleLoader__.load({
88
88
  return path.charAt(path.length - 1) === "/" || path.charAt(path.length - 1) === "\\";
89
89
  }
90
90
 
91
+ /** 问不到会话时只好按形状猜:末尾没有扩展名,多半是目录。 */
92
+ function guessDirectory(path) {
93
+ if (looksLikeDirectory(path)) return true;
94
+ var trimmed = path.replace(/[\\/]+$/, "");
95
+ var cut = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
96
+ var name = cut === -1 ? trimmed : trimmed.slice(cut + 1);
97
+ if (name.charAt(0) === ".") return true;
98
+ return !/\.[A-Za-z0-9]{1,8}$/.test(name);
99
+ }
100
+
91
101
  /** Last path segment, the same label the composer's own reference chips show. */
92
102
  function displayLabel(path, directory) {
93
103
  var trimmed = path.replace(/[\\/]+$/, "");
@@ -103,7 +113,7 @@ window.__ModuleLoader__.load({
103
113
  * The shell owns that insertion behind a session-scoped event; when the event
104
114
  * is not reachable the caller falls back to plain `insertText`.
105
115
  */
106
- function insertChip(sessionId, actions, path) {
116
+ function insertChip(sessionId, actions, path, knownDirectory) {
107
117
  if (rootContext === null || typeof rootContext.sessions !== "object" || rootContext.sessions === null) return false;
108
118
  var binding = null;
109
119
  try {
@@ -115,7 +125,7 @@ window.__ModuleLoader__.load({
115
125
  if (sessionContext === null || sessionContext === undefined || typeof sessionContext.emit !== "function") return false;
116
126
  var before = actions.captureInsertion();
117
127
  if (before === null || before === undefined) return false;
118
- var directory = looksLikeDirectory(path);
128
+ var directory = typeof knownDirectory === "boolean" ? knownDirectory : looksLikeDirectory(path);
119
129
  var mention = referenceText(path);
120
130
  var reference = {};
121
131
  reference.source = "reference";
@@ -139,14 +149,14 @@ window.__ModuleLoader__.load({
139
149
  }
140
150
 
141
151
  /** Insert the reference into a live composer. Returns true on success. */
142
- function quoteIntoComposer(path) {
152
+ function quoteIntoComposer(path, knownDirectory) {
143
153
  var candidates = draftCandidates();
144
154
  for (var i = 0; i < candidates.length; i += 1) {
145
155
  var sessionId = candidates[i].sessionId;
146
156
  var actions = candidates[i].actions;
147
157
  try {
148
158
  if (typeof actions.captureInsertion !== "function") continue;
149
- if (insertChip(sessionId, actions, path)) return true;
159
+ if (insertChip(sessionId, actions, path, knownDirectory)) return true;
150
160
  var span = actions.captureInsertion();
151
161
  if (span === null || span === undefined) continue;
152
162
  if (actions.insertText(referenceText(path) + " ", span) === true) return true;
@@ -169,14 +179,29 @@ window.__ModuleLoader__.load({
169
179
  candidate = candidate.slice(1, -1);
170
180
  }
171
181
  candidate = candidate.replace(/[.,;:!?,。;:!?]+$/u, "");
172
- candidate = candidate.replace(/^file:\/\//, "/");
182
+ if (/^file:\/\//i.test(candidate)) {
183
+ candidate = candidate.replace(/^file:\/\/(?:localhost)?/i, "");
184
+ candidate = decodePercent(candidate);
185
+ if (/^\/[A-Za-z]:[\\/]/.test(candidate)) candidate = candidate.slice(1);
186
+ }
173
187
  if (/^[A-Za-z]:(?!\/)/.test(candidate)) candidate = candidate.slice(0, 2) + "/" + candidate.slice(2);
174
- candidate = candidate.replace(/^([A-Za-z]):\//, "$1:/");
175
188
  return looksAbsolute(candidate) ? candidate : null;
176
189
  }
177
190
 
178
191
  function looksAbsolute(value) {
179
- return typeof value === "string" && (value.charAt(0) === "/" || /^[A-Za-z]:[\\/]/.test(value));
192
+ if (typeof value !== "string" || value === "") return false;
193
+ if (value.charAt(0) === "/") return true;
194
+ if (/^[A-Za-z]:[\\/]/.test(value)) return true;
195
+ return /^\\\\[^\\]+\\[^\\]+/.test(value);
196
+ }
197
+
198
+ /** 只在能安全解码时解开百分号转义。 */
199
+ function decodePercent(value) {
200
+ try {
201
+ return decodeURIComponent(value);
202
+ } catch (error) {
203
+ return value;
204
+ }
180
205
  }
181
206
 
182
207
  /** Highest-confidence marker: the file route an inline link or image points at. */
@@ -219,7 +244,41 @@ window.__ModuleLoader__.load({
219
244
  }
220
245
 
221
246
  /** An absolute path written as plain text inside the clicked element. */
222
- var PATH_IN_TEXT = /(?:^|[\s"'“”「」()()\[\]])(@?"?(?:\/|[A-Za-z]:[\\/])[^\s"'“”「」<>,。;:!?]*)/u;
247
+ var PATH_IN_TEXT = /(?:^|[\s"'“”「」()()\[\]])(@?"?(?:\/|[A-Za-z]:[\\/]|\\\\[^\\\s]+\\|~\/(?!\/))[^\s"'“”「」<>,。;:!?]*)/u;
248
+
249
+ /** 路径结尾成立的样子:有扩展名,或以分隔符收尾。 */
250
+ var PATH_TAIL = /(?:\.[A-Za-z0-9]{1,8}|[\\/])$/u;
251
+
252
+ /** 一段词像不像同一路径的下一段:不以分隔符或盘符开头,也不带句子标点。 */
253
+ function continuesPath(word) {
254
+ if (word === "" || /^[\/"'\u201c\u201d\u300c\u300d<>]/u.test(word)) return false;
255
+ if (/^[A-Za-z]:/u.test(word)) return false;
256
+ return true;
257
+ }
258
+
259
+ /**
260
+ * `C:\Users\apple\Desktop\a b.md` 这类带空格的路径,正则只吃到第一个空格。
261
+ * 往后最多接三段,取「结尾仍然像一个路径」的最长一段。
262
+ */
263
+ function joinPathWords(text, rest) {
264
+ var best = text;
265
+ var remaining = rest;
266
+ for (var round = 0; round < 3; round += 1) {
267
+ var space = /^[ \t]+/u.exec(remaining);
268
+ if (space === null) break;
269
+ var word = /^[^ \t"'\u201c\u201d\u300c\u300d<>\u3001\uff0c\u3002\uff1b\uff1a\uff01\uff1f]+/u.exec(remaining.slice(space[0].length));
270
+ if (word === null) break;
271
+ remaining = remaining.slice(space[0].length + word[0].length);
272
+ if (!continuesPath(word[0])) break;
273
+ var joined = best + " " + word[0];
274
+ if (PATH_TAIL.test(joined)) best = joined;
275
+ else if (!PATH_TAIL.test(best)) {
276
+ /* 还没成句:继续接,等扩展名出现 */
277
+ best = joined;
278
+ }
279
+ }
280
+ return best;
281
+ }
223
282
 
224
283
  /** 正文纯文本里,取光标落点所在的那段路径。 */
225
284
  function pathFromCaret(x, y) {
@@ -231,8 +290,25 @@ window.__ModuleLoader__.load({
231
290
  return null;
232
291
  }
233
292
  if (range === null || range.startContainer === null || range.startContainer.nodeType !== 3) return null;
234
- var data = range.startContainer.data;
293
+ var node = range.startContainer;
294
+ var data = node.data;
235
295
  var offset = Math.min(range.startOffset, data.length);
296
+ var lead = 0;
297
+ for (var back = 0, walker = node; back < 2; back += 1) {
298
+ walker = walker.previousSibling;
299
+ if (walker === null || walker.nodeType !== 3) break;
300
+ lead += walker.data.length;
301
+ }
302
+ if (lead > 0) {
303
+ var merged = "";
304
+ for (var step = 0, cursor = node; step < 2; step += 1) {
305
+ cursor = cursor.previousSibling;
306
+ if (cursor === null || cursor.nodeType !== 3) break;
307
+ merged = cursor.data + merged;
308
+ }
309
+ data = merged + data;
310
+ offset += lead;
311
+ }
236
312
  var bounds = [];
237
313
  var matcher = new RegExp(PATH_IN_TEXT.source, "gu");
238
314
  var hit = null;
@@ -242,7 +318,8 @@ window.__ModuleLoader__.load({
242
318
  }
243
319
  for (var i = 0; i < bounds.length; i += 1) {
244
320
  if (offset >= bounds[i].start && offset <= bounds[i].end) {
245
- return normalizeCandidate(bounds[i].text);
321
+ var tail = data.slice(bounds[i].end);
322
+ return normalizeCandidate(joinPathWords(bounds[i].text, tail));
246
323
  }
247
324
  }
248
325
  return null;
@@ -280,17 +357,19 @@ window.__ModuleLoader__.load({
280
357
  * the DOM: a Markdown image renders an icon and a file name only. Walk the
281
358
  * fiber chain up from the clicked node until a prop names a file.
282
359
  */
283
- function pathFromFiber(node) {
284
- var fiber = null;
285
- for (var owner = node; owner !== null && owner !== undefined && owner !== document.body && fiber === null; owner = owner.parentElement) {
360
+ function fiberOf(node) {
361
+ for (var owner = node; owner !== null && owner !== undefined && owner !== document.body; owner = owner.parentElement) {
286
362
  if (owner.nodeType !== 1) continue;
287
363
  var keys = Object.keys(owner);
288
364
  for (var i = 0; i < keys.length; i += 1) {
289
- if (!FIBER_KEY.test(keys[i])) continue;
290
- fiber = owner[keys[i]];
291
- break;
365
+ if (FIBER_KEY.test(keys[i])) return owner[keys[i]];
292
366
  }
293
367
  }
368
+ return null;
369
+ }
370
+
371
+ function pathFromFiber(node) {
372
+ var fiber = fiberOf(node);
294
373
  for (var depth = 0; fiber !== null && fiber !== undefined && depth < 24; depth += 1) {
295
374
  var found = pathFromProps(fiber.memoizedProps);
296
375
  if (found !== null) return found;
@@ -299,6 +378,44 @@ window.__ModuleLoader__.load({
299
378
  return null;
300
379
  }
301
380
 
381
+ /** 组件自己知道这是个文件夹:appearance / isDirectory 之类的提示。 */
382
+ function directoryHintFromProps(props) {
383
+ if (props === null || typeof props !== "object") return undefined;
384
+ var round = [props];
385
+ var file = props.file;
386
+ if (file !== null && typeof file === "object") round.push(file);
387
+ var inner = props.reference;
388
+ if (inner !== null && typeof inner === "object") round.push(inner);
389
+ for (var i = 0; i < round.length; i += 1) {
390
+ var item = round[i];
391
+ if (item === null || typeof item !== "object") continue;
392
+ if (item.appearance === "folder" || item.appearance === "directory") return true;
393
+ if (item.appearance === "file") return false;
394
+ if (item.isDirectory === true || item.directory === true) return true;
395
+ if (item.isDirectory === false) return false;
396
+ }
397
+ return undefined;
398
+ }
399
+
400
+ function directoryHint(node) {
401
+ for (var owner = node; owner !== null && owner !== undefined && owner !== document.body; owner = owner.parentElement) {
402
+ if (owner.nodeType !== 1) continue;
403
+ var attr = owner.getAttribute("data-appearance") || owner.getAttribute("data-type") || owner.getAttribute("data-kind");
404
+ if (attr !== null) {
405
+ var value = attr.toLowerCase();
406
+ if (value === "folder" || value === "directory") return true;
407
+ if (value === "file") return false;
408
+ }
409
+ }
410
+ var fiber = fiberOf(node);
411
+ for (var depth = 0; fiber !== null && fiber !== undefined && depth < 24; depth += 1) {
412
+ var hint = directoryHintFromProps(fiber.memoizedProps);
413
+ if (hint !== undefined) return hint;
414
+ fiber = fiber.return;
415
+ }
416
+ return undefined;
417
+ }
418
+
302
419
  /**
303
420
  * Walk up from the clicked node looking for a file link: href/src first, then
304
421
  * an absolute `data-*` value, then a title, then the element's own text. The
@@ -318,7 +435,7 @@ window.__ModuleLoader__.load({
318
435
  }
319
436
  var fromFiber = pathFromFiber(start);
320
437
  if (fromFiber !== null) return fromFiber;
321
- if (point !== null && point !== undefined) {
438
+ if (point !== null && point !== undefined && point.allowProse !== false) {
322
439
  var fromCaret = pathFromCaret(point.x, point.y);
323
440
  if (fromCaret !== null) return fromCaret;
324
441
  }
@@ -360,6 +477,37 @@ window.__ModuleLoader__.load({
360
477
  "@keyframes dcla-in{from{opacity:0;transform:translateY(-2px);}to{opacity:1;transform:none;}}"
361
478
  ].join("");
362
479
 
480
+ /** 界面语言跟着宿主走:宿主把当前语言写在 <html lang> 上。 */ // ui-copy-ignore
481
+ var TEXT = {
482
+ zh: { // ui-copy-ignore
483
+ menu: "文件操作", // ui-copy-ignore
484
+ quote: "引用到对话", // ui-copy-ignore
485
+ reveal: "在文件管理器中查看", // ui-copy-ignore
486
+ copy: "复制路径", // ui-copy-ignore
487
+ copiedPath: "已复制路径", // ui-copy-ignore
488
+ copiedQuote: "已复制引用", // ui-copy-ignore
489
+ missing: "目标文件不存在", // ui-copy-ignore
490
+ managerFailed: "无法打开文件管理器" // ui-copy-ignore
491
+ },
492
+ en: { // ui-copy-ignore
493
+ menu: "File actions", // ui-copy-ignore
494
+ quote: "Quote in chat", // ui-copy-ignore
495
+ reveal: "Show in file manager", // ui-copy-ignore
496
+ copy: "Copy path", // ui-copy-ignore
497
+ copiedPath: "Path copied", // ui-copy-ignore
498
+ copiedQuote: "Reference copied", // ui-copy-ignore
499
+ missing: "File not found", // ui-copy-ignore
500
+ managerFailed: "Could not open the file manager" // ui-copy-ignore
501
+ }
502
+ };
503
+
504
+ function strings() {
505
+ var element = document.documentElement;
506
+ var lang = element === null ? "" : element.getAttribute("lang");
507
+ var tag = typeof lang === "string" && lang !== "" ? lang : (typeof navigator === "undefined" ? "" : navigator.language);
508
+ return /^zh/i.test(String(tag === null || tag === undefined ? "" : tag)) ? TEXT.zh : TEXT.en;
509
+ }
510
+
363
511
  function ensureStyle() {
364
512
  if (document.getElementById(STYLE_ID) !== null) return;
365
513
  var style = document.createElement("style");
@@ -482,7 +630,7 @@ window.__ModuleLoader__.load({
482
630
  var panel = document.createElement("div");
483
631
  panel.className = "dcla-menu";
484
632
  panel.setAttribute("role", "menu");
485
- panel.setAttribute("aria-label", "文件操作");
633
+ panel.setAttribute("aria-label", strings().menu);
486
634
 
487
635
  var caption = document.createElement("div");
488
636
  caption.className = "dcla-path";
@@ -528,34 +676,39 @@ window.__ModuleLoader__.load({
528
676
  return { panel: panel, buttons: buttons };
529
677
  }
530
678
 
531
- function openMenu(path, x, y) {
679
+ function openMenu(path, x, y, options) {
532
680
  closeMenu(false);
533
681
  ensureStyle();
682
+ var settings = options === null || options === undefined ? {} : options;
534
683
 
535
684
  var items = [
536
- { id: "quote", label: "引用到对话", icon: "quote", run: function () {
537
- var inserted = quoteIntoComposer(path);
538
- if (!inserted) {
539
- navigator.clipboard.writeText(referenceText(path)).then(function () {}, function () {});
540
- }
685
+ { id: "quote", label: strings().quote, icon: "quote", run: function () {
541
686
  probePath(path, function (kind) {
542
- if (kind === "missing") {
543
- toast("引用文件不存在");
544
- return;
545
- }
546
- if (!inserted) toast("已复制引用");
687
+ var directory = kind === "directory" ? true : kind === "file" ? false : settings.directory;
688
+ if (directory === undefined && kind !== "file" && kind !== "directory") directory = guessDirectory(path);
689
+ var inserted = quoteIntoComposer(path, directory);
690
+ if (inserted) return;
691
+ navigator.clipboard.writeText(referenceText(path)).then(function () {
692
+ toast(strings().copiedQuote);
693
+ }, function () {});
547
694
  });
548
695
  } },
549
- { id: "reveal", label: "在文件管理器中查看", icon: "reveal", run: function () {
696
+ { id: "reveal", label: strings().reveal, icon: "reveal", run: function () {
550
697
  reveal(path);
551
698
  } },
552
- { id: "copy", label: "复制路径", icon: "copy", run: function () {
699
+ { id: "copy", label: strings().copy, icon: "copy", run: function () {
553
700
  navigator.clipboard.writeText(path).then(function () {
554
- toast("已复制路径");
701
+ toast(strings().copiedPath);
555
702
  }, function () {});
556
703
  } }
557
704
  ];
558
705
 
706
+ if (settings.inField === true) {
707
+ items = items.filter(function (item) {
708
+ return item.id !== "quote";
709
+ });
710
+ }
711
+
559
712
  var layer = document.createElement("div");
560
713
  layer.className = "dcla-layer";
561
714
  var built = buildMenu(path, items);
@@ -627,51 +780,95 @@ window.__ModuleLoader__.load({
627
780
  return null;
628
781
  }
629
782
 
630
- /** 路径是否存在:先当文件问,再当目录问。 */
783
+ /** 路径是否存在:先当文件问,再当目录问;多个会话依次问,问到一个就够。 */
784
+ /** 某个会话自己的 workspaceFiles;拿不到时退回当前会话的那一份。 */
785
+ function filesFor(sessionId, fallback) {
786
+ try {
787
+ var context = rootContext === null || typeof rootContext.sessions !== "object" || rootContext.sessions === null
788
+ ? null
789
+ : rootContext.sessions.binding(sessionId);
790
+ var sessionContext = context === null || context === undefined ? null : context.ctx;
791
+ var remote = sessionContext === null || sessionContext === undefined ? null : sessionContext.remote;
792
+ var files = remote === null || remote === undefined ? null : remote.workspaceFiles;
793
+ if (files !== null && files !== undefined && typeof files.stat === "function") return files;
794
+ } catch (error) {
795
+ /* fall through to the shared remote */
796
+ }
797
+ return fallback;
798
+ }
799
+
631
800
  function probePath(path, done) {
632
801
  var remote = sessionRemote();
633
- var files = remote === null ? null : remote.workspaceFiles;
802
+ var shared = remote === null ? null : remote.workspaceFiles;
634
803
  var candidates = draftCandidates();
635
- var sessionId = candidates.length === 0 ? undefined : candidates[0].sessionId;
636
- if (files === null || files === undefined || typeof files.stat !== "function" || sessionId === undefined) {
804
+ if (candidates.length === 0) {
637
805
  done("unknown");
638
806
  return;
639
807
  }
640
808
  var settled = false;
809
+ var position = 0;
641
810
  var probeTimer = setTimeout(function () {
642
811
  finish("unknown");
643
- }, 900);
812
+ }, 700);
644
813
  function finish(kind) {
645
814
  if (settled) return;
646
815
  settled = true;
647
816
  clearTimeout(probeTimer);
648
817
  done(kind);
649
818
  }
650
- function askDirectory() {
819
+ function next() {
820
+ if (settled) return;
821
+ if (position >= candidates.length) {
822
+ finish("missing");
823
+ return;
824
+ }
825
+ var sessionId = candidates[position].sessionId;
826
+ position += 1;
827
+ var files = filesFor(sessionId, shared);
828
+ if (files === null || files === undefined || typeof files.stat !== "function") {
829
+ next();
830
+ return;
831
+ }
832
+ var moved = false;
833
+ function move() {
834
+ if (settled || moved) return;
835
+ moved = true;
836
+ next();
837
+ }
838
+ function askDirectory() {
839
+ try {
840
+ files.list(sessionId, path).then(function () {
841
+ finish("directory");
842
+ }, move);
843
+ } catch (error) {
844
+ move();
845
+ }
846
+ }
651
847
  try {
652
- files.list(sessionId, path).then(function () {
653
- finish("present");
654
- }, function () {
655
- finish("missing");
656
- });
848
+ files.stat(sessionId, path).then(function () {
849
+ /* stat 不区分文件与目录:用列举确认,列得出来就是目录。 */
850
+ files.list(sessionId, path).then(function () {
851
+ finish("directory");
852
+ }, function (error) {
853
+ /* 只有后端明确说「不是目录」才算文件;别的失败当作问不到。 */
854
+ var code = error === null || error === undefined ? "" : String(error.code === undefined ? "" : error.code);
855
+ var message = error === null || error === undefined ? "" : String(error.message === undefined ? "" : error.message);
856
+ if (/not-directory/i.test(code) || /not-directory|is a file/i.test(message)) finish("file");
857
+ else finish("unknown");
858
+ });
859
+ }, askDirectory);
657
860
  } catch (error) {
658
- finish("unknown");
861
+ askDirectory();
659
862
  }
660
863
  }
661
- try {
662
- files.stat(sessionId, path).then(function () {
663
- finish("present");
664
- }, askDirectory);
665
- } catch (error) {
666
- askDirectory();
667
- }
864
+ next();
668
865
  }
669
866
 
670
867
  /** 在系统文件管理器里选中路径;路径不存在时直接告知。 */
671
868
  function reveal(path) {
672
869
  probePath(path, function (kind) {
673
870
  if (kind === "missing") {
674
- toast("目标文件不存在");
871
+ toast(strings().missing);
675
872
  return;
676
873
  }
677
874
  revealViaRoute(path);
@@ -683,7 +880,7 @@ window.__ModuleLoader__.load({
683
880
  fetch("/chat-link-actions?action=reveal&path=" + encodeURIComponent(path), { method: "POST" }).then(function (response) {
684
881
  if (response.ok) return;
685
882
  if (response.status === 404) {
686
- toast("目标文件不存在");
883
+ toast(strings().missing);
687
884
  return;
688
885
  }
689
886
  revealRemote(path);
@@ -697,18 +894,18 @@ window.__ModuleLoader__.load({
697
894
  var remote = sessionRemote();
698
895
  var session = remote === null ? null : remote.session;
699
896
  if (session === null || session === undefined || typeof session.openWorkspacePath !== "function") {
700
- toast("无法打开文件管理器");
897
+ toast(strings().managerFailed);
701
898
  return;
702
899
  }
703
900
  try {
704
901
  session.openWorkspacePath({ path: path, action: "reveal" }).then(function (result) {
705
902
  if (result !== null && result !== undefined && result.ok === true) return;
706
- toast(isUnreachablePath(result === null || result === undefined ? null : result.error) ? "目标文件不存在" : "无法打开文件管理器");
903
+ toast(isUnreachablePath(result === null || result === undefined ? null : result.error) ? strings().missing : strings().managerFailed);
707
904
  }, function (error) {
708
- toast(isUnreachablePath(error) ? "目标文件不存在" : "无法打开文件管理器");
905
+ toast(isUnreachablePath(error) ? strings().missing : strings().managerFailed);
709
906
  });
710
907
  } catch (error) {
711
- toast("无法打开文件管理器");
908
+ toast(strings().managerFailed);
712
909
  }
713
910
  }
714
911
 
@@ -719,14 +916,40 @@ window.__ModuleLoader__.load({
719
916
 
720
917
  /* ── interception ───────────────────────────────────────────────────── */
721
918
 
919
+ var EDITABLE_SELECTOR = "textarea,input,[contenteditable=''],[contenteditable=true],[contenteditable=plaintext-only]";
920
+
921
+ /** 输入框里右键通常是要粘贴:只有点到引用芯片这类带路径的元素才接管。 */
922
+ function editableField(node) {
923
+ if (node instanceof Element && node.closest(EDITABLE_SELECTOR) !== null) return true;
924
+ return node instanceof Element && node.isContentEditable;
925
+ }
926
+
927
+ /** 插件自己的故障只记一条日志,绝不让页面上的右键整体失效。 */
928
+ function noteFailure(error) {
929
+ try {
930
+ var context = rootContext;
931
+ if (context !== null && context !== undefined && typeof context.logger?.warn === "function") {
932
+ context.logger.warn("chat-link-actions: menu failed: " + String(error && error.message ? error.message : error));
933
+ }
934
+ } catch (ignored) {
935
+ /* logging must never throw */
936
+ }
937
+ }
938
+
722
939
  function onContextMenu(event) {
723
940
  if (!(event.target instanceof Element)) return;
724
941
  var target = event.target;
725
- var path = pathFromNode(target, { x: event.clientX, y: event.clientY });
726
- if (path === null) return;
727
- event.preventDefault();
728
- event.stopPropagation();
729
- openMenu(path, event.clientX, event.clientY);
942
+ try {
943
+ var inField = editableField(target);
944
+ var path = pathFromNode(target, { x: event.clientX, y: event.clientY, allowProse: !inField });
945
+ if (path === null) return;
946
+ openMenu(path, event.clientX, event.clientY, { inField: inField, directory: directoryHint(target) });
947
+ event.preventDefault();
948
+ event.stopPropagation();
949
+ } catch (error) {
950
+ closeMenu(false);
951
+ noteFailure(error);
952
+ }
730
953
  }
731
954
 
732
955
  /* ── plugin entry ───────────────────────────────────────────────────── */
package/index.js CHANGED
@@ -9,55 +9,166 @@
9
9
  */
10
10
 
11
11
  import { execFile } from 'node:child_process'
12
- import { stat } from 'node:fs/promises'
13
- import { dirname, isAbsolute } from 'node:path'
12
+ import { lstat, stat } from 'node:fs/promises'
13
+ import { dirname, isAbsolute, win32 as pathWin32 } from 'node:path'
14
14
 
15
15
  /** Services required before mounting: route registration + the web runtime fence. */
16
16
  export const inject = ['webServer', 'connection']
17
17
 
18
+ /** A launcher that hangs must not keep the route open. */
19
+ const LAUNCH_TIMEOUT_MS = 3000
20
+
21
+ /** Absolute on the target platform: POSIX here, drives and UNC on Windows. */
22
+ function isAbsoluteFor(path, platform = process.platform) {
23
+ return platform === 'win32' ? pathWin32.isAbsolute(path) : isAbsolute(path)
24
+ }
25
+
18
26
  /** Paths that are not a local file on this machine cannot be revealed. */
19
- function requireAbsolute(path) {
20
- if (typeof path !== 'string' || path === '' || !isAbsolute(path)) {
27
+ export function requireAbsolute(path, platform = process.platform) {
28
+ if (typeof path !== 'string' || path === '' || path.includes('\0')) {
21
29
  throw new Error('absolute path required')
22
30
  }
31
+ if (!isAbsoluteFor(path, platform)) throw new Error('absolute path required')
23
32
  return path
24
33
  }
25
34
 
26
35
  /**
27
- * The argv that selects `path` in the platform's file manager. Linux has no
28
- * select protocol, so its containing directory is opened instead.
36
+ * `open -R /a/b/` and `open -R /a/b` are not treated the same by Finder, and a
37
+ * trailing separator makes lstat resolve through a symlink. One canonical form
38
+ * reaches every launcher. Roots (`/`, `C:\`) keep their separator.
39
+ *
40
+ * Only Windows treats `\` as a separator; on POSIX it is an ordinary filename
41
+ * character, so a trailing backslash there is part of the name.
42
+ */
43
+ function trimTrailingSeparators(path, platform = process.platform) {
44
+ const pattern = platform === 'win32' ? /[/\\]+$/ : /\/+$/
45
+ const trimmed = path.replace(pattern, '')
46
+ if (trimmed === '') return path
47
+ if (platform === 'win32' && /^[a-z]:$/i.test(trimmed)) return `${trimmed}\\`
48
+ return trimmed
49
+ }
50
+
51
+ /** Containing directory, with the separator rules of the target platform. */
52
+ function directoryOf(path, platform = process.platform) {
53
+ return platform === 'win32' ? pathWin32.dirname(path) : dirname(path)
54
+ }
55
+
56
+ /**
57
+ * One canonical Windows spelling: forward slashes become backslashes, duplicate
58
+ * separators collapse, and the drive letter is upper-cased. An existing UNC or
59
+ * `\\?\` prefix keeps its leading pair.
60
+ */
61
+ export function normalizeWindowsPath(path) {
62
+ const slashed = trimTrailingSeparators(path, 'win32').replace(/\//g, '\\')
63
+ let prefix = ''
64
+ let rest = slashed
65
+ if (rest.startsWith('\\\\?\\')) {
66
+ prefix = '\\\\?\\'
67
+ rest = rest.slice(4)
68
+ } else if (rest.startsWith('\\\\')) {
69
+ prefix = '\\\\'
70
+ rest = rest.slice(2)
71
+ }
72
+ const joined = `${prefix}${rest.replace(/\\{2,}/g, '\\')}`
73
+ return joined.replace(/^([a-z]):/, (_match, drive) => `${drive.toUpperCase()}:`)
74
+ }
75
+
76
+ /**
77
+ * Windows still caps ordinary paths at MAX_PATH (260) and silently drops
78
+ * trailing spaces and dots, so those targets only resolve through the verbatim
79
+ * form: `\\?\C:\…`, or `\\?\UNC\server\share\…` for UNC shares.
80
+ */
81
+ export function extendedWindowsPath(path) {
82
+ if (path.startsWith('\\\\?\\')) return path
83
+ const touchesLimit = path.length > 255
84
+ const awkward = path.split('\\').some((segment) => /[ .]$/.test(segment))
85
+ if (!touchesLimit && !awkward) return path
86
+ if (path.startsWith('\\\\')) return `\\\\?\\UNC\\${path.slice(2)}`
87
+ return `\\\\?\\${path}`
88
+ }
89
+
90
+ /** The exact Windows string the file manager receives. */
91
+ function windowsTarget(path) {
92
+ return extendedWindowsPath(normalizeWindowsPath(path))
93
+ }
94
+
95
+ /**
96
+ * Build the single argv element `explorer.exe` expects for a selection.
97
+ *
98
+ * explorer parses its own command line and splits on commas, so a path that
99
+ * itself contains a comma must be quoted as a whole or explorer truncates it
100
+ * (`/select,C:\a,b\c d.md` → `C:\a`). Whitespace is quoted for the same reason.
101
+ * Ordinary paths keep the historical unquoted form.
29
102
  */
30
- function revealCommand(path, platform = process.platform) {
103
+ export function encodeExplorerSelect(path) {
104
+ if (/[",\s]/.test(path)) return `/select,"${path}"`
105
+ return `/select,${path}`
106
+ }
107
+
108
+ /**
109
+ * The argv that selects `path` in the platform's file manager.
110
+ *
111
+ * `facts.isDirectory` / `facts.isSymbolicLink` come from stat/lstat: macOS opens
112
+ * a directory directly instead of using `-R` (Finder behaves inconsistently for
113
+ * directories), and a symlink is always selected as the link itself — never with
114
+ * `-L`, which would reveal the resolved target instead.
115
+ */
116
+ export function revealCommand(path, platform = process.platform, facts = {}) {
117
+ const target = trimTrailingSeparators(path, platform)
118
+ const isDirectory = facts?.isDirectory === true
119
+ const isSymbolicLink = facts?.isSymbolicLink === true
31
120
  switch (platform) {
32
121
  case 'darwin':
33
- return { command: 'open', args: ['-R', path] }
122
+ if (isDirectory && !isSymbolicLink) return { command: 'open', args: [target] }
123
+ return { command: 'open', args: ['-R', target] }
34
124
  case 'win32':
35
- return { command: 'explorer.exe', args: [`/select,${path}`] }
125
+ return { command: 'explorer.exe', args: [encodeExplorerSelect(windowsTarget(target))] }
36
126
  case 'android':
37
- return { command: 'termux-open', args: [dirname(path)] }
127
+ return { command: 'termux-open', args: [directoryOf(target, platform)] }
38
128
  default:
39
- return { command: 'xdg-open', args: [dirname(path)] }
129
+ return { command: 'xdg-open', args: [directoryOf(target, platform)] }
40
130
  }
41
131
  }
42
132
 
43
133
  /** The argv that only opens the containing directory, used when selection fails. */
44
- function openDirectoryCommand(path, platform = process.platform) {
134
+ export function openDirectoryCommand(path, platform = process.platform) {
135
+ const target = trimTrailingSeparators(path, platform)
136
+ const directory = platform === 'win32'
137
+ ? extendedWindowsPath(directoryOf(normalizeWindowsPath(target), platform))
138
+ : directoryOf(target, platform)
45
139
  switch (platform) {
46
140
  case 'darwin':
47
- return { command: 'open', args: [dirname(path)] }
141
+ return { command: 'open', args: [directory] }
48
142
  case 'win32':
49
- return { command: 'explorer.exe', args: [dirname(path)] }
143
+ return { command: 'explorer.exe', args: [directory] }
50
144
  case 'android':
51
- return { command: 'termux-open', args: [dirname(path)] }
145
+ return { command: 'termux-open', args: [directory] }
52
146
  default:
53
- return { command: 'xdg-open', args: [dirname(path)] }
147
+ return { command: 'xdg-open', args: [directory] }
54
148
  }
55
149
  }
56
150
 
151
+ /**
152
+ * explorer.exe exits with 1 even when it opened the window, so a non-zero exit
153
+ * code from it is not proof of failure — only a spawn error (ENOENT) is.
154
+ */
155
+ export function succeeded(result, command) {
156
+ if (result?.ok === true) return true
157
+ if (command !== 'explorer.exe') return false
158
+ const code = result?.error?.code
159
+ return code === 1 || code === '1'
160
+ }
161
+
162
+ /** One line of failure context for the log, never echoed to the caller. */
163
+ function describeFailure(result) {
164
+ const detail = result?.stderr || result?.error?.message || result?.error
165
+ return detail === undefined || detail === null || detail === '' ? 'unknown error' : String(detail)
166
+ }
167
+
57
168
  /** Run one launcher and resolve its outcome instead of throwing. */
58
169
  function run(command, args) {
59
170
  return new Promise((resolve) => {
60
- execFile(command, args, { timeout: 3000, windowsHide: true }, (error, stdout, stderr) => {
171
+ execFile(command, args, { timeout: LAUNCH_TIMEOUT_MS, windowsHide: true }, (error, stdout, stderr) => {
61
172
  if (error === null) {
62
173
  resolve({ ok: true })
63
174
  return
@@ -74,17 +185,20 @@ function reply(res, status, body) {
74
185
 
75
186
  export function apply(ctx) {
76
187
  const fence = (req) => ctx.connection.requestRejection(req) === undefined
188
+ const warn = (message) => ctx.logger?.warn?.(message)
189
+ /** Test seam: a context may replace the process launcher with a recorder. */
190
+ const execute = typeof ctx.runLauncher === 'function' ? ctx.runLauncher : run
77
191
 
78
- /** Select the file; when selection is refused, at least open its directory. */
79
- const launch = async (path) => {
80
- const select = revealCommand(path)
81
- const selected = await run(select.command, select.args)
82
- if (selected.ok) return { ok: true }
83
- ctx.logger?.warn?.(`chat-link-actions: select via ${select.command} failed: ${String(selected.stderr || selected.error?.message || selected.error)}`)
84
- const open = openDirectoryCommand(path)
85
- const opened = await run(open.command, open.args)
86
- if (opened.ok) return { ok: true }
87
- ctx.logger?.warn?.(`chat-link-actions: open ${dirname(path)} failed: ${String(opened.stderr || opened.error?.message || opened.error)}`)
192
+ /** Select the target; when selection is refused, at least open its directory. */
193
+ const launch = async (path, facts = {}) => {
194
+ const select = revealCommand(path, process.platform, facts)
195
+ const selected = await execute(select.command, select.args)
196
+ if (succeeded(selected, select.command)) return { ok: true }
197
+ warn(`chat-link-actions: select via ${select.command} failed: ${describeFailure(selected)}`)
198
+ const open = openDirectoryCommand(path, process.platform)
199
+ const opened = await execute(open.command, open.args)
200
+ if (succeeded(opened, open.command)) return { ok: true }
201
+ warn(`chat-link-actions: open ${directoryOf(path)} failed: ${describeFailure(opened)}`)
88
202
  return { ok: false }
89
203
  }
90
204
 
@@ -93,7 +207,7 @@ export function apply(ctx) {
93
207
  path: '/chat-link-actions',
94
208
  handler: async (req, res) => {
95
209
  if (!fence(req)) {
96
- ctx.logger?.warn?.('chat-link-actions: reveal request rejected by the connection fence')
210
+ warn('chat-link-actions: reveal request rejected by the connection fence')
97
211
  return reply(res, 403, { ok: false, error: 'forbidden' })
98
212
  }
99
213
  if (req.method !== 'POST') return reply(res, 405, { ok: false, error: 'method' })
@@ -102,10 +216,15 @@ export function apply(ctx) {
102
216
  if (url.searchParams.get('action') !== 'reveal') {
103
217
  return reply(res, 400, { ok: false, error: 'unknown action' })
104
218
  }
105
- const path = requireAbsolute(url.searchParams.get('path'))
106
- const info = await stat(path).catch(() => null)
107
- if (info === null) return reply(res, 404, { ok: false, error: 'not-found' })
108
- const outcome = await launch(path)
219
+ const target = trimTrailingSeparators(requireAbsolute(url.searchParams.get('path')))
220
+ const link = await lstat(target).catch(() => null)
221
+ const info = link === null ? null : await stat(target).catch(() => null)
222
+ if (link === null && info === null) return reply(res, 404, { ok: false, error: 'not-found' })
223
+ const facts = {
224
+ isDirectory: info !== null && info.isDirectory(),
225
+ isSymbolicLink: link !== null && link.isSymbolicLink(),
226
+ }
227
+ const outcome = await launch(target, facts)
109
228
  if (!outcome.ok) return reply(res, 500, { ok: false, error: 'launch-failed' })
110
229
  return reply(res, 200, { ok: true })
111
230
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-chat-link-actions",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Right-click actions on file links in the DSH transcript: quote the path into the composer, or reveal the file in the OS file manager. Menus are drawn in the app's own style, never the system one.",
5
5
  "type": "module",
6
6
  "main": "index.js",