dsh-chat-link-actions 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/client.js +173 -7
- package/index.js +28 -5
- 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`
|
|
17
|
+
右键不是只认一种写法,常见渲染都走同一条解析:`href` / `src` 里的 `?path=`(图片、附件卡片)、`data-path` 之类的绝对路径属性、`title` 里的路径(宿主自绘的文件链接、文件夹链接)、以及直接写在正文里的绝对路径(光标落在路径上才算)。只显示「图标 + 文件名」、DOM 里没有路径的芯片(图片链接、Markdown 图片)也能认:路径从渲染它的组件属性里取。Windows 的 `C:\...` 与含空格、中文的路径同样能认。
|
|
18
18
|
|
|
19
19
|
## 装
|
|
20
20
|
|
package/client.js
CHANGED
|
@@ -248,6 +248,57 @@ window.__ModuleLoader__.load({
|
|
|
248
248
|
return null;
|
|
249
249
|
}
|
|
250
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
|
+
|
|
251
302
|
/**
|
|
252
303
|
* Walk up from the clicked node looking for a file link: href/src first, then
|
|
253
304
|
* an absolute `data-*` value, then a title, then the element's own text. The
|
|
@@ -265,6 +316,8 @@ window.__ModuleLoader__.load({
|
|
|
265
316
|
var fromTitle = normalizeCandidate(node.getAttribute("title"));
|
|
266
317
|
if (fromTitle !== null) return fromTitle;
|
|
267
318
|
}
|
|
319
|
+
var fromFiber = pathFromFiber(start);
|
|
320
|
+
if (fromFiber !== null) return fromFiber;
|
|
268
321
|
if (point !== null && point !== undefined) {
|
|
269
322
|
var fromCaret = pathFromCaret(point.x, point.y);
|
|
270
323
|
if (fromCaret !== null) return fromCaret;
|
|
@@ -485,11 +538,17 @@ window.__ModuleLoader__.load({
|
|
|
485
538
|
|
|
486
539
|
var items = [
|
|
487
540
|
{ id: "quote", label: "引用到对话", icon: "quote", run: function () {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
}, function () {});
|
|
541
|
+
var inserted = quoteIntoComposer(path);
|
|
542
|
+
if (!inserted) {
|
|
543
|
+
navigator.clipboard.writeText(referenceText(path)).then(function () {}, function () {});
|
|
492
544
|
}
|
|
545
|
+
probePath(path, function (kind) {
|
|
546
|
+
if (kind === "missing") {
|
|
547
|
+
toast("引用文件不存在");
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (!inserted) toast("已复制引用");
|
|
551
|
+
});
|
|
493
552
|
} },
|
|
494
553
|
{ id: "reveal", label: isMac() ? "在访达中查看" : "在文件管理器中查看", icon: "reveal", run: function () {
|
|
495
554
|
reveal(path);
|
|
@@ -547,9 +606,116 @@ window.__ModuleLoader__.load({
|
|
|
547
606
|
}, 1400);
|
|
548
607
|
}
|
|
549
608
|
|
|
550
|
-
/**
|
|
609
|
+
/** 当前会话提供的应用服务;没有会话时返回空。 */
|
|
610
|
+
function sessionRemote() {
|
|
611
|
+
var context = sessionContext();
|
|
612
|
+
var remote = context === null ? undefined : context.remote;
|
|
613
|
+
if (remote !== undefined && remote !== null) return remote;
|
|
614
|
+
var root = rootContext === null ? undefined : rootContext.remote;
|
|
615
|
+
return root === undefined || root === null ? null : root;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** 第一条可用会话的上下文,应用的会话服务挂在它上面。 */
|
|
619
|
+
function sessionContext() {
|
|
620
|
+
if (rootContext === null || typeof rootContext.sessions !== "object" || rootContext.sessions === null) return null;
|
|
621
|
+
var candidates = draftCandidates();
|
|
622
|
+
for (var i = 0; i < candidates.length; i += 1) {
|
|
623
|
+
try {
|
|
624
|
+
var binding = rootContext.sessions.binding(candidates[i].sessionId);
|
|
625
|
+
var context = binding === null || binding === undefined ? null : binding.ctx;
|
|
626
|
+
if (context !== null && context !== undefined) return context;
|
|
627
|
+
} catch (error) {
|
|
628
|
+
/* try the next session */
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** 路径是否存在:先当文件问,再当目录问。 */
|
|
635
|
+
function probePath(path, done) {
|
|
636
|
+
var remote = sessionRemote();
|
|
637
|
+
var files = remote === null ? null : remote.workspaceFiles;
|
|
638
|
+
var candidates = draftCandidates();
|
|
639
|
+
var sessionId = candidates.length === 0 ? undefined : candidates[0].sessionId;
|
|
640
|
+
if (files === null || files === undefined || typeof files.stat !== "function" || sessionId === undefined) {
|
|
641
|
+
done("unknown");
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
var settled = false;
|
|
645
|
+
function finish(kind) {
|
|
646
|
+
if (settled) return;
|
|
647
|
+
settled = true;
|
|
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
|
+
/** 在系统文件管理器里选中路径;路径不存在时直接告知。 */
|
|
551
671
|
function reveal(path) {
|
|
552
|
-
|
|
672
|
+
probePath(path, function (kind) {
|
|
673
|
+
if (kind === "missing") {
|
|
674
|
+
toast("目标文件不存在");
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
revealRemote(path);
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** 交给应用去打开;路径定位不到时同样按不存在处理。 */
|
|
682
|
+
function revealRemote(path) {
|
|
683
|
+
var remote = sessionRemote();
|
|
684
|
+
var session = remote === null ? null : remote.session;
|
|
685
|
+
if (session === null || session === undefined || typeof session.openWorkspacePath !== "function") {
|
|
686
|
+
revealViaRoute(path);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
try {
|
|
690
|
+
session.openWorkspacePath({ path: path, action: "reveal" }).then(function (result) {
|
|
691
|
+
if (result !== null && result !== undefined && result.ok === true) return;
|
|
692
|
+
if (isUnreachablePath(result === null || result === undefined ? null : result.error)) {
|
|
693
|
+
toast("目标文件不存在");
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
revealViaRoute(path);
|
|
697
|
+
}, function (error) {
|
|
698
|
+
if (isUnreachablePath(error)) {
|
|
699
|
+
toast("目标文件不存在");
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
revealViaRoute(path);
|
|
703
|
+
});
|
|
704
|
+
} catch (error) {
|
|
705
|
+
revealViaRoute(path);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** 应用对定位不到的路径返回这个结果。 */
|
|
710
|
+
function isUnreachablePath(error) {
|
|
711
|
+
return error !== null && error !== undefined && error.code === "gateway/bad-request";
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** 本地通道:请应用在系统文件管理器里选中路径。 */
|
|
715
|
+
function revealViaRoute(path) {
|
|
716
|
+
fetch("/chat-link-actions?action=reveal&path=" + encodeURIComponent(path), { method: "POST" }).then(function (response) {
|
|
717
|
+
if (!response.ok) toast("无法打开文件管理器");
|
|
718
|
+
}, function () {
|
|
553
719
|
toast("无法打开文件管理器");
|
|
554
720
|
});
|
|
555
721
|
}
|
|
@@ -568,7 +734,7 @@ window.__ModuleLoader__.load({
|
|
|
568
734
|
|
|
569
735
|
/* ── plugin entry ───────────────────────────────────────────────────── */
|
|
570
736
|
|
|
571
|
-
var inject = ["slots", "sessions"];
|
|
737
|
+
var inject = ["slots", "sessions", "remote", "remote.session"];
|
|
572
738
|
|
|
573
739
|
function apply(ctx) {
|
|
574
740
|
rootContext = ctx;
|
package/index.js
CHANGED
|
@@ -48,11 +48,33 @@ function reply(res, status, body) {
|
|
|
48
48
|
export function apply(ctx) {
|
|
49
49
|
const fence = (req) => ctx.connection.requestRejection(req) === undefined
|
|
50
50
|
|
|
51
|
+
/** Start the file manager and report whether the launcher accepted the command. */
|
|
52
|
+
const launch = async (path) => {
|
|
53
|
+
const { command, args } = revealCommand(path)
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
let settled = false
|
|
56
|
+
const done = (value) => {
|
|
57
|
+
if (settled) return
|
|
58
|
+
settled = true
|
|
59
|
+
clearTimeout(timer)
|
|
60
|
+
resolve(value)
|
|
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
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
ctx.effect(() => ctx.webServer.register({
|
|
52
71
|
kind: 'prefix',
|
|
53
72
|
path: '/chat-link-actions',
|
|
54
73
|
handler: async (req, res) => {
|
|
55
|
-
if (!fence(req))
|
|
74
|
+
if (!fence(req)) {
|
|
75
|
+
ctx.logger?.warn?.('chat-link-actions: reveal request rejected by the connection fence')
|
|
76
|
+
return reply(res, 403, { ok: false, error: 'forbidden' })
|
|
77
|
+
}
|
|
56
78
|
if (req.method !== 'POST') return reply(res, 405, { ok: false, error: 'method' })
|
|
57
79
|
try {
|
|
58
80
|
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
|
@@ -62,10 +84,11 @@ export function apply(ctx) {
|
|
|
62
84
|
const path = requireAbsolute(url.searchParams.get('path'))
|
|
63
85
|
const info = await stat(path).catch(() => null)
|
|
64
86
|
if (info === null) return reply(res, 404, { ok: false, error: 'not-found' })
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
87
|
+
const outcome = await launch(path)
|
|
88
|
+
if (outcome !== 'launched') {
|
|
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
|
+
}
|
|
69
92
|
return reply(res, 200, { ok: true })
|
|
70
93
|
} catch (error) {
|
|
71
94
|
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.3",
|
|
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",
|