dsh-chat-link-actions 0.2.3 → 0.2.5
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 +5 -5
- package/client.js +266 -77
- package/index.js +174 -37
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
# dsh-chat-link-actions(对话里的文件链接,右键就有动作)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**对话里的文件路径,右键就能引用回输入框,也能在文件管理器里直接定位。菜单是 App 自己画的,不是系统弹窗。**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
长任务跑完,记录里一串路径;想引用回去得手打,想在文件管理器里找到又是另一回事。这个插件把右键菜单接到对话里的文件链接上。
|
|
6
6
|
|
|
7
7
|
## 三个动作
|
|
8
8
|
|
|
9
9
|
| 右键文件链接 | 做什么 |
|
|
10
10
|
| --- | --- |
|
|
11
11
|
| **引用到对话** | 插成输入框里的引用芯片(`(</> 文件名)`),和拖文件、`@` 选文件的结果一样 |
|
|
12
|
-
|
|
|
12
|
+
| **在文件管理器中查看** | 打开所在目录并选中这个文件(macOS 打开访达、Windows 打开资源管理器、Linux 打开所在目录) |
|
|
13
13
|
| **复制路径** | 绝对路径进剪贴板,直接粘给别的窗口 |
|
|
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
|
|
|
@@ -33,7 +33,7 @@ dsh plugin install dsh-chat-link-actions
|
|
|
33
33
|
## 不做什么
|
|
34
34
|
|
|
35
35
|
- 只认本机存在的绝对路径:网络路径、已删掉的文件,会直接说找不到。
|
|
36
|
-
-
|
|
36
|
+
- 不上传、不打开编辑器;「在文件管理器中查看」只让系统文件管理器选中文件,不修改它。
|
|
37
37
|
- 不改对话内容:插入是一次正常的输入框编辑,可以撤销,也会进草稿。
|
|
38
38
|
|
|
39
39
|
## 给 agent 的话
|
package/client.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* menu. Two actions:
|
|
7
7
|
*
|
|
8
8
|
* 引用到对话 insert `@path` into the composer as one undoable edit
|
|
9
|
-
*
|
|
9
|
+
* 在文件管理器中查看 reveal the file in the OS file manager (服务端接口)
|
|
10
10
|
*
|
|
11
11
|
* Wiring:
|
|
12
12
|
* - `inputActions` is a standard prop of session-scoped slots
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
284
|
-
var
|
|
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 (
|
|
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
|
}
|
|
@@ -327,10 +444,6 @@ window.__ModuleLoader__.load({
|
|
|
327
444
|
|
|
328
445
|
/* ── wording ────────────────────────────────────────────────────────── */
|
|
329
446
|
|
|
330
|
-
function isMac() {
|
|
331
|
-
return /Mac/i.test(navigator.platform || navigator.userAgent || "");
|
|
332
|
-
}
|
|
333
|
-
|
|
334
447
|
/* ── menu styling: project tokens only, no new palette ──────────────── */
|
|
335
448
|
|
|
336
449
|
var STYLE_ID = "dsh-chat-link-actions-style";
|
|
@@ -532,25 +645,24 @@ window.__ModuleLoader__.load({
|
|
|
532
645
|
return { panel: panel, buttons: buttons };
|
|
533
646
|
}
|
|
534
647
|
|
|
535
|
-
function openMenu(path, x, y) {
|
|
648
|
+
function openMenu(path, x, y, options) {
|
|
536
649
|
closeMenu(false);
|
|
537
650
|
ensureStyle();
|
|
651
|
+
var settings = options === null || options === undefined ? {} : options;
|
|
538
652
|
|
|
539
653
|
var items = [
|
|
540
654
|
{ id: "quote", label: "引用到对话", icon: "quote", run: function () {
|
|
541
|
-
var inserted = quoteIntoComposer(path);
|
|
542
|
-
if (!inserted) {
|
|
543
|
-
navigator.clipboard.writeText(referenceText(path)).then(function () {}, function () {});
|
|
544
|
-
}
|
|
545
655
|
probePath(path, function (kind) {
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
656
|
+
var directory = kind === "directory" ? true : kind === "file" ? false : settings.directory;
|
|
657
|
+
if (directory === undefined && kind !== "file" && kind !== "directory") directory = guessDirectory(path);
|
|
658
|
+
var inserted = quoteIntoComposer(path, directory);
|
|
659
|
+
if (inserted) return;
|
|
660
|
+
navigator.clipboard.writeText(referenceText(path)).then(function () {
|
|
661
|
+
toast("已复制引用");
|
|
662
|
+
}, function () {});
|
|
551
663
|
});
|
|
552
664
|
} },
|
|
553
|
-
{ id: "reveal", label:
|
|
665
|
+
{ id: "reveal", label: "在文件管理器中查看", icon: "reveal", run: function () {
|
|
554
666
|
reveal(path);
|
|
555
667
|
} },
|
|
556
668
|
{ id: "copy", label: "复制路径", icon: "copy", run: function () {
|
|
@@ -560,6 +672,12 @@ window.__ModuleLoader__.load({
|
|
|
560
672
|
} }
|
|
561
673
|
];
|
|
562
674
|
|
|
675
|
+
if (settings.inField === true) {
|
|
676
|
+
items = items.filter(function (item) {
|
|
677
|
+
return item.id !== "quote";
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
563
681
|
var layer = document.createElement("div");
|
|
564
682
|
layer.className = "dcla-layer";
|
|
565
683
|
var built = buildMenu(path, items);
|
|
@@ -631,40 +749,88 @@ window.__ModuleLoader__.load({
|
|
|
631
749
|
return null;
|
|
632
750
|
}
|
|
633
751
|
|
|
634
|
-
/**
|
|
752
|
+
/** 路径是否存在:先当文件问,再当目录问;多个会话依次问,问到一个就够。 */
|
|
753
|
+
/** 某个会话自己的 workspaceFiles;拿不到时退回当前会话的那一份。 */
|
|
754
|
+
function filesFor(sessionId, fallback) {
|
|
755
|
+
try {
|
|
756
|
+
var context = rootContext === null || typeof rootContext.sessions !== "object" || rootContext.sessions === null
|
|
757
|
+
? null
|
|
758
|
+
: rootContext.sessions.binding(sessionId);
|
|
759
|
+
var sessionContext = context === null || context === undefined ? null : context.ctx;
|
|
760
|
+
var remote = sessionContext === null || sessionContext === undefined ? null : sessionContext.remote;
|
|
761
|
+
var files = remote === null || remote === undefined ? null : remote.workspaceFiles;
|
|
762
|
+
if (files !== null && files !== undefined && typeof files.stat === "function") return files;
|
|
763
|
+
} catch (error) {
|
|
764
|
+
/* fall through to the shared remote */
|
|
765
|
+
}
|
|
766
|
+
return fallback;
|
|
767
|
+
}
|
|
768
|
+
|
|
635
769
|
function probePath(path, done) {
|
|
636
770
|
var remote = sessionRemote();
|
|
637
|
-
var
|
|
771
|
+
var shared = remote === null ? null : remote.workspaceFiles;
|
|
638
772
|
var candidates = draftCandidates();
|
|
639
|
-
|
|
640
|
-
if (files === null || files === undefined || typeof files.stat !== "function" || sessionId === undefined) {
|
|
773
|
+
if (candidates.length === 0) {
|
|
641
774
|
done("unknown");
|
|
642
775
|
return;
|
|
643
776
|
}
|
|
644
777
|
var settled = false;
|
|
778
|
+
var position = 0;
|
|
779
|
+
var probeTimer = setTimeout(function () {
|
|
780
|
+
finish("unknown");
|
|
781
|
+
}, 700);
|
|
645
782
|
function finish(kind) {
|
|
646
783
|
if (settled) return;
|
|
647
784
|
settled = true;
|
|
785
|
+
clearTimeout(probeTimer);
|
|
648
786
|
done(kind);
|
|
649
787
|
}
|
|
650
|
-
function
|
|
788
|
+
function next() {
|
|
789
|
+
if (settled) return;
|
|
790
|
+
if (position >= candidates.length) {
|
|
791
|
+
finish("missing");
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
var sessionId = candidates[position].sessionId;
|
|
795
|
+
position += 1;
|
|
796
|
+
var files = filesFor(sessionId, shared);
|
|
797
|
+
if (files === null || files === undefined || typeof files.stat !== "function") {
|
|
798
|
+
next();
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
var moved = false;
|
|
802
|
+
function move() {
|
|
803
|
+
if (settled || moved) return;
|
|
804
|
+
moved = true;
|
|
805
|
+
next();
|
|
806
|
+
}
|
|
807
|
+
function askDirectory() {
|
|
808
|
+
try {
|
|
809
|
+
files.list(sessionId, path).then(function () {
|
|
810
|
+
finish("directory");
|
|
811
|
+
}, move);
|
|
812
|
+
} catch (error) {
|
|
813
|
+
move();
|
|
814
|
+
}
|
|
815
|
+
}
|
|
651
816
|
try {
|
|
652
|
-
files.
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
817
|
+
files.stat(sessionId, path).then(function () {
|
|
818
|
+
/* stat 不区分文件与目录:用列举确认,列得出来就是目录。 */
|
|
819
|
+
files.list(sessionId, path).then(function () {
|
|
820
|
+
finish("directory");
|
|
821
|
+
}, function (error) {
|
|
822
|
+
/* 只有后端明确说「不是目录」才算文件;别的失败当作问不到。 */
|
|
823
|
+
var code = error === null || error === undefined ? "" : String(error.code === undefined ? "" : error.code);
|
|
824
|
+
var message = error === null || error === undefined ? "" : String(error.message === undefined ? "" : error.message);
|
|
825
|
+
if (/not-directory/i.test(code) || /not-directory|is a file/i.test(message)) finish("file");
|
|
826
|
+
else finish("unknown");
|
|
827
|
+
});
|
|
828
|
+
}, askDirectory);
|
|
657
829
|
} catch (error) {
|
|
658
|
-
|
|
830
|
+
askDirectory();
|
|
659
831
|
}
|
|
660
832
|
}
|
|
661
|
-
|
|
662
|
-
files.stat(sessionId, path).then(function () {
|
|
663
|
-
finish("present");
|
|
664
|
-
}, askDirectory);
|
|
665
|
-
} catch (error) {
|
|
666
|
-
askDirectory();
|
|
667
|
-
}
|
|
833
|
+
next();
|
|
668
834
|
}
|
|
669
835
|
|
|
670
836
|
/** 在系统文件管理器里选中路径;路径不存在时直接告知。 */
|
|
@@ -674,35 +840,41 @@ window.__ModuleLoader__.load({
|
|
|
674
840
|
toast("目标文件不存在");
|
|
675
841
|
return;
|
|
676
842
|
}
|
|
843
|
+
revealViaRoute(path);
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** 首选:直接请系统文件管理器选中。 */
|
|
848
|
+
function revealViaRoute(path) {
|
|
849
|
+
fetch("/chat-link-actions?action=reveal&path=" + encodeURIComponent(path), { method: "POST" }).then(function (response) {
|
|
850
|
+
if (response.ok) return;
|
|
851
|
+
if (response.status === 404) {
|
|
852
|
+
toast("目标文件不存在");
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
revealRemote(path);
|
|
856
|
+
}, function () {
|
|
677
857
|
revealRemote(path);
|
|
678
858
|
});
|
|
679
859
|
}
|
|
680
860
|
|
|
681
|
-
/**
|
|
861
|
+
/** 退路:交给应用打开。 */
|
|
682
862
|
function revealRemote(path) {
|
|
683
863
|
var remote = sessionRemote();
|
|
684
864
|
var session = remote === null ? null : remote.session;
|
|
685
865
|
if (session === null || session === undefined || typeof session.openWorkspacePath !== "function") {
|
|
686
|
-
|
|
866
|
+
toast("无法打开文件管理器");
|
|
687
867
|
return;
|
|
688
868
|
}
|
|
689
869
|
try {
|
|
690
870
|
session.openWorkspacePath({ path: path, action: "reveal" }).then(function (result) {
|
|
691
871
|
if (result !== null && result !== undefined && result.ok === true) return;
|
|
692
|
-
|
|
693
|
-
toast("目标文件不存在");
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
revealViaRoute(path);
|
|
872
|
+
toast(isUnreachablePath(result === null || result === undefined ? null : result.error) ? "目标文件不存在" : "无法打开文件管理器");
|
|
697
873
|
}, function (error) {
|
|
698
|
-
|
|
699
|
-
toast("目标文件不存在");
|
|
700
|
-
return;
|
|
701
|
-
}
|
|
702
|
-
revealViaRoute(path);
|
|
874
|
+
toast(isUnreachablePath(error) ? "目标文件不存在" : "无法打开文件管理器");
|
|
703
875
|
});
|
|
704
876
|
} catch (error) {
|
|
705
|
-
|
|
877
|
+
toast("无法打开文件管理器");
|
|
706
878
|
}
|
|
707
879
|
}
|
|
708
880
|
|
|
@@ -711,25 +883,42 @@ window.__ModuleLoader__.load({
|
|
|
711
883
|
return error !== null && error !== undefined && error.code === "gateway/bad-request";
|
|
712
884
|
}
|
|
713
885
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
886
|
+
/* ── interception ───────────────────────────────────────────────────── */
|
|
887
|
+
|
|
888
|
+
var EDITABLE_SELECTOR = "textarea,input,[contenteditable=''],[contenteditable=true],[contenteditable=plaintext-only]";
|
|
889
|
+
|
|
890
|
+
/** 输入框里右键通常是要粘贴:只有点到引用芯片这类带路径的元素才接管。 */
|
|
891
|
+
function editableField(node) {
|
|
892
|
+
if (node instanceof Element && node.closest(EDITABLE_SELECTOR) !== null) return true;
|
|
893
|
+
return node instanceof Element && node.isContentEditable;
|
|
721
894
|
}
|
|
722
895
|
|
|
723
|
-
|
|
896
|
+
/** 插件自己的故障只记一条日志,绝不让页面上的右键整体失效。 */
|
|
897
|
+
function noteFailure(error) {
|
|
898
|
+
try {
|
|
899
|
+
var context = rootContext;
|
|
900
|
+
if (context !== null && context !== undefined && typeof context.logger?.warn === "function") {
|
|
901
|
+
context.logger.warn("chat-link-actions: menu failed: " + String(error && error.message ? error.message : error));
|
|
902
|
+
}
|
|
903
|
+
} catch (ignored) {
|
|
904
|
+
/* logging must never throw */
|
|
905
|
+
}
|
|
906
|
+
}
|
|
724
907
|
|
|
725
908
|
function onContextMenu(event) {
|
|
726
909
|
if (!(event.target instanceof Element)) return;
|
|
727
910
|
var target = event.target;
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
911
|
+
try {
|
|
912
|
+
var inField = editableField(target);
|
|
913
|
+
var path = pathFromNode(target, { x: event.clientX, y: event.clientY, allowProse: !inField });
|
|
914
|
+
if (path === null) return;
|
|
915
|
+
openMenu(path, event.clientX, event.clientY, { inField: inField, directory: directoryHint(target) });
|
|
916
|
+
event.preventDefault();
|
|
917
|
+
event.stopPropagation();
|
|
918
|
+
} catch (error) {
|
|
919
|
+
closeMenu(false);
|
|
920
|
+
noteFailure(error);
|
|
921
|
+
}
|
|
733
922
|
}
|
|
734
923
|
|
|
735
924
|
/* ── plugin entry ───────────────────────────────────────────────────── */
|
package/index.js
CHANGED
|
@@ -8,38 +8,176 @@
|
|
|
8
8
|
* the argv is never shell-interpolated.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
12
|
-
import { stat } from 'node:fs/promises'
|
|
13
|
-
import { dirname, isAbsolute } from 'node:path'
|
|
11
|
+
import { execFile } from 'node:child_process'
|
|
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 === '' ||
|
|
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
|
-
*
|
|
28
|
-
*
|
|
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
|
|
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
|
|
120
|
+
switch (platform) {
|
|
121
|
+
case 'darwin':
|
|
122
|
+
if (isDirectory && !isSymbolicLink) return { command: 'open', args: [target] }
|
|
123
|
+
return { command: 'open', args: ['-R', target] }
|
|
124
|
+
case 'win32':
|
|
125
|
+
return { command: 'explorer.exe', args: [encodeExplorerSelect(windowsTarget(target))] }
|
|
126
|
+
case 'android':
|
|
127
|
+
return { command: 'termux-open', args: [directoryOf(target, platform)] }
|
|
128
|
+
default:
|
|
129
|
+
return { command: 'xdg-open', args: [directoryOf(target, platform)] }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The argv that only opens the containing directory, used when selection fails. */
|
|
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)
|
|
31
139
|
switch (platform) {
|
|
32
140
|
case 'darwin':
|
|
33
|
-
return { command: 'open', args: [
|
|
141
|
+
return { command: 'open', args: [directory] }
|
|
34
142
|
case 'win32':
|
|
35
|
-
return { command: 'explorer.exe', args: [
|
|
143
|
+
return { command: 'explorer.exe', args: [directory] }
|
|
36
144
|
case 'android':
|
|
37
|
-
return { command: 'termux-open', args: [
|
|
145
|
+
return { command: 'termux-open', args: [directory] }
|
|
38
146
|
default:
|
|
39
|
-
return { command: 'xdg-open', args: [
|
|
147
|
+
return { command: 'xdg-open', args: [directory] }
|
|
40
148
|
}
|
|
41
149
|
}
|
|
42
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
|
+
|
|
168
|
+
/** Run one launcher and resolve its outcome instead of throwing. */
|
|
169
|
+
function run(command, args) {
|
|
170
|
+
return new Promise((resolve) => {
|
|
171
|
+
execFile(command, args, { timeout: LAUNCH_TIMEOUT_MS, windowsHide: true }, (error, stdout, stderr) => {
|
|
172
|
+
if (error === null) {
|
|
173
|
+
resolve({ ok: true })
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
resolve({ ok: false, error, stderr: String(stderr ?? '') })
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
43
181
|
function reply(res, status, body) {
|
|
44
182
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
45
183
|
res.end(JSON.stringify(body))
|
|
@@ -47,24 +185,21 @@ function reply(res, status, body) {
|
|
|
47
185
|
|
|
48
186
|
export function apply(ctx) {
|
|
49
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
|
|
50
191
|
|
|
51
|
-
/**
|
|
52
|
-
const launch = async (path) => {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const timer = setTimeout(() => done('launched'), 1500)
|
|
63
|
-
const child = spawn(command, args, { detached: true, stdio: 'ignore' })
|
|
64
|
-
child.once('spawn', () => done('launched'))
|
|
65
|
-
child.once('error', (error) => done(error))
|
|
66
|
-
child.unref()
|
|
67
|
-
})
|
|
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)}`)
|
|
202
|
+
return { ok: false }
|
|
68
203
|
}
|
|
69
204
|
|
|
70
205
|
ctx.effect(() => ctx.webServer.register({
|
|
@@ -72,7 +207,7 @@ export function apply(ctx) {
|
|
|
72
207
|
path: '/chat-link-actions',
|
|
73
208
|
handler: async (req, res) => {
|
|
74
209
|
if (!fence(req)) {
|
|
75
|
-
|
|
210
|
+
warn('chat-link-actions: reveal request rejected by the connection fence')
|
|
76
211
|
return reply(res, 403, { ok: false, error: 'forbidden' })
|
|
77
212
|
}
|
|
78
213
|
if (req.method !== 'POST') return reply(res, 405, { ok: false, error: 'method' })
|
|
@@ -81,14 +216,16 @@ export function apply(ctx) {
|
|
|
81
216
|
if (url.searchParams.get('action') !== 'reveal') {
|
|
82
217
|
return reply(res, 400, { ok: false, error: 'unknown action' })
|
|
83
218
|
}
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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(),
|
|
91
226
|
}
|
|
227
|
+
const outcome = await launch(target, facts)
|
|
228
|
+
if (!outcome.ok) return reply(res, 500, { ok: false, error: 'launch-failed' })
|
|
92
229
|
return reply(res, 200, { ok: true })
|
|
93
230
|
} catch (error) {
|
|
94
231
|
return reply(res, 400, { ok: false, error: String(error?.message ?? error) })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-chat-link-actions",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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",
|