dsh-chat-link-actions 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +40 -2
- package/client.js +596 -0
- package/cordis.patch.yml +7 -0
- package/icon.png +0 -0
- package/index.js +75 -0
- package/package.json +50 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 regrevia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-chat-link-actions(对话里的文件链接,右键就有动作)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**对话里的文件路径,右键就能引用回输入框,也能在访达里直接定位。菜单是 App 自己画的,不是系统弹窗。**
|
|
4
|
+
|
|
5
|
+
长任务跑完,记录里一串路径;想引用回去得手打,想在访达里找到又是另一回事。这个插件把右键菜单接到对话里的文件链接上。
|
|
6
|
+
|
|
7
|
+
## 三个动作
|
|
8
|
+
|
|
9
|
+
| 右键文件链接 | 做什么 |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| **引用到对话** | 插成输入框里的引用芯片(`(</> 文件名)`),和拖文件、`@` 选文件的结果一样 |
|
|
12
|
+
| **在访达中查看** | 打开所在目录并选中这个文件(Windows 用资源管理器;Linux 打开所在目录) |
|
|
13
|
+
| **复制路径** | 绝对路径进剪贴板,直接粘给别的窗口 |
|
|
14
|
+
|
|
15
|
+
## 认哪些链接
|
|
16
|
+
|
|
17
|
+
右键不是只认一种写法,常见渲染都走同一条解析:`href` / `src` 里的 `?path=`(图片、附件卡片)、`data-path` 之类的绝对路径属性、`title` 里的路径(宿主自绘的文件链接、文件夹链接)、以及直接写在正文里的绝对路径(光标落在路径上才算)。Windows 的 `C:\...` 与含空格、中文的路径同样能认。
|
|
18
|
+
|
|
19
|
+
## 装
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
dsh plugin install dsh-chat-link-actions
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 用
|
|
26
|
+
|
|
27
|
+
鼠标停在对话里的文件链接上,右键。菜单跟着 App 的深浅色和圆角走;Esc 或点别处关掉。
|
|
28
|
+
|
|
29
|
+
点下菜单之后,如果你又改了输入框或换了会话,插入会停下来不覆盖已写的内容——重新右键一次即可。
|
|
30
|
+
|
|
31
|
+
芯片由宿主渲染:图和视频进的是附件,文件夹显示成 `文件夹名/`,其他文件按扩展名走同一个芯片。
|
|
32
|
+
|
|
33
|
+
## 不做什么
|
|
34
|
+
|
|
35
|
+
- 只认本机存在的绝对路径:网络路径、已删掉的文件,会直接说找不到。
|
|
36
|
+
- 不上传、不打开编辑器;「在访达中查看」只让系统文件管理器选中文件,不修改它。
|
|
37
|
+
- 不改对话内容:插入是一次正常的输入框编辑,可以撤销,也会进草稿。
|
|
38
|
+
|
|
39
|
+
## 给 agent 的话
|
|
40
|
+
|
|
41
|
+
要用户引用文件时,照常写绝对路径即可;用户可以用右键引用,不需要另换写法。
|
package/client.js
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* dsh-chat-link-actions — client half.
|
|
3
|
+
*
|
|
4
|
+
* Right-click menu for file links in the transcript, drawn in the app's own
|
|
5
|
+
* style (theme tokens, radius, elevation, blur) instead of the WebView's native
|
|
6
|
+
* menu. Two actions:
|
|
7
|
+
*
|
|
8
|
+
* 引用到对话 insert `@path` into the composer as one undoable edit
|
|
9
|
+
* 在访达/资源管理器中查看 reveal the file in the OS file manager (服务端接口)
|
|
10
|
+
*
|
|
11
|
+
* Wiring:
|
|
12
|
+
* - `inputActions` is a standard prop of session-scoped slots
|
|
13
|
+
* (`ctx.uiSession.provide({ props: ["inputActions"] })` in ui-conversation).
|
|
14
|
+
* A zero-size occupant of `conversation.input.overlay` captures it per
|
|
15
|
+
* session; the menu uses `captureInsertion()` + `insertText(text, span)`,
|
|
16
|
+
* which only lands while the captured `draftRev` is still current.
|
|
17
|
+
* - Revealing needs process access, so it runs in the 服务端半
|
|
18
|
+
* (`POST /chat-link-actions`).
|
|
19
|
+
*
|
|
20
|
+
* Bundle format: the official client-bundle shape — a lazy-CJS closure
|
|
21
|
+
* registered with window.__ModuleLoader__.load({ id, factory }).
|
|
22
|
+
*/
|
|
23
|
+
window.__ModuleLoader__.load({
|
|
24
|
+
id: "dsh-chat-link-actions",
|
|
25
|
+
factory: (require) => {
|
|
26
|
+
var module = { exports: {} };
|
|
27
|
+
var exports = module.exports;
|
|
28
|
+
"use strict";
|
|
29
|
+
|
|
30
|
+
var react = require("react");
|
|
31
|
+
|
|
32
|
+
/* ── composer draft handles ─────────────────────────────────────────── */
|
|
33
|
+
|
|
34
|
+
/** SessionId -> inputActions for that session's composer. */
|
|
35
|
+
var drafts = new Map();
|
|
36
|
+
/** Client root context, kept for the session-scoped chip event. */
|
|
37
|
+
var rootContext = null;
|
|
38
|
+
/** Session whose composer rendered most recently. */
|
|
39
|
+
var activeSession = null;
|
|
40
|
+
|
|
41
|
+
function readInputActions(props) {
|
|
42
|
+
if (props === null || typeof props !== "object") return null;
|
|
43
|
+
var candidates = [props.inputActions, props.actions, props.input && props.input.actions];
|
|
44
|
+
for (var i = 0; i < candidates.length; i += 1) {
|
|
45
|
+
var candidate = candidates[i];
|
|
46
|
+
if (candidate !== null && typeof candidate === "object" && typeof candidate.insertText === "function") {
|
|
47
|
+
return candidate;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Invisible occupant: remembers the composer handle of the session it rendered for. */
|
|
54
|
+
function DraftCapture(props) {
|
|
55
|
+
var actions = readInputActions(props);
|
|
56
|
+
var sessionId = props.sessionId;
|
|
57
|
+
if (actions !== null) {
|
|
58
|
+
if (sessionId !== undefined) drafts.set(sessionId, actions);
|
|
59
|
+
activeSession = sessionId === undefined ? activeSession : sessionId;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function draftCandidates() {
|
|
65
|
+
var ordered = [];
|
|
66
|
+
var seen = [];
|
|
67
|
+
function add(sessionId) {
|
|
68
|
+
if (sessionId === undefined || sessionId === null) return;
|
|
69
|
+
var actions = drafts.get(sessionId);
|
|
70
|
+
if (actions === undefined || seen.indexOf(actions) !== -1) return;
|
|
71
|
+
seen.push(actions);
|
|
72
|
+
ordered.push({ sessionId: sessionId, actions: actions });
|
|
73
|
+
}
|
|
74
|
+
add(activeSession);
|
|
75
|
+
drafts.forEach(function (_actions, sessionId) {
|
|
76
|
+
add(sessionId);
|
|
77
|
+
});
|
|
78
|
+
return ordered;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `@path`; a path containing whitespace uses the quoted mention form. */
|
|
82
|
+
function referenceText(path) {
|
|
83
|
+
return /[\s"]/.test(path) ? '@"' + path.replace(/"/g, '\\"') + '"' : "@" + path;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Directory links carry a trailing separator; files never do. */
|
|
87
|
+
function looksLikeDirectory(path) {
|
|
88
|
+
return path.charAt(path.length - 1) === "/" || path.charAt(path.length - 1) === "\\";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Last path segment, the same label the composer's own reference chips show. */
|
|
92
|
+
function displayLabel(path, directory) {
|
|
93
|
+
var trimmed = path.replace(/[\\/]+$/, "");
|
|
94
|
+
var cut = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
|
|
95
|
+
var name = cut === -1 ? trimmed : trimmed.slice(cut + 1);
|
|
96
|
+
if (name === "") name = trimmed;
|
|
97
|
+
return directory ? name + "/" : name;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Insert the reference as the composer's own reference chip (the `(</> name)`
|
|
102
|
+
* node), which the shell only builds for its file-drop and `@`-picker paths.
|
|
103
|
+
* The shell owns that insertion behind a session-scoped event; when the event
|
|
104
|
+
* is not reachable the caller falls back to plain `insertText`.
|
|
105
|
+
*/
|
|
106
|
+
function insertChip(sessionId, actions, path) {
|
|
107
|
+
if (rootContext === null || typeof rootContext.sessions !== "object" || rootContext.sessions === null) return false;
|
|
108
|
+
var binding = null;
|
|
109
|
+
try {
|
|
110
|
+
binding = rootContext.sessions.binding(sessionId);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
var sessionContext = binding === null || binding === undefined ? null : binding.ctx;
|
|
115
|
+
if (sessionContext === null || sessionContext === undefined || typeof sessionContext.emit !== "function") return false;
|
|
116
|
+
var before = actions.captureInsertion();
|
|
117
|
+
if (before === null || before === undefined) return false;
|
|
118
|
+
var directory = looksLikeDirectory(path);
|
|
119
|
+
var mention = referenceText(path);
|
|
120
|
+
var reference = {};
|
|
121
|
+
reference.source = "reference";
|
|
122
|
+
reference.ref = mention;
|
|
123
|
+
reference.label = displayLabel(path, directory);
|
|
124
|
+
reference.appearance = directory ? "folder" : "file";
|
|
125
|
+
reference.clipboardText = mention;
|
|
126
|
+
var request = {};
|
|
127
|
+
request.reference = reference;
|
|
128
|
+
request.span = before;
|
|
129
|
+
var delivered = false;
|
|
130
|
+
try {
|
|
131
|
+
delivered = sessionContext.emit("slash/input-insert-reference", request);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
if (delivered === false) return false;
|
|
136
|
+
var after = actions.captureInsertion();
|
|
137
|
+
if (after === null || after === undefined) return true;
|
|
138
|
+
return after.start !== before.start || after.end !== before.end;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Insert the reference into a live composer. Returns true on success. */
|
|
142
|
+
function quoteIntoComposer(path) {
|
|
143
|
+
var candidates = draftCandidates();
|
|
144
|
+
for (var i = 0; i < candidates.length; i += 1) {
|
|
145
|
+
var sessionId = candidates[i].sessionId;
|
|
146
|
+
var actions = candidates[i].actions;
|
|
147
|
+
try {
|
|
148
|
+
if (typeof actions.captureInsertion !== "function") continue;
|
|
149
|
+
if (insertChip(sessionId, actions, path)) return true;
|
|
150
|
+
var span = actions.captureInsertion();
|
|
151
|
+
if (span === null || span === undefined) continue;
|
|
152
|
+
if (actions.insertText(referenceText(path) + " ", span) === true) return true;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
/* try the next session's composer */
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* ── resolving a file path out of the clicked node ──────────────────── */
|
|
161
|
+
|
|
162
|
+
/** Trim a candidate down to the bare file path a link carries. */
|
|
163
|
+
function normalizeCandidate(value) {
|
|
164
|
+
if (typeof value !== "string") return null;
|
|
165
|
+
var candidate = value.trim();
|
|
166
|
+
if (candidate === "") return null;
|
|
167
|
+
if (candidate.charAt(0) === "@") candidate = candidate.slice(1).trim();
|
|
168
|
+
if (candidate.length > 1 && /^["']/.test(candidate) && /["']$/.test(candidate)) {
|
|
169
|
+
candidate = candidate.slice(1, -1);
|
|
170
|
+
}
|
|
171
|
+
candidate = candidate.replace(/[.,;:!?,。;:!?]+$/u, "");
|
|
172
|
+
candidate = candidate.replace(/^file:\/\//, "/");
|
|
173
|
+
if (/^[A-Za-z]:(?!\/)/.test(candidate)) candidate = candidate.slice(0, 2) + "/" + candidate.slice(2);
|
|
174
|
+
candidate = candidate.replace(/^([A-Za-z]):\//, "$1:/");
|
|
175
|
+
return looksAbsolute(candidate) ? candidate : null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function looksAbsolute(value) {
|
|
179
|
+
return typeof value === "string" && (value.charAt(0) === "/" || /^[A-Za-z]:[\\/]/.test(value));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Highest-confidence marker: the file route an inline link or image points at. */
|
|
183
|
+
function decodePathFromHref(href) {
|
|
184
|
+
if (typeof href !== "string" || href === "") return null;
|
|
185
|
+
try {
|
|
186
|
+
var url = new URL(href, document.baseURI);
|
|
187
|
+
var fromQuery = normalizeCandidate(url.searchParams.get("path"));
|
|
188
|
+
if (fromQuery !== null) return fromQuery;
|
|
189
|
+
for (var i = 0; i < ["file", "filepath", "abs", "target"].length; i += 1) {
|
|
190
|
+
var fromNamed = normalizeCandidate(url.searchParams.get(["file", "filepath", "abs", "target"][i]));
|
|
191
|
+
if (fromNamed !== null) return fromNamed;
|
|
192
|
+
}
|
|
193
|
+
if (url.protocol === "file:") return normalizeCandidate(decodeURIComponent(url.pathname));
|
|
194
|
+
if (url.origin === location.origin && looksAbsolute(url.pathname) && /\.[A-Za-z0-9]{1,6}$/.test(url.pathname)) {
|
|
195
|
+
return normalizeCandidate(decodeURIComponent(url.pathname));
|
|
196
|
+
}
|
|
197
|
+
} catch (error) {
|
|
198
|
+
/* not a URL shape we handle */
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Walk up from the clicked node looking for a link that names a file: href
|
|
205
|
+
* first, then an absolute `data-*` value, then the reference chip title.
|
|
206
|
+
*/
|
|
207
|
+
var PATH_ATTRS = ["href", "src"];
|
|
208
|
+
|
|
209
|
+
/** `data-*` values that may carry a path, in confidence order. */
|
|
210
|
+
function datasetPath(node) {
|
|
211
|
+
var dataset = node.dataset || {};
|
|
212
|
+
var keys = Object.keys(dataset);
|
|
213
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
214
|
+
if (!/path|file|href|src|dir|folder|abs/i.test(keys[i])) continue;
|
|
215
|
+
var candidate = normalizeCandidate(dataset[keys[i]]);
|
|
216
|
+
if (candidate !== null) return candidate;
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** An absolute path written as plain text inside the clicked element. */
|
|
222
|
+
var PATH_IN_TEXT = /(?:^|[\s"'“”「」()()\[\]])(@?"?(?:\/|[A-Za-z]:[\\/])[^\s"'“”「」<>,。;:!?]*)/u;
|
|
223
|
+
|
|
224
|
+
/** 正文纯文本里,取光标落点所在的那段路径。 */
|
|
225
|
+
function pathFromCaret(x, y) {
|
|
226
|
+
if (typeof document.caretRangeFromPoint !== "function") return null;
|
|
227
|
+
var range = null;
|
|
228
|
+
try {
|
|
229
|
+
range = document.caretRangeFromPoint(x, y);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
if (range === null || range.startContainer === null || range.startContainer.nodeType !== 3) return null;
|
|
234
|
+
var data = range.startContainer.data;
|
|
235
|
+
var offset = Math.min(range.startOffset, data.length);
|
|
236
|
+
var bounds = [];
|
|
237
|
+
var matcher = new RegExp(PATH_IN_TEXT.source, "gu");
|
|
238
|
+
var hit = null;
|
|
239
|
+
while ((hit = matcher.exec(data)) !== null) {
|
|
240
|
+
bounds.push({ text: hit[0], start: hit.index, end: hit.index + hit[0].length });
|
|
241
|
+
if (hit[0].length === 0) matcher.lastIndex += 1;
|
|
242
|
+
}
|
|
243
|
+
for (var i = 0; i < bounds.length; i += 1) {
|
|
244
|
+
if (offset >= bounds[i].start && offset <= bounds[i].end) {
|
|
245
|
+
return normalizeCandidate(bounds[i].text);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Walk up from the clicked node looking for a file link: href/src first, then
|
|
253
|
+
* an absolute `data-*` value, then a title, then the element's own text. The
|
|
254
|
+
* text pass covers paths the transcript renders as plain prose.
|
|
255
|
+
*/
|
|
256
|
+
function pathFromNode(start, point) {
|
|
257
|
+
for (var node = start; node !== null && node !== undefined && node !== document.body; node = node.parentElement) {
|
|
258
|
+
if (node.nodeType !== 1) continue;
|
|
259
|
+
for (var a = 0; a < PATH_ATTRS.length; a += 1) {
|
|
260
|
+
var fromHref = decodePathFromHref(node.getAttribute(PATH_ATTRS[a]));
|
|
261
|
+
if (fromHref !== null) return fromHref;
|
|
262
|
+
}
|
|
263
|
+
var fromData = datasetPath(node);
|
|
264
|
+
if (fromData !== null) return fromData;
|
|
265
|
+
var fromTitle = normalizeCandidate(node.getAttribute("title"));
|
|
266
|
+
if (fromTitle !== null) return fromTitle;
|
|
267
|
+
}
|
|
268
|
+
if (point !== null && point !== undefined) {
|
|
269
|
+
var fromCaret = pathFromCaret(point.x, point.y);
|
|
270
|
+
if (fromCaret !== null) return fromCaret;
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/* ── wording ────────────────────────────────────────────────────────── */
|
|
276
|
+
|
|
277
|
+
function isMac() {
|
|
278
|
+
return /Mac/i.test(navigator.platform || navigator.userAgent || "");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* ── menu styling: project tokens only, no new palette ──────────────── */
|
|
282
|
+
|
|
283
|
+
var STYLE_ID = "dsh-chat-link-actions-style";
|
|
284
|
+
var CSS = [
|
|
285
|
+
".dcla-layer{position:fixed;inset:0;z-index:1100;pointer-events:none;}",
|
|
286
|
+
".dcla-menu{position:fixed;z-index:1100;pointer-events:auto;box-sizing:border-box;padding:6px;min-width:200px;",
|
|
287
|
+
"max-width:min(320px,calc(100vw - 24px));background:var(--dsw-specific-menu,rgba(30,32,38,.96));",
|
|
288
|
+
"backdrop-filter:var(--dsw-menu-backdrop-filter,blur(20px) saturate(180%));",
|
|
289
|
+
"-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(20px) saturate(180%));",
|
|
290
|
+
"border:1px solid var(--dsw-elevation-stroke-color,rgba(255,255,255,.08));",
|
|
291
|
+
"border-radius:var(--dsw-radius-lg,12px);box-shadow:var(--dsw-elevation-prominent,0 12px 32px rgba(0,0,0,.4));",
|
|
292
|
+
"font-family:var(--dsw-font-family,system-ui),-apple-system,\"PingFang SC\",\"Microsoft YaHei\",sans-serif;",
|
|
293
|
+
"font-size:var(--dsw-font-xs-13,13px);line-height:20px;",
|
|
294
|
+
"color:var(--dsw-alias-label-primary,#f2f3f5);user-select:none;-webkit-user-select:none;}",
|
|
295
|
+
".dcla-path{padding:2px 8px 6px;color:var(--dsw-alias-label-tertiary,rgba(242,243,245,.45));",
|
|
296
|
+
"font-size:var(--dsw-font-xxs-12,12px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;direction:rtl;text-align:left;}",
|
|
297
|
+
".dcla-sep{height:1px;margin:2px 6px 6px;background:var(--dsw-alias-border-l2,rgba(255,255,255,.08));}",
|
|
298
|
+
".dcla-item{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;padding:6px 8px;",
|
|
299
|
+
"border:0;border-radius:var(--dsw-radius-md,8px);background:transparent;color:inherit;font:inherit;",
|
|
300
|
+
"text-align:left;cursor:default;white-space:nowrap;overflow:hidden;}",
|
|
301
|
+
".dcla-item[data-active=true]{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08));}",
|
|
302
|
+
".dcla-item:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,#4c8dff);outline-offset:-1px;}",
|
|
303
|
+
".dcla-glyph{flex:none;display:inline-flex;width:16px;height:16px;align-items:center;justify-content:center;",
|
|
304
|
+
"color:var(--dsw-alias-label-secondary,rgba(242,243,245,.65));}",
|
|
305
|
+
".dcla-label{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;}",
|
|
306
|
+
".dcla-toast{position:fixed;left:50%;bottom:32px;transform:translateX(-50%);z-index:1100;",
|
|
307
|
+
"padding:6px 12px;border-radius:var(--dsw-radius-md,8px);background:var(--dsw-elevation-panel,rgba(30,32,38,.96));",
|
|
308
|
+
"border:1px solid var(--dsw-elevation-stroke-color,rgba(255,255,255,.08));color:var(--dsw-alias-label-primary,#f2f3f5);",
|
|
309
|
+
"font:inherit;font-size:var(--dsw-font-xs-13,13px);box-shadow:var(--dsw-elevation-panel,0 8px 24px rgba(0,0,0,.3));}",
|
|
310
|
+
"@media (prefers-reduced-motion:no-preference){.dcla-menu{animation:dcla-in 90ms ease-out;}}",
|
|
311
|
+
"@keyframes dcla-in{from{opacity:0;transform:translateY(-2px);}to{opacity:1;transform:none;}}"
|
|
312
|
+
].join("");
|
|
313
|
+
|
|
314
|
+
function ensureStyle() {
|
|
315
|
+
if (document.getElementById(STYLE_ID) !== null) return;
|
|
316
|
+
var style = document.createElement("style");
|
|
317
|
+
style.id = STYLE_ID;
|
|
318
|
+
style.textContent = CSS;
|
|
319
|
+
document.head.appendChild(style);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/* ── icons: 16px outline, currentColor ──────────────────────────────── */
|
|
323
|
+
|
|
324
|
+
var ICONS = {
|
|
325
|
+
quote: ["M2.5 4.5h11M2.5 8h7M2.5 11.5h9"],
|
|
326
|
+
reveal: ["M2 5.7A1.6 1.6 0 0 1 3.6 4.1h2.2l1.2 1.5h4.6A1.6 1.6 0 0 1 13.2 7.2v4.6A1.6 1.6 0 0 1 11.6 13.4h-8A1.6 1.6 0 0 1 2 11.8V5.7Z"],
|
|
327
|
+
copy: ["M5.6 5.6h6.8v6.8H5.6z", "M10.4 5.6V4.2A1.6 1.6 0 0 0 8.8 2.6H4.2A1.6 1.6 0 0 0 2.6 4.2v4.6A1.6 1.6 0 0 0 4.2 10.4h1.4"]
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
function icon(name) {
|
|
331
|
+
var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
332
|
+
svg.setAttribute("width", "16");
|
|
333
|
+
svg.setAttribute("height", "16");
|
|
334
|
+
svg.setAttribute("viewBox", "0 0 16 16");
|
|
335
|
+
svg.setAttribute("fill", "none");
|
|
336
|
+
svg.setAttribute("aria-hidden", "true");
|
|
337
|
+
var paths = ICONS[name];
|
|
338
|
+
for (var i = 0; i < paths.length; i += 1) {
|
|
339
|
+
var path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
340
|
+
path.setAttribute("d", paths[i]);
|
|
341
|
+
path.setAttribute("stroke", "currentColor");
|
|
342
|
+
path.setAttribute("stroke-width", "1.3");
|
|
343
|
+
path.setAttribute("stroke-linecap", "round");
|
|
344
|
+
path.setAttribute("stroke-linejoin", "round");
|
|
345
|
+
svg.appendChild(path);
|
|
346
|
+
}
|
|
347
|
+
return svg;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/* ── menu ───────────────────────────────────────────────────────────── */
|
|
351
|
+
|
|
352
|
+
var open = null;
|
|
353
|
+
|
|
354
|
+
function closeMenu(restoreFocus) {
|
|
355
|
+
if (open === null) return;
|
|
356
|
+
var state = open;
|
|
357
|
+
open = null;
|
|
358
|
+
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
359
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
360
|
+
document.removeEventListener("contextmenu", onContextDuringMenu, true);
|
|
361
|
+
window.removeEventListener("resize", onViewportChange, true);
|
|
362
|
+
window.removeEventListener("scroll", onViewportChange, true);
|
|
363
|
+
if (state.layer.parentNode !== null) state.layer.parentNode.removeChild(state.layer);
|
|
364
|
+
if (restoreFocus === true && state.restoreFocus !== null && document.contains(state.restoreFocus)) {
|
|
365
|
+
state.restoreFocus.focus({ preventScroll: true });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function onPointerDown(event) {
|
|
370
|
+
if (open === null || open.layer.contains(event.target)) return;
|
|
371
|
+
closeMenu(false);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function onContextDuringMenu(event) {
|
|
375
|
+
if (open !== null && open.layer.contains(event.target)) {
|
|
376
|
+
event.preventDefault();
|
|
377
|
+
event.stopPropagation();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function onKeyDown(event) {
|
|
382
|
+
if (open === null) return;
|
|
383
|
+
if (event.key === "Escape") {
|
|
384
|
+
event.preventDefault();
|
|
385
|
+
event.stopPropagation();
|
|
386
|
+
closeMenu(true);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
390
|
+
event.preventDefault();
|
|
391
|
+
event.stopPropagation();
|
|
392
|
+
var count = open.items.length;
|
|
393
|
+
var step = event.key === "ArrowDown" ? 1 : -1;
|
|
394
|
+
open.index = ((open.index + step) % count + count) % count;
|
|
395
|
+
paintActive();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
399
|
+
var item = open.items[open.index];
|
|
400
|
+
if (item === undefined) return;
|
|
401
|
+
event.preventDefault();
|
|
402
|
+
event.stopPropagation();
|
|
403
|
+
closeMenu(false);
|
|
404
|
+
item.run();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function onViewportChange() {
|
|
409
|
+
if (open === null) return;
|
|
410
|
+
placeMenu(open.panel, open.x, open.y);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Place the panel at the pointer, flipped inward at the viewport edges. */
|
|
414
|
+
function placeMenu(panel, x, y) {
|
|
415
|
+
var margin = 8;
|
|
416
|
+
var rect = panel.getBoundingClientRect();
|
|
417
|
+
var left = x;
|
|
418
|
+
var top = y;
|
|
419
|
+
if (left + rect.width > window.innerWidth - margin) left = Math.max(margin, x - rect.width);
|
|
420
|
+
if (top + rect.height > window.innerHeight - margin) top = Math.max(margin, y - rect.height);
|
|
421
|
+
panel.style.left = Math.round(left) + "px";
|
|
422
|
+
panel.style.top = Math.round(top) + "px";
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function paintActive() {
|
|
426
|
+
if (open === null) return;
|
|
427
|
+
for (var i = 0; i < open.buttons.length; i += 1) {
|
|
428
|
+
open.buttons[i].setAttribute("data-active", i === open.index ? "true" : "false");
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function buildMenu(path, items) {
|
|
433
|
+
var panel = document.createElement("div");
|
|
434
|
+
panel.className = "dcla-menu";
|
|
435
|
+
panel.setAttribute("role", "menu");
|
|
436
|
+
panel.setAttribute("aria-label", "文件操作");
|
|
437
|
+
|
|
438
|
+
var caption = document.createElement("div");
|
|
439
|
+
caption.className = "dcla-path";
|
|
440
|
+
caption.title = path;
|
|
441
|
+
caption.textContent = path;
|
|
442
|
+
panel.appendChild(caption);
|
|
443
|
+
|
|
444
|
+
var separator = document.createElement("div");
|
|
445
|
+
separator.className = "dcla-sep";
|
|
446
|
+
panel.appendChild(separator);
|
|
447
|
+
|
|
448
|
+
var buttons = [];
|
|
449
|
+
items.forEach(function (item, position) {
|
|
450
|
+
var button = document.createElement("button");
|
|
451
|
+
button.type = "button";
|
|
452
|
+
button.className = "dcla-item";
|
|
453
|
+
button.setAttribute("role", "menuitem");
|
|
454
|
+
button.setAttribute("data-active", position === 0 ? "true" : "false");
|
|
455
|
+
|
|
456
|
+
var glyph = document.createElement("span");
|
|
457
|
+
glyph.className = "dcla-glyph";
|
|
458
|
+
glyph.appendChild(icon(item.icon));
|
|
459
|
+
button.appendChild(glyph);
|
|
460
|
+
|
|
461
|
+
var label = document.createElement("span");
|
|
462
|
+
label.className = "dcla-label";
|
|
463
|
+
label.textContent = item.label;
|
|
464
|
+
button.appendChild(label);
|
|
465
|
+
|
|
466
|
+
button.addEventListener("mouseenter", function () {
|
|
467
|
+
if (open === null) return;
|
|
468
|
+
open.index = position;
|
|
469
|
+
paintActive();
|
|
470
|
+
});
|
|
471
|
+
button.addEventListener("click", function () {
|
|
472
|
+
closeMenu(false);
|
|
473
|
+
item.run();
|
|
474
|
+
});
|
|
475
|
+
panel.appendChild(button);
|
|
476
|
+
buttons.push(button);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
return { panel: panel, buttons: buttons };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function openMenu(path, x, y) {
|
|
483
|
+
closeMenu(false);
|
|
484
|
+
ensureStyle();
|
|
485
|
+
|
|
486
|
+
var items = [
|
|
487
|
+
{ id: "quote", label: "引用到对话", icon: "quote", run: function () {
|
|
488
|
+
if (!quoteIntoComposer(path)) {
|
|
489
|
+
navigator.clipboard.writeText(referenceText(path)).then(function () {
|
|
490
|
+
toast("已复制引用");
|
|
491
|
+
}, function () {});
|
|
492
|
+
}
|
|
493
|
+
} },
|
|
494
|
+
{ id: "reveal", label: isMac() ? "在访达中查看" : "在文件管理器中查看", icon: "reveal", run: function () {
|
|
495
|
+
reveal(path);
|
|
496
|
+
} },
|
|
497
|
+
{ id: "copy", label: "复制路径", icon: "copy", run: function () {
|
|
498
|
+
navigator.clipboard.writeText(path).then(function () {
|
|
499
|
+
toast("已复制路径");
|
|
500
|
+
}, function () {});
|
|
501
|
+
} }
|
|
502
|
+
];
|
|
503
|
+
|
|
504
|
+
var layer = document.createElement("div");
|
|
505
|
+
layer.className = "dcla-layer";
|
|
506
|
+
var built = buildMenu(path, items);
|
|
507
|
+
layer.appendChild(built.panel);
|
|
508
|
+
document.body.appendChild(layer);
|
|
509
|
+
|
|
510
|
+
var state = {};
|
|
511
|
+
state.layer = layer;
|
|
512
|
+
state.panel = built.panel;
|
|
513
|
+
state.buttons = built.buttons;
|
|
514
|
+
state.items = items;
|
|
515
|
+
state.index = 0;
|
|
516
|
+
state.path = path;
|
|
517
|
+
state.x = x;
|
|
518
|
+
state.y = y;
|
|
519
|
+
state.restoreFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
520
|
+
open = state;
|
|
521
|
+
placeMenu(built.panel, x, y);
|
|
522
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
523
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
524
|
+
document.addEventListener("contextmenu", onContextDuringMenu, true);
|
|
525
|
+
window.addEventListener("resize", onViewportChange, true);
|
|
526
|
+
window.addEventListener("scroll", onViewportChange, true);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/* ── small toast for the actions that do not change the screen ──────── */
|
|
530
|
+
|
|
531
|
+
var toastTimer = null;
|
|
532
|
+
var toastNode = null;
|
|
533
|
+
|
|
534
|
+
function toast(text) {
|
|
535
|
+
ensureStyle();
|
|
536
|
+
if (toastNode !== null && toastNode.parentNode !== null) toastNode.parentNode.removeChild(toastNode);
|
|
537
|
+
if (toastTimer !== null) clearTimeout(toastTimer);
|
|
538
|
+
toastNode = document.createElement("div");
|
|
539
|
+
toastNode.className = "dcla-toast";
|
|
540
|
+
toastNode.setAttribute("role", "status");
|
|
541
|
+
toastNode.textContent = text;
|
|
542
|
+
document.body.appendChild(toastNode);
|
|
543
|
+
toastTimer = setTimeout(function () {
|
|
544
|
+
if (toastNode !== null && toastNode.parentNode !== null) toastNode.parentNode.removeChild(toastNode);
|
|
545
|
+
toastNode = null;
|
|
546
|
+
toastTimer = null;
|
|
547
|
+
}, 1400);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Ask the 服务端 to select the file in the OS file manager. */
|
|
551
|
+
function reveal(path) {
|
|
552
|
+
fetch("/chat-link-actions?action=reveal&path=" + encodeURIComponent(path), { method: "POST" }).catch(function () {
|
|
553
|
+
toast("无法打开文件管理器");
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/* ── interception ───────────────────────────────────────────────────── */
|
|
558
|
+
|
|
559
|
+
function onContextMenu(event) {
|
|
560
|
+
if (!(event.target instanceof Element)) return;
|
|
561
|
+
var target = event.target;
|
|
562
|
+
var path = pathFromNode(target, { x: event.clientX, y: event.clientY });
|
|
563
|
+
if (path === null) return;
|
|
564
|
+
event.preventDefault();
|
|
565
|
+
event.stopPropagation();
|
|
566
|
+
openMenu(path, event.clientX, event.clientY);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/* ── plugin entry ───────────────────────────────────────────────────── */
|
|
570
|
+
|
|
571
|
+
var inject = ["slots", "sessions"];
|
|
572
|
+
|
|
573
|
+
function apply(ctx) {
|
|
574
|
+
rootContext = ctx;
|
|
575
|
+
ctx.effect(function () {
|
|
576
|
+
document.addEventListener("contextmenu", onContextMenu, true);
|
|
577
|
+
return function () {
|
|
578
|
+
document.removeEventListener("contextmenu", onContextMenu, true);
|
|
579
|
+
closeMenu(false);
|
|
580
|
+
};
|
|
581
|
+
});
|
|
582
|
+
ctx.effect(function () {
|
|
583
|
+
return ctx.slots.inject("conversation.input.overlay", function () {
|
|
584
|
+
return ctx.slots.register({
|
|
585
|
+
name: "conversation.input.overlay",
|
|
586
|
+
id: "chat-link-actions",
|
|
587
|
+
order: 900
|
|
588
|
+
}, DraftCapture);
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
module.exports = { inject: inject, apply: apply };
|
|
594
|
+
return module.exports;
|
|
595
|
+
}
|
|
596
|
+
});
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# dsh-chat-link-actions bundle patch
|
|
2
|
+
#
|
|
3
|
+
# One insert row; the entry `name` must equal the package name so the client
|
|
4
|
+
# modules scan resolves ./client and serves it as the plugin's browser bundle.
|
|
5
|
+
- insert:
|
|
6
|
+
- id: chat-link-actions
|
|
7
|
+
name: 'dsh-chat-link-actions'
|
package/icon.png
ADDED
|
Binary file
|
package/index.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-chat-link-actions — 服务端半:按请求在系统文件管理器里定位文件。
|
|
3
|
+
*
|
|
4
|
+
* 只做一件事:在文件管理器里选中文件本身。界面由 client.js 绘制。
|
|
5
|
+
*
|
|
6
|
+
* Security posture mirrors the other installed bundles: the connection's own
|
|
7
|
+
* request fence rejects cross-origin callers, the path must be absolute, and
|
|
8
|
+
* the argv is never shell-interpolated.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'node:child_process'
|
|
12
|
+
import { stat } from 'node:fs/promises'
|
|
13
|
+
import { dirname, isAbsolute } from 'node:path'
|
|
14
|
+
|
|
15
|
+
/** Services required before mounting: route registration + the web runtime fence. */
|
|
16
|
+
export const inject = ['webServer', 'connection']
|
|
17
|
+
|
|
18
|
+
/** 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)) {
|
|
21
|
+
throw new Error('absolute path required')
|
|
22
|
+
}
|
|
23
|
+
return path
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
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.
|
|
29
|
+
*/
|
|
30
|
+
function revealCommand(path, platform = process.platform) {
|
|
31
|
+
switch (platform) {
|
|
32
|
+
case 'darwin':
|
|
33
|
+
return { command: 'open', args: ['-R', path] }
|
|
34
|
+
case 'win32':
|
|
35
|
+
return { command: 'explorer.exe', args: [`/select,${path}`] }
|
|
36
|
+
case 'android':
|
|
37
|
+
return { command: 'termux-open', args: [dirname(path)] }
|
|
38
|
+
default:
|
|
39
|
+
return { command: 'xdg-open', args: [dirname(path)] }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function reply(res, status, body) {
|
|
44
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
45
|
+
res.end(JSON.stringify(body))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function apply(ctx) {
|
|
49
|
+
const fence = (req) => ctx.connection.requestRejection(req) === undefined
|
|
50
|
+
|
|
51
|
+
ctx.effect(() => ctx.webServer.register({
|
|
52
|
+
kind: 'prefix',
|
|
53
|
+
path: '/chat-link-actions',
|
|
54
|
+
handler: async (req, res) => {
|
|
55
|
+
if (!fence(req)) return reply(res, 403, { ok: false, error: 'forbidden' })
|
|
56
|
+
if (req.method !== 'POST') return reply(res, 405, { ok: false, error: 'method' })
|
|
57
|
+
try {
|
|
58
|
+
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
|
59
|
+
if (url.searchParams.get('action') !== 'reveal') {
|
|
60
|
+
return reply(res, 400, { ok: false, error: 'unknown action' })
|
|
61
|
+
}
|
|
62
|
+
const path = requireAbsolute(url.searchParams.get('path'))
|
|
63
|
+
const info = await stat(path).catch(() => null)
|
|
64
|
+
if (info === null) return reply(res, 404, { ok: false, error: 'not-found' })
|
|
65
|
+
const { command, args } = revealCommand(path)
|
|
66
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' })
|
|
67
|
+
child.on('error', () => {})
|
|
68
|
+
child.unref()
|
|
69
|
+
return reply(res, 200, { ok: true })
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return reply(res, 400, { ok: false, error: String(error?.message ?? error) })
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}), 'dsh-chat-link-actions: reveal route')
|
|
75
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-chat-link-actions",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./client": {
|
|
10
|
+
"default": "./client.js"
|
|
11
|
+
},
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"index.js",
|
|
16
|
+
"client.js",
|
|
17
|
+
"cordis.patch.yml",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"icon.png"
|
|
21
|
+
],
|
|
22
|
+
"keywords": [
|
|
23
|
+
"deepseek-harness",
|
|
24
|
+
"dsh",
|
|
25
|
+
"dsh-plugin",
|
|
26
|
+
"link-actions",
|
|
27
|
+
"file-link",
|
|
28
|
+
"quote-path",
|
|
29
|
+
"reveal-in-finder"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20",
|
|
34
|
+
"dsh": ">=0.2.0"
|
|
35
|
+
},
|
|
36
|
+
"dsh": {
|
|
37
|
+
"bundle": {
|
|
38
|
+
"patch": "./cordis.patch.yml"
|
|
39
|
+
},
|
|
40
|
+
"client": {
|
|
41
|
+
"inject": [
|
|
42
|
+
"@deepseek-ai/dsh-client-connection"
|
|
43
|
+
],
|
|
44
|
+
"platform": "web"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"author": "regrevia",
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/regrevia/dsh-chat-link-actions.git"
|
|
51
|
+
}
|
|
52
|
+
}
|