dsh-chat-link-actions 0.2.2 → 0.2.4
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 +170 -27
- package/index.js +39 -21
- 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`
|
|
17
|
+
右键不是只认一种写法,常见渲染都走同一条解析:`href` / `src` 里的 `?path=`(图片、附件卡片)、`data-path` 之类的绝对路径属性、`title` 里的路径(宿主自绘的文件链接、文件夹链接)、以及直接写在正文里的绝对路径(光标落在路径上才算)。只显示「图标 + 文件名」、DOM 里没有路径的芯片(图片链接、Markdown 图片)也能认:路径从渲染它的组件属性里取。Windows 的 `C:\...` 与含空格、中文的路径同样能认。
|
|
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
|
|
@@ -35,8 +35,6 @@ window.__ModuleLoader__.load({
|
|
|
35
35
|
var drafts = new Map();
|
|
36
36
|
/** Client root context, kept for the session-scoped chip event. */
|
|
37
37
|
var rootContext = null;
|
|
38
|
-
/** 应用提供的服务,用来在文件管理器里定位路径。 */
|
|
39
|
-
var appRemote = null;
|
|
40
38
|
/** Session whose composer rendered most recently. */
|
|
41
39
|
var activeSession = null;
|
|
42
40
|
|
|
@@ -250,6 +248,57 @@ window.__ModuleLoader__.load({
|
|
|
250
248
|
return null;
|
|
251
249
|
}
|
|
252
250
|
|
|
251
|
+
/** 节点上的内部属性键前缀。 */
|
|
252
|
+
var FIBER_KEY = /^__(?:reactFiber|reactInternalInstance)\$/;
|
|
253
|
+
|
|
254
|
+
/** 可能带文件的属性名。 */
|
|
255
|
+
var PROP_KEYS = ["destination", "path", "src", "href"];
|
|
256
|
+
|
|
257
|
+
/** Strip the line suffix a Markdown file link carries (`path#L12`). */
|
|
258
|
+
function stripLineSuffix(value) {
|
|
259
|
+
if (typeof value !== "string") return value;
|
|
260
|
+
return value.replace(/#L[0-9]+(?:-L[0-9]+)?$/u, "");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Read the path a component's props carry, if any. */
|
|
264
|
+
function pathFromProps(props) {
|
|
265
|
+
if (props === null || typeof props !== "object") return null;
|
|
266
|
+
var file = props.file;
|
|
267
|
+
if (file !== null && typeof file === "object") {
|
|
268
|
+
var fromFile = normalizeCandidate(stripLineSuffix(file.path));
|
|
269
|
+
if (fromFile !== null) return fromFile;
|
|
270
|
+
}
|
|
271
|
+
for (var i = 0; i < PROP_KEYS.length; i += 1) {
|
|
272
|
+
var fromProp = normalizeCandidate(stripLineSuffix(props[PROP_KEYS[i]]));
|
|
273
|
+
if (fromProp !== null) return fromProp;
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* React file cards keep their path in the owning component's props, not in
|
|
280
|
+
* the DOM: a Markdown image renders an icon and a file name only. Walk the
|
|
281
|
+
* fiber chain up from the clicked node until a prop names a file.
|
|
282
|
+
*/
|
|
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) {
|
|
286
|
+
if (owner.nodeType !== 1) continue;
|
|
287
|
+
var keys = Object.keys(owner);
|
|
288
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
289
|
+
if (!FIBER_KEY.test(keys[i])) continue;
|
|
290
|
+
fiber = owner[keys[i]];
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (var depth = 0; fiber !== null && fiber !== undefined && depth < 24; depth += 1) {
|
|
295
|
+
var found = pathFromProps(fiber.memoizedProps);
|
|
296
|
+
if (found !== null) return found;
|
|
297
|
+
fiber = fiber.return;
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
253
302
|
/**
|
|
254
303
|
* Walk up from the clicked node looking for a file link: href/src first, then
|
|
255
304
|
* an absolute `data-*` value, then a title, then the element's own text. The
|
|
@@ -267,6 +316,8 @@ window.__ModuleLoader__.load({
|
|
|
267
316
|
var fromTitle = normalizeCandidate(node.getAttribute("title"));
|
|
268
317
|
if (fromTitle !== null) return fromTitle;
|
|
269
318
|
}
|
|
319
|
+
var fromFiber = pathFromFiber(start);
|
|
320
|
+
if (fromFiber !== null) return fromFiber;
|
|
270
321
|
if (point !== null && point !== undefined) {
|
|
271
322
|
var fromCaret = pathFromCaret(point.x, point.y);
|
|
272
323
|
if (fromCaret !== null) return fromCaret;
|
|
@@ -276,10 +327,6 @@ window.__ModuleLoader__.load({
|
|
|
276
327
|
|
|
277
328
|
/* ── wording ────────────────────────────────────────────────────────── */
|
|
278
329
|
|
|
279
|
-
function isMac() {
|
|
280
|
-
return /Mac/i.test(navigator.platform || navigator.userAgent || "");
|
|
281
|
-
}
|
|
282
|
-
|
|
283
330
|
/* ── menu styling: project tokens only, no new palette ──────────────── */
|
|
284
331
|
|
|
285
332
|
var STYLE_ID = "dsh-chat-link-actions-style";
|
|
@@ -487,13 +534,19 @@ window.__ModuleLoader__.load({
|
|
|
487
534
|
|
|
488
535
|
var items = [
|
|
489
536
|
{ id: "quote", label: "引用到对话", icon: "quote", run: function () {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
}, function () {});
|
|
537
|
+
var inserted = quoteIntoComposer(path);
|
|
538
|
+
if (!inserted) {
|
|
539
|
+
navigator.clipboard.writeText(referenceText(path)).then(function () {}, function () {});
|
|
494
540
|
}
|
|
541
|
+
probePath(path, function (kind) {
|
|
542
|
+
if (kind === "missing") {
|
|
543
|
+
toast("引用文件不存在");
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (!inserted) toast("已复制引用");
|
|
547
|
+
});
|
|
495
548
|
} },
|
|
496
|
-
{ id: "reveal", label:
|
|
549
|
+
{ id: "reveal", label: "在文件管理器中查看", icon: "reveal", run: function () {
|
|
497
550
|
reveal(path);
|
|
498
551
|
} },
|
|
499
552
|
{ id: "copy", label: "复制路径", icon: "copy", run: function () {
|
|
@@ -549,30 +602,121 @@ window.__ModuleLoader__.load({
|
|
|
549
602
|
}, 1400);
|
|
550
603
|
}
|
|
551
604
|
|
|
552
|
-
/**
|
|
553
|
-
function
|
|
554
|
-
var
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
605
|
+
/** 当前会话提供的应用服务;没有会话时返回空。 */
|
|
606
|
+
function sessionRemote() {
|
|
607
|
+
var context = sessionContext();
|
|
608
|
+
var remote = context === null ? undefined : context.remote;
|
|
609
|
+
if (remote !== undefined && remote !== null) return remote;
|
|
610
|
+
var root = rootContext === null ? undefined : rootContext.remote;
|
|
611
|
+
return root === undefined || root === null ? null : root;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** 第一条可用会话的上下文,应用的会话服务挂在它上面。 */
|
|
615
|
+
function sessionContext() {
|
|
616
|
+
if (rootContext === null || typeof rootContext.sessions !== "object" || rootContext.sessions === null) return null;
|
|
617
|
+
var candidates = draftCandidates();
|
|
618
|
+
for (var i = 0; i < candidates.length; i += 1) {
|
|
619
|
+
try {
|
|
620
|
+
var binding = rootContext.sessions.binding(candidates[i].sessionId);
|
|
621
|
+
var context = binding === null || binding === undefined ? null : binding.ctx;
|
|
622
|
+
if (context !== null && context !== undefined) return context;
|
|
623
|
+
} catch (error) {
|
|
624
|
+
/* try the next session */
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** 路径是否存在:先当文件问,再当目录问。 */
|
|
631
|
+
function probePath(path, done) {
|
|
632
|
+
var remote = sessionRemote();
|
|
633
|
+
var files = remote === null ? null : remote.workspaceFiles;
|
|
634
|
+
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) {
|
|
637
|
+
done("unknown");
|
|
562
638
|
return;
|
|
563
639
|
}
|
|
564
|
-
|
|
640
|
+
var settled = false;
|
|
641
|
+
var probeTimer = setTimeout(function () {
|
|
642
|
+
finish("unknown");
|
|
643
|
+
}, 900);
|
|
644
|
+
function finish(kind) {
|
|
645
|
+
if (settled) return;
|
|
646
|
+
settled = true;
|
|
647
|
+
clearTimeout(probeTimer);
|
|
648
|
+
done(kind);
|
|
649
|
+
}
|
|
650
|
+
function askDirectory() {
|
|
651
|
+
try {
|
|
652
|
+
files.list(sessionId, path).then(function () {
|
|
653
|
+
finish("present");
|
|
654
|
+
}, function () {
|
|
655
|
+
finish("missing");
|
|
656
|
+
});
|
|
657
|
+
} catch (error) {
|
|
658
|
+
finish("unknown");
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
try {
|
|
662
|
+
files.stat(sessionId, path).then(function () {
|
|
663
|
+
finish("present");
|
|
664
|
+
}, askDirectory);
|
|
665
|
+
} catch (error) {
|
|
666
|
+
askDirectory();
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** 在系统文件管理器里选中路径;路径不存在时直接告知。 */
|
|
671
|
+
function reveal(path) {
|
|
672
|
+
probePath(path, function (kind) {
|
|
673
|
+
if (kind === "missing") {
|
|
674
|
+
toast("目标文件不存在");
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
revealViaRoute(path);
|
|
678
|
+
});
|
|
565
679
|
}
|
|
566
680
|
|
|
567
|
-
/**
|
|
681
|
+
/** 首选:直接请系统文件管理器选中。 */
|
|
568
682
|
function revealViaRoute(path) {
|
|
569
683
|
fetch("/chat-link-actions?action=reveal&path=" + encodeURIComponent(path), { method: "POST" }).then(function (response) {
|
|
570
|
-
if (
|
|
684
|
+
if (response.ok) return;
|
|
685
|
+
if (response.status === 404) {
|
|
686
|
+
toast("目标文件不存在");
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
revealRemote(path);
|
|
571
690
|
}, function () {
|
|
572
|
-
|
|
691
|
+
revealRemote(path);
|
|
573
692
|
});
|
|
574
693
|
}
|
|
575
694
|
|
|
695
|
+
/** 退路:交给应用打开。 */
|
|
696
|
+
function revealRemote(path) {
|
|
697
|
+
var remote = sessionRemote();
|
|
698
|
+
var session = remote === null ? null : remote.session;
|
|
699
|
+
if (session === null || session === undefined || typeof session.openWorkspacePath !== "function") {
|
|
700
|
+
toast("无法打开文件管理器");
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
try {
|
|
704
|
+
session.openWorkspacePath({ path: path, action: "reveal" }).then(function (result) {
|
|
705
|
+
if (result !== null && result !== undefined && result.ok === true) return;
|
|
706
|
+
toast(isUnreachablePath(result === null || result === undefined ? null : result.error) ? "目标文件不存在" : "无法打开文件管理器");
|
|
707
|
+
}, function (error) {
|
|
708
|
+
toast(isUnreachablePath(error) ? "目标文件不存在" : "无法打开文件管理器");
|
|
709
|
+
});
|
|
710
|
+
} catch (error) {
|
|
711
|
+
toast("无法打开文件管理器");
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** 应用对定位不到的路径返回这个结果。 */
|
|
716
|
+
function isUnreachablePath(error) {
|
|
717
|
+
return error !== null && error !== undefined && error.code === "gateway/bad-request";
|
|
718
|
+
}
|
|
719
|
+
|
|
576
720
|
/* ── interception ───────────────────────────────────────────────────── */
|
|
577
721
|
|
|
578
722
|
function onContextMenu(event) {
|
|
@@ -591,7 +735,6 @@ window.__ModuleLoader__.load({
|
|
|
591
735
|
|
|
592
736
|
function apply(ctx) {
|
|
593
737
|
rootContext = ctx;
|
|
594
|
-
appRemote = ctx.remote === undefined ? null : ctx.remote;
|
|
595
738
|
ctx.effect(function () {
|
|
596
739
|
document.addEventListener("contextmenu", onContextMenu, true);
|
|
597
740
|
return function () {
|
package/index.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* the argv is never shell-interpolated.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { execFile } from 'node:child_process'
|
|
12
12
|
import { stat } from 'node:fs/promises'
|
|
13
13
|
import { dirname, isAbsolute } from 'node:path'
|
|
14
14
|
|
|
@@ -40,6 +40,33 @@ function revealCommand(path, platform = process.platform) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** The argv that only opens the containing directory, used when selection fails. */
|
|
44
|
+
function openDirectoryCommand(path, platform = process.platform) {
|
|
45
|
+
switch (platform) {
|
|
46
|
+
case 'darwin':
|
|
47
|
+
return { command: 'open', args: [dirname(path)] }
|
|
48
|
+
case 'win32':
|
|
49
|
+
return { command: 'explorer.exe', args: [dirname(path)] }
|
|
50
|
+
case 'android':
|
|
51
|
+
return { command: 'termux-open', args: [dirname(path)] }
|
|
52
|
+
default:
|
|
53
|
+
return { command: 'xdg-open', args: [dirname(path)] }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Run one launcher and resolve its outcome instead of throwing. */
|
|
58
|
+
function run(command, args) {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
execFile(command, args, { timeout: 3000, windowsHide: true }, (error, stdout, stderr) => {
|
|
61
|
+
if (error === null) {
|
|
62
|
+
resolve({ ok: true })
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
resolve({ ok: false, error, stderr: String(stderr ?? '') })
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
43
70
|
function reply(res, status, body) {
|
|
44
71
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
45
72
|
res.end(JSON.stringify(body))
|
|
@@ -48,23 +75,17 @@ function reply(res, status, body) {
|
|
|
48
75
|
export function apply(ctx) {
|
|
49
76
|
const fence = (req) => ctx.connection.requestRejection(req) === undefined
|
|
50
77
|
|
|
51
|
-
/**
|
|
78
|
+
/** Select the file; when selection is refused, at least open its directory. */
|
|
52
79
|
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
|
-
})
|
|
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)}`)
|
|
88
|
+
return { ok: false }
|
|
68
89
|
}
|
|
69
90
|
|
|
70
91
|
ctx.effect(() => ctx.webServer.register({
|
|
@@ -85,10 +106,7 @@ export function apply(ctx) {
|
|
|
85
106
|
const info = await stat(path).catch(() => null)
|
|
86
107
|
if (info === null) return reply(res, 404, { ok: false, error: 'not-found' })
|
|
87
108
|
const outcome = await launch(path)
|
|
88
|
-
if (outcome
|
|
89
|
-
ctx.logger?.warn?.(`chat-link-actions: file manager launch failed: ${String(outcome?.message ?? outcome)}`)
|
|
90
|
-
return reply(res, 500, { ok: false, error: 'launch-failed' })
|
|
91
|
-
}
|
|
109
|
+
if (!outcome.ok) return reply(res, 500, { ok: false, error: 'launch-failed' })
|
|
92
110
|
return reply(res, 200, { ok: true })
|
|
93
111
|
} catch (error) {
|
|
94
112
|
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.4",
|
|
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",
|