@dybai/file-line-reference 0.0.0-stage → 1.0.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 +25 -2
- package/client.js +849 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +11 -0
- package/index.js +2 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +36 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dy.bai
|
|
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,26 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 文件行引用 · File Line Reference
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DeepSeek Harness (DSH) 插件:在侧栏文件预览中选中若干行,把 `@路径:起-止` 行范围引用插入对话输入框;点击消息里的引用 chip 直接跳回文件的起始行。
|
|
4
|
+
|
|
5
|
+
- 划选拖拽或行选模式(点击选中、Shift 连选、Ctrl/Cmd 增减)→ 工具栏按钮或 **Ctrl+Shift+L** 插入引用到草稿;
|
|
6
|
+
- 引用前后自动补空格,不会与相邻文字粘连;
|
|
7
|
+
- 纯文本预览可开关行号(代码预览自带行号),屏上所见即所选;
|
|
8
|
+
- 全局快捷键默认 `Ctrl+Shift+L`(macOS `⌘⇧L`),可在 设置 → 快捷键 中改键(命令:文件行引用 → 添加选中行到对话)。
|
|
9
|
+
|
|
10
|
+
## 安装
|
|
11
|
+
|
|
12
|
+
见 [docs/deploy.zh-CN.md](docs/deploy.zh-CN.md) 第 5 节:启用实验性插件市场后添加本仓库的索引 URL,或直接按 spec 安装。
|
|
13
|
+
|
|
14
|
+
## 开发与发布
|
|
15
|
+
|
|
16
|
+
```powershell
|
|
17
|
+
node scripts/build.mjs # src/ 变更后重新生成 client.js
|
|
18
|
+
node --test # 纯逻辑单测
|
|
19
|
+
node scripts/verify-loader.mjs # DSH ModuleLoader 契约模拟
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
编译、发布(git/npm 两种模式)与常见坑的完整手册:[docs/deploy.zh-CN.md](docs/deploy.zh-CN.md)。
|
|
23
|
+
|
|
24
|
+
## License
|
|
25
|
+
|
|
26
|
+
[MIT](LICENSE)
|
package/client.js
ADDED
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
/* Generated by scripts/build.mjs from src/ — do not edit. */
|
|
2
|
+
window.__ModuleLoader__.load({
|
|
3
|
+
id: '@dybai/file-line-reference',
|
|
4
|
+
factory(require) {
|
|
5
|
+
var module = { exports: {} };
|
|
6
|
+
var exports = module.exports;
|
|
7
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
8
|
+
const FlrCore = (function () {
|
|
9
|
+
|
|
10
|
+
/** The shared file-reference token grammar: quoted `@"…"` or a `@`-run of non-space, at a whitespace boundary. */
|
|
11
|
+
const TOKEN_SOURCE = '(?<!\\S)(@"[^"\\n]+"|@[^\\s]+)';
|
|
12
|
+
const TRAILING_PUNCTUATION = /[.,;:!?,。;:!?]+$/u;
|
|
13
|
+
const LINE_SUFFIX = /^(\d{1,7})(?:-(\d{1,7}))?$/u;
|
|
14
|
+
const FILE_ADDRESS_PREFIX = 'dsh-resource://file/';
|
|
15
|
+
|
|
16
|
+
/** Remove trailing punctuation the reference grammar treats as sentence text. */
|
|
17
|
+
function stripTrailingPunctuation(text) {
|
|
18
|
+
return text.replace(TRAILING_PUNCTUATION, '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Split a trailing `:S-E` / `:S` line suffix off a path at the last colon.
|
|
23
|
+
* @param path - candidate path text, quotes already removed.
|
|
24
|
+
* @returns `{ path, start, end }` with 1-based inclusive lines, or null.
|
|
25
|
+
*/
|
|
26
|
+
function parseLineSuffix(path) {
|
|
27
|
+
if (typeof path !== 'string') return null;
|
|
28
|
+
const colon = path.lastIndexOf(':');
|
|
29
|
+
if (colon <= 0) return null;
|
|
30
|
+
const suffix = path.slice(colon + 1);
|
|
31
|
+
const match = LINE_SUFFIX.exec(suffix);
|
|
32
|
+
if (match === null) return null;
|
|
33
|
+
const filePath = path.slice(0, colon);
|
|
34
|
+
if (filePath.length === 0 || filePath.endsWith('/')) return null;
|
|
35
|
+
const start = Number(match[1]);
|
|
36
|
+
const end = match[2] === undefined ? start : Number(match[2]);
|
|
37
|
+
if (!Number.isSafeInteger(start) || start < 1) return null;
|
|
38
|
+
if (!Number.isSafeInteger(end) || end < start) return null;
|
|
39
|
+
return { path: filePath, start, end };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Display label of a path: its last non-empty segment in either separator spelling. */
|
|
43
|
+
function labelOf(path) {
|
|
44
|
+
return path.split(/[\\/]/u).filter(Boolean).at(-1) ?? path;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Find the nearest `@path:S-E` token at or after `from`.
|
|
49
|
+
* @param text - the complete original text; never mutated.
|
|
50
|
+
* @param from - inclusive UTF-16 offset to search from.
|
|
51
|
+
* @returns a `file-lines` user-text node, or `undefined` when no later
|
|
52
|
+
* candidate exists. The conversation pipeline treats only `undefined` as
|
|
53
|
+
* "no match" — returning `null` would crash its `node.start` access.
|
|
54
|
+
*/
|
|
55
|
+
function matchFileLinesNode(text, from) {
|
|
56
|
+
const re = new RegExp(TOKEN_SOURCE, 'gu');
|
|
57
|
+
re.lastIndex = from;
|
|
58
|
+
for (let match = re.exec(text); match !== null; match = re.exec(text)) {
|
|
59
|
+
const token = match[0];
|
|
60
|
+
const quoted = token.startsWith('@"');
|
|
61
|
+
const raw = quoted ? token : stripTrailingPunctuation(token);
|
|
62
|
+
if (raw.length <= 1) continue;
|
|
63
|
+
const inner = quoted ? raw.slice(2, -1) : raw.slice(1);
|
|
64
|
+
const parsed = parseLineSuffix(inner);
|
|
65
|
+
if (parsed === null) continue;
|
|
66
|
+
return {
|
|
67
|
+
id: 'file-lines',
|
|
68
|
+
start: match.index,
|
|
69
|
+
end: match.index + raw.length,
|
|
70
|
+
data: {
|
|
71
|
+
path: parsed.path,
|
|
72
|
+
label: labelOf(parsed.path),
|
|
73
|
+
lineStart: parsed.start,
|
|
74
|
+
lineEnd: parsed.end,
|
|
75
|
+
raw
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fold 1-based line numbers into ascending contiguous inclusive runs.
|
|
84
|
+
* @param numbers - arbitrary-order line numbers; duplicates and stray values ignored.
|
|
85
|
+
* @returns `[start, end]` pairs, contiguous runs merged.
|
|
86
|
+
*/
|
|
87
|
+
function mergeLines(numbers) {
|
|
88
|
+
const sorted = [...new Set(numbers)]
|
|
89
|
+
.filter((n) => Number.isInteger(n) && n >= 1)
|
|
90
|
+
.sort((left, right) => left - right);
|
|
91
|
+
const runs = [];
|
|
92
|
+
for (const n of sorted) {
|
|
93
|
+
const last = runs[runs.length - 1];
|
|
94
|
+
if (last !== undefined && n === last[1] + 1) last[1] = n;
|
|
95
|
+
else runs.push([n, n]);
|
|
96
|
+
}
|
|
97
|
+
return runs;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build mention text for one path and line runs, quoting paths the unquoted
|
|
102
|
+
* grammar cannot represent (whitespace); control characters or quotes in the
|
|
103
|
+
* path are unrepresentable and answer the empty string.
|
|
104
|
+
* @param path - workspace-relative or absolute path in either separator spelling.
|
|
105
|
+
* @param ranges - ascending `[start, end]` inclusive line runs.
|
|
106
|
+
* @returns space-joined `@path:S-E` mentions, or '' when nothing is representable.
|
|
107
|
+
*/
|
|
108
|
+
function formatMention(path, ranges) {
|
|
109
|
+
if (typeof path !== 'string' || path.length === 0) return '';
|
|
110
|
+
const runs = (ranges ?? []).filter(([s, e]) => Number.isSafeInteger(s) && s >= 1 && Number.isSafeInteger(e) && e >= s);
|
|
111
|
+
if (runs.length === 0) return '';
|
|
112
|
+
if (/["\u0000-\u001f\u007f-\u009f]/u.test(path)) return '';
|
|
113
|
+
const quoted = /\s/u.test(path);
|
|
114
|
+
return runs
|
|
115
|
+
.map(([s, e]) => {
|
|
116
|
+
const ref = `${path}:${s === e ? `${s}` : `${s}-${e}`}`;
|
|
117
|
+
return quoted ? `@"${ref}"` : `@${ref}`;
|
|
118
|
+
})
|
|
119
|
+
.join(' ');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Normalize a path for a mention: forward slashes, and the session cwd
|
|
124
|
+
* stripped when the path lives inside it (comparison is case-insensitive for
|
|
125
|
+
* Windows spellings); anything else stays absolute.
|
|
126
|
+
*/
|
|
127
|
+
function relativeMentionPath(path, cwd) {
|
|
128
|
+
const normalized = String(path).replace(/\\/g, '/');
|
|
129
|
+
if (cwd === undefined || cwd === null || cwd === '') return normalized;
|
|
130
|
+
const root = String(cwd).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
131
|
+
if (root === '') return normalized;
|
|
132
|
+
if (normalized.toLowerCase() === root.toLowerCase()) return '';
|
|
133
|
+
if (normalized.toLowerCase().startsWith(`${root.toLowerCase()}/`)) return normalized.slice(root.length + 1);
|
|
134
|
+
return normalized;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Component-encode one address segment, keeping `:` literal for drive letters. */
|
|
138
|
+
function encodeSegment(segment) {
|
|
139
|
+
return encodeURIComponent(segment).replace(/%3A/gi, ':');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Encode a `/`-separated path segment by segment. */
|
|
143
|
+
function encodePath(path) {
|
|
144
|
+
return path.split('/').map(encodeSegment).join('/');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Build the `dsh-resource://file/session/<id>/<path>` address of a normalized path. */
|
|
148
|
+
function sessionFileAddress(sessionId, path) {
|
|
149
|
+
const normalized = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '');
|
|
150
|
+
return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(normalized)}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Whether a path is absolute in either spelling the Host accepts. */
|
|
154
|
+
function isAbsoluteWorkspacePath(path) {
|
|
155
|
+
return path.startsWith('/') || /^[A-Za-z]:[/\\]/u.test(path) || path.startsWith('\\\\');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The address for a path as a caller holds it: relative or inside the
|
|
160
|
+
* Session's workspace becomes session-relative; anything else keeps its
|
|
161
|
+
* absolute path inside that Session's address.
|
|
162
|
+
*/
|
|
163
|
+
function fileAddress(sessionId, cwd, path) {
|
|
164
|
+
const normalized = path.replace(/\\/g, '/');
|
|
165
|
+
if (!isAbsoluteWorkspacePath(normalized)) return sessionFileAddress(sessionId, normalized);
|
|
166
|
+
const root = cwd === undefined || cwd === null ? '' : String(cwd).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
167
|
+
if (root !== '' && normalized === root) return sessionFileAddress(sessionId, '');
|
|
168
|
+
if (root !== '' && normalized.startsWith(`${root}/`)) return sessionFileAddress(sessionId, normalized.slice(root.length + 1));
|
|
169
|
+
return sessionFileAddress(sessionId, normalized);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Pad one draft insertion with spaces so the mention never glues to
|
|
174
|
+
* neighboring text: a leading space unless the caret sits at offset 0 or
|
|
175
|
+
* after whitespace; a trailing space unless whitespace immediately follows —
|
|
176
|
+
* at the draft end the trailing space always lands, so text typed next
|
|
177
|
+
* cannot be swallowed into the reference path.
|
|
178
|
+
*
|
|
179
|
+
* Callers that pass an empty draft with a live span start (the conservative
|
|
180
|
+
* retry path) get exactly this always-pad shape: `draft[start - 1]` is
|
|
181
|
+
* `undefined`, which never counts as whitespace, so the leading space lands
|
|
182
|
+
* whenever `start > 0`, and the trailing space always lands.
|
|
183
|
+
*
|
|
184
|
+
* @param draft - the composer draft text in insertion coordinates.
|
|
185
|
+
* @param start - the insertion span start offset in that draft.
|
|
186
|
+
* @param insertion - the text being inserted.
|
|
187
|
+
* @returns the padded insertion text.
|
|
188
|
+
*/
|
|
189
|
+
function padInsertion(draft, start, insertion) {
|
|
190
|
+
const before = start > 0 && !/\s/u.test(draft[start - 1]);
|
|
191
|
+
const after = !(start < draft.length && /\s/u.test(draft[start]));
|
|
192
|
+
return `${before ? ' ' : ''}${insertion}${after ? ' ' : ''}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
stripTrailingPunctuation,
|
|
197
|
+
parseLineSuffix,
|
|
198
|
+
labelOf,
|
|
199
|
+
matchFileLinesNode,
|
|
200
|
+
mergeLines,
|
|
201
|
+
formatMention,
|
|
202
|
+
relativeMentionPath,
|
|
203
|
+
fileAddress,
|
|
204
|
+
padInsertion
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
})();
|
|
208
|
+
const React = require('react');
|
|
209
|
+
const h = React.createElement;
|
|
210
|
+
|
|
211
|
+
const NS = 'fileLineReference';
|
|
212
|
+
|
|
213
|
+
const NUMBERS_STORAGE_KEY = 'flr.numbers';
|
|
214
|
+
const NUMBERS_CLASS = 'flr-numbers';
|
|
215
|
+
|
|
216
|
+
const zh = {
|
|
217
|
+
'meta.title': '文件行引用',
|
|
218
|
+
'meta.description': '选中文件的若干行,把 @路径:起-止 行范围引用插入对话输入框(Ctrl+Shift+L);点击消息中的引用 chip 回到起始行。当前支持侧栏文件预览。',
|
|
219
|
+
'action.addLines': '添加选中行到对话',
|
|
220
|
+
'action.addLinesTitle': '把选中的行以 @路径:起-止 引用插入输入框(Ctrl+Shift+L)',
|
|
221
|
+
'action.lineMode': '行选择模式',
|
|
222
|
+
'action.lineModeHint': '行选择模式:点击行选中整行,Shift+点击连选,Ctrl/Cmd+点击增减;再次点击关闭',
|
|
223
|
+
'action.numbers': '行号',
|
|
224
|
+
'action.numbersHint': '在纯文本预览中显示行号(代码预览自带行号)',
|
|
225
|
+
'shortcut.label': '添加选中行到对话'
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const en = {
|
|
229
|
+
'meta.title': 'File Line Reference',
|
|
230
|
+
'meta.description': 'Select file lines and insert @path:start-end references into the chat composer (Ctrl+Shift+L); click a reference chip in a message to reopen the file at its start line. Currently supports the sidebar file preview.',
|
|
231
|
+
'action.addLines': 'Add selected lines to chat',
|
|
232
|
+
'action.addLinesTitle': 'Insert the selected lines as @path:start-end into the composer (Ctrl+Shift+L)',
|
|
233
|
+
'action.lineMode': 'Line select mode',
|
|
234
|
+
'action.lineModeHint': 'Line select mode: click selects a line, Shift+click extends, Ctrl/Cmd+click toggles; click again to turn off',
|
|
235
|
+
'action.numbers': 'Line numbers',
|
|
236
|
+
'action.numbersHint': 'Show line numbers in the plain-text preview (the code preview has its own)',
|
|
237
|
+
'shortcut.label': 'Add selected lines to chat'
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/** Context services this module reads; activation order comes from package.json `dsh.client.inject`. */
|
|
241
|
+
const inject = ['slots', 'locale', 'sessions', 'uiConversation', 'sidebarRight'];
|
|
242
|
+
|
|
243
|
+
/* ---------------------------------------------------------------- styles -- */
|
|
244
|
+
|
|
245
|
+
const STYLE_ID = 'flr-styles';
|
|
246
|
+
const HIGHLIGHT_CLASS = 'flr-line-selected';
|
|
247
|
+
|
|
248
|
+
const STYLE_TEXT = [
|
|
249
|
+
`.${HIGHLIGHT_CLASS} { background: color-mix(in srgb, var(--dsw-alias-brand-primary) 18%, transparent); }`,
|
|
250
|
+
`.flr-chip { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 6px; border: none; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 18px; max-width: 100%; text-align: left; }`,
|
|
251
|
+
'button.flr-chip { cursor: pointer; }',
|
|
252
|
+
'button.flr-chip:hover { background: var(--dsw-alias-bg-layer-1); border-radius: 6px; outline: .5px solid var(--dsw-alias-border-l2); }',
|
|
253
|
+
'.flr-chip-label { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
|
|
254
|
+
'.flr-chip-lines { color: var(--dsw-alias-label-secondary); font-family: var(--dsw-font-mono, ui-monospace, monospace); white-space: nowrap; }',
|
|
255
|
+
'.flr-chip-icon { flex: none; opacity: .7; }',
|
|
256
|
+
'.flr-tool-btn { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; padding: 0; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; }',
|
|
257
|
+
'.flr-tool-btn:hover { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }',
|
|
258
|
+
'.flr-tool-btn:disabled { opacity: .4; cursor: default; background: transparent; color: var(--dsw-alias-label-secondary); }',
|
|
259
|
+
'.flr-tool-btn[aria-pressed="true"] { color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent); }',
|
|
260
|
+
`.${NUMBERS_CLASS} [data-textpreview-line]::before { content: attr(data-textpreview-line); display: inline-block; min-width: 3ch; margin-right: 10px; color: var(--dsw-alias-label-secondary); opacity: .75; text-align: right; user-select: none; }`
|
|
261
|
+
].join('\n');
|
|
262
|
+
|
|
263
|
+
function injectStylesheet() {
|
|
264
|
+
const existing = document.getElementById(STYLE_ID);
|
|
265
|
+
if (existing) return () => {};
|
|
266
|
+
const tag = document.createElement('style');
|
|
267
|
+
tag.id = STYLE_ID;
|
|
268
|
+
tag.textContent = STYLE_TEXT;
|
|
269
|
+
document.head.appendChild(tag);
|
|
270
|
+
return () => tag.remove();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/* ----------------------------------------------------------------- icons -- */
|
|
274
|
+
|
|
275
|
+
const ICON_CHAT = h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
|
|
276
|
+
h('path', { d: 'M2.5 3.5A1.5 1.5 0 0 1 4 2h8a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 12 11H6.5L3.8 13.4A.6.6 0 0 1 2.8 13v-2H4a.5.5 0 0 1 .5.5', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round' }),
|
|
277
|
+
h('path', { d: 'M5 5.5h6M5 8h4', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round' })
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const ICON_ROWS = h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
|
|
281
|
+
h('rect', { x: 1.5, y: 2.5, width: 13, height: 3.4, rx: 1, fill: 'currentColor', opacity: 0.35 }),
|
|
282
|
+
h('path', { d: 'M2.6 8.2h10.8M2.6 11.6h10.8', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round' }),
|
|
283
|
+
h('path', { d: 'M13.2 8.2v3.4', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', opacity: 0.55 })
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const ICON_FILE = h('svg', { className: 'flr-chip-icon', width: 12, height: 12, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
|
|
287
|
+
h('path', { d: 'M4 1.8h5.2L12.6 5v9.2a.6.6 0 0 1-.6.6H4a.6.6 0 0 1-.6-.6V2.4a.6.6 0 0 1 .6-.6Z', stroke: 'currentColor', strokeWidth: 1.1, strokeLinejoin: 'round' }),
|
|
288
|
+
h('path', { d: 'M9 2v3.4h3.4', stroke: 'currentColor', strokeWidth: 1.1, strokeLinejoin: 'round' })
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const ICON_NUMBERS = h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
|
|
292
|
+
h('path', { d: 'M5.6 2 4.4 14M11.6 2 10.4 14M2.4 5.6h11.2M2 10.4h11.2', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round' })
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
/* ----------------------------------------------------- selection controller -- */
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Tracks "which preview lines the user has picked" for the document toolbar.
|
|
299
|
+
*
|
|
300
|
+
* The preview plugin exposes no selection API, so this module reads its stable
|
|
301
|
+
* DOM contract: plain-text rows carry `[data-textpreview-line]` (the value is
|
|
302
|
+
* the 1-based source line), and highlighted code renders `.line` elements in
|
|
303
|
+
* source order inside the `[data-code-block-content]` scrollport. Every read
|
|
304
|
+
* goes through `queryTarget()` so a harness update that changes the contract
|
|
305
|
+
* is a one-function fix.
|
|
306
|
+
*/
|
|
307
|
+
const controller = (() => {
|
|
308
|
+
const state = {
|
|
309
|
+
mode: false,
|
|
310
|
+
selected: [],
|
|
311
|
+
anchor: null,
|
|
312
|
+
mouse: null,
|
|
313
|
+
numbers: true
|
|
314
|
+
};
|
|
315
|
+
const listeners = new Set();
|
|
316
|
+
const decorated = new Set();
|
|
317
|
+
let lastContainer = null;
|
|
318
|
+
let attached = 0;
|
|
319
|
+
let inserter = null;
|
|
320
|
+
|
|
321
|
+
function readStoredNumbers() {
|
|
322
|
+
try {
|
|
323
|
+
const stored = window.localStorage.getItem(NUMBERS_STORAGE_KEY);
|
|
324
|
+
return stored === null ? true : stored === '1';
|
|
325
|
+
} catch {
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function applyNumbersClass(on) {
|
|
331
|
+
document.documentElement.classList.toggle(NUMBERS_CLASS, on);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
state.numbers = readStoredNumbers();
|
|
335
|
+
applyNumbersClass(state.numbers);
|
|
336
|
+
|
|
337
|
+
function subscribe(listener) {
|
|
338
|
+
listeners.add(listener);
|
|
339
|
+
return () => listeners.delete(listener);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function notify() {
|
|
343
|
+
for (const listener of listeners) listener();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Resolve the mounted preview body. Returns `{ kind, container }` where
|
|
348
|
+
* `kind` is `'plain' | 'code'`, or null when no line-bearing body exists.
|
|
349
|
+
* Prefers a visible container so a floating preview does not shadow the
|
|
350
|
+
* active one.
|
|
351
|
+
*
|
|
352
|
+
* Chat markdown fences also carry `[data-code-block-content]`, so a code
|
|
353
|
+
* container is accepted only inside the document preview's own
|
|
354
|
+
* `[data-code-preview]` renderer root; chat fences never match and the
|
|
355
|
+
* lookup falls through to the plain-text branch.
|
|
356
|
+
*/
|
|
357
|
+
function queryTarget() {
|
|
358
|
+
const isVisible = (el) => el.getClientRects().length > 0;
|
|
359
|
+
const previews = [...document.querySelectorAll('[data-code-preview]')];
|
|
360
|
+
const preview = previews.find(isVisible) ?? previews[0];
|
|
361
|
+
if (preview) {
|
|
362
|
+
const code = preview.querySelector('[data-code-block-content]');
|
|
363
|
+
if (code) return { kind: 'code', container: code };
|
|
364
|
+
}
|
|
365
|
+
const plains = [...document.querySelectorAll('[data-textpreview-line]')];
|
|
366
|
+
const first = plains.find((el) => isVisible(el)) ?? plains[0];
|
|
367
|
+
if (!first) return null;
|
|
368
|
+
const total = document.querySelectorAll('[data-textpreview-line]').length;
|
|
369
|
+
// One ancestor must hold every row; with two coexisting plain previews
|
|
370
|
+
// the walk never resolves and the target stays null (degenerate case).
|
|
371
|
+
let container = first.parentElement;
|
|
372
|
+
while (container && container !== document.body && container.querySelectorAll('[data-textpreview-line]').length < total) {
|
|
373
|
+
container = container.parentElement;
|
|
374
|
+
}
|
|
375
|
+
if (!container || container === document.body) return null;
|
|
376
|
+
return { kind: 'plain', container };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Discard picks when the preview body changed underneath (tab or file switch). */
|
|
380
|
+
function ensureFreshTarget() {
|
|
381
|
+
const target = queryTarget();
|
|
382
|
+
const identity = target ? target.container : null;
|
|
383
|
+
if (identity !== lastContainer) {
|
|
384
|
+
lastContainer = identity;
|
|
385
|
+
clearHighlight();
|
|
386
|
+
state.selected = [];
|
|
387
|
+
state.anchor = null;
|
|
388
|
+
if (state.mode) {
|
|
389
|
+
state.mode = false;
|
|
390
|
+
notify();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return target;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function lineRowOf(element, target) {
|
|
397
|
+
if (!(element instanceof Element)) return null;
|
|
398
|
+
if (target.kind === 'plain') {
|
|
399
|
+
const row = element.closest('[data-textpreview-line]');
|
|
400
|
+
if (!row || !target.container.contains(row)) return null;
|
|
401
|
+
return { line: Number(row.getAttribute('data-textpreview-line')), el: row };
|
|
402
|
+
}
|
|
403
|
+
const row = element.closest('pre .line');
|
|
404
|
+
if (!row || !target.container.contains(row)) return null;
|
|
405
|
+
const rows = target.container.querySelectorAll('pre .line');
|
|
406
|
+
const index = Array.prototype.indexOf.call(rows, row);
|
|
407
|
+
return index >= 0 ? { line: index + 1, el: row } : null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function rowForLine(line, target) {
|
|
411
|
+
if (target.kind === 'plain') {
|
|
412
|
+
return target.container.querySelector(`[data-textpreview-line="${line}"]`);
|
|
413
|
+
}
|
|
414
|
+
return target.container.querySelectorAll('pre .line').item(line - 1);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function clearHighlight() {
|
|
418
|
+
for (const el of decorated) el.classList.remove(HIGHLIGHT_CLASS);
|
|
419
|
+
decorated.clear();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function applyHighlight(target) {
|
|
423
|
+
clearHighlight();
|
|
424
|
+
for (const line of state.selected) {
|
|
425
|
+
const row = rowForLine(line, target);
|
|
426
|
+
if (row instanceof Element) {
|
|
427
|
+
row.classList.add(HIGHLIGHT_CLASS);
|
|
428
|
+
decorated.add(row);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function onClick(event) {
|
|
434
|
+
if (!state.mode || event.button !== 0) return;
|
|
435
|
+
const target = ensureFreshTarget();
|
|
436
|
+
if (!target) return;
|
|
437
|
+
const hit = lineRowOf(event.target, target);
|
|
438
|
+
if (!hit) return;
|
|
439
|
+
event.preventDefault();
|
|
440
|
+
event.stopPropagation();
|
|
441
|
+
if (event.shiftKey && state.anchor !== null) {
|
|
442
|
+
const [from, to] = state.anchor <= hit.line ? [state.anchor, hit.line] : [hit.line, state.anchor];
|
|
443
|
+
state.selected = [];
|
|
444
|
+
for (let n = from; n <= to; n++) state.selected.push(n);
|
|
445
|
+
} else if (event.ctrlKey || event.metaKey) {
|
|
446
|
+
state.selected = state.selected.includes(hit.line)
|
|
447
|
+
? state.selected.filter((n) => n !== hit.line)
|
|
448
|
+
: [...state.selected, hit.line];
|
|
449
|
+
state.anchor = hit.line;
|
|
450
|
+
} else {
|
|
451
|
+
state.selected = [hit.line];
|
|
452
|
+
state.anchor = hit.line;
|
|
453
|
+
}
|
|
454
|
+
applyHighlight(target);
|
|
455
|
+
notify();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function lineFromNode(node, target) {
|
|
459
|
+
const element = node instanceof Element ? node : node?.parentElement;
|
|
460
|
+
if (!element) return null;
|
|
461
|
+
const hit = lineRowOf(element, target);
|
|
462
|
+
return hit ? hit.line : null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function onSelectionChange() {
|
|
466
|
+
const target = ensureFreshTarget();
|
|
467
|
+
const selection = document.getSelection();
|
|
468
|
+
if (!target || !selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
|
469
|
+
if (state.mouse !== null) {
|
|
470
|
+
state.mouse = null;
|
|
471
|
+
notify();
|
|
472
|
+
}
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const anchor = lineFromNode(selection.anchorNode, target);
|
|
476
|
+
const focus = lineFromNode(selection.focusNode, target);
|
|
477
|
+
if (anchor === null && focus === null) {
|
|
478
|
+
if (state.mouse !== null) {
|
|
479
|
+
state.mouse = null;
|
|
480
|
+
notify();
|
|
481
|
+
}
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const from = anchor ?? focus;
|
|
485
|
+
const to = focus ?? anchor;
|
|
486
|
+
const next = [Math.min(from, to), Math.max(from, to)];
|
|
487
|
+
if (state.mouse === null || state.mouse[0] !== next[0] || state.mouse[1] !== next[1]) {
|
|
488
|
+
state.mouse = next;
|
|
489
|
+
notify();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
subscribe,
|
|
495
|
+
/** Refcounted document listeners, alive while a toolbar is mounted. */
|
|
496
|
+
retain() {
|
|
497
|
+
attached += 1;
|
|
498
|
+
if (attached === 1) {
|
|
499
|
+
document.addEventListener('click', onClick, true);
|
|
500
|
+
document.addEventListener('selectionchange', onSelectionChange);
|
|
501
|
+
}
|
|
502
|
+
return () => {
|
|
503
|
+
attached -= 1;
|
|
504
|
+
if (attached === 0) {
|
|
505
|
+
document.removeEventListener('click', onClick, true);
|
|
506
|
+
document.removeEventListener('selectionchange', onSelectionChange);
|
|
507
|
+
state.mode = false;
|
|
508
|
+
state.selected = [];
|
|
509
|
+
state.anchor = null;
|
|
510
|
+
state.mouse = null;
|
|
511
|
+
clearHighlight();
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
},
|
|
515
|
+
setMode(on) {
|
|
516
|
+
state.mode = on;
|
|
517
|
+
state.selected = [];
|
|
518
|
+
state.anchor = null;
|
|
519
|
+
state.mouse = null;
|
|
520
|
+
clearHighlight();
|
|
521
|
+
notify();
|
|
522
|
+
},
|
|
523
|
+
/** Current picked lines: explicit mode picks, else the tracked drag range. */
|
|
524
|
+
currentLines() {
|
|
525
|
+
ensureFreshTarget();
|
|
526
|
+
if (state.mode) return [...state.selected].sort((a, b) => a - b);
|
|
527
|
+
if (state.mouse === null) return [];
|
|
528
|
+
const lines = [];
|
|
529
|
+
for (let n = state.mouse[0]; n <= state.mouse[1]; n++) lines.push(n);
|
|
530
|
+
return lines;
|
|
531
|
+
},
|
|
532
|
+
isMode() {
|
|
533
|
+
return state.mode;
|
|
534
|
+
},
|
|
535
|
+
numbersOn() {
|
|
536
|
+
return state.numbers;
|
|
537
|
+
},
|
|
538
|
+
setNumbers(on) {
|
|
539
|
+
state.numbers = on;
|
|
540
|
+
applyNumbersClass(on);
|
|
541
|
+
try {
|
|
542
|
+
window.localStorage.setItem(NUMBERS_STORAGE_KEY, on ? '1' : '0');
|
|
543
|
+
} catch {
|
|
544
|
+
// Storage unavailable (privacy mode); the toggle stays session-local.
|
|
545
|
+
}
|
|
546
|
+
notify();
|
|
547
|
+
},
|
|
548
|
+
/** Register the mounted toolbar's insert action for the global hotkey. */
|
|
549
|
+
setInserter(fn) {
|
|
550
|
+
inserter = fn;
|
|
551
|
+
return () => {
|
|
552
|
+
if (inserter === fn) inserter = null;
|
|
553
|
+
};
|
|
554
|
+
},
|
|
555
|
+
currentInserter() {
|
|
556
|
+
return inserter;
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
})();
|
|
560
|
+
|
|
561
|
+
/* ------------------------------------------------------------ components -- */
|
|
562
|
+
|
|
563
|
+
/** Session facade captured by apply(); lets action handlers resolve cwd without hooks. */
|
|
564
|
+
let sessionsFacade = null;
|
|
565
|
+
|
|
566
|
+
/** Workspace root of one session from the facade snapshot; undefined when unknown. */
|
|
567
|
+
function sessionCwd(sessionId) {
|
|
568
|
+
try {
|
|
569
|
+
if (sessionId === undefined || sessionId === null || !sessionsFacade) return undefined;
|
|
570
|
+
return sessionsFacade.list.getSnapshot().byId[sessionId]?.cwd;
|
|
571
|
+
} catch {
|
|
572
|
+
return undefined;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Keep one broken render from silently deleting the toolbar: the boundary
|
|
578
|
+
* logs the failure to the console and drops only the buttons. Falls back to a
|
|
579
|
+
* pass-through when this React build exposes no Component class, so the
|
|
580
|
+
* module can never fail to load because of the boundary itself.
|
|
581
|
+
*/
|
|
582
|
+
const ToolbarBoundary = typeof React.Component === 'function'
|
|
583
|
+
? class ToolbarBoundary extends React.Component {
|
|
584
|
+
constructor(props) {
|
|
585
|
+
super(props);
|
|
586
|
+
this.state = { failed: false };
|
|
587
|
+
}
|
|
588
|
+
static getDerivedStateFromError() {
|
|
589
|
+
return { failed: true };
|
|
590
|
+
}
|
|
591
|
+
componentDidCatch(error) {
|
|
592
|
+
console.error('[file-line-reference] toolbar render failed:', error);
|
|
593
|
+
}
|
|
594
|
+
render() {
|
|
595
|
+
return this.state.failed ? null : this.props.children;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
: function ToolbarBoundary({ children }) {
|
|
599
|
+
return children;
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Slot component: a single h() call that cannot throw, so the boundary
|
|
604
|
+
* genuinely encloses ToolbarActions's own render — errors there would
|
|
605
|
+
* otherwise fly straight to the slot runtime and silently drop the occupant.
|
|
606
|
+
*/
|
|
607
|
+
function ToolbarActionsGuarded(props) {
|
|
608
|
+
return h(ToolbarBoundary, null, h(ToolbarActions, props));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Toolbar occupant for `sidebar.right.tab.document.actions`.
|
|
613
|
+
* Owner props: `{ absolutePath }`; session standard props supply `sessionId`
|
|
614
|
+
* and `inputActions`.
|
|
615
|
+
*
|
|
616
|
+
* Hook discipline: this component calls ONLY fixed React hooks — never
|
|
617
|
+
* hook-valued standard props (`useSession`/`useInput`). Those props can
|
|
618
|
+
* appear between renders, and a conditional hook call then changes the hook
|
|
619
|
+
* count mid-life, crashing the slot with React #310 and silently deleting
|
|
620
|
+
* the toolbar. The workspace root is therefore resolved at action time from
|
|
621
|
+
* the sessions facade snapshot (the same pattern ui-reference uses).
|
|
622
|
+
*/
|
|
623
|
+
function ToolbarActions(props) {
|
|
624
|
+
const [, force] = React.useReducer((n) => n + 1, 0);
|
|
625
|
+
React.useEffect(controller.retain, []);
|
|
626
|
+
React.useEffect(() => controller.subscribe(force), [force]);
|
|
627
|
+
React.useEffect(() => {
|
|
628
|
+
console.info('[file-line-reference] toolbar mounted');
|
|
629
|
+
return () => console.info('[file-line-reference] toolbar unmounted');
|
|
630
|
+
}, []);
|
|
631
|
+
|
|
632
|
+
const t = props.t;
|
|
633
|
+
const inputActions = props.inputActions;
|
|
634
|
+
const absolutePath = props.absolutePath;
|
|
635
|
+
const latest = React.useRef({ sessionId: props.sessionId, inputActions, absolutePath });
|
|
636
|
+
latest.current = { sessionId: props.sessionId, inputActions, absolutePath };
|
|
637
|
+
// Controller reads walk the live preview DOM; keep a DOM surprise from
|
|
638
|
+
// crashing the render and silently deleting the buttons.
|
|
639
|
+
let lines = [];
|
|
640
|
+
let mode = false;
|
|
641
|
+
let numbers = controller.numbersOn();
|
|
642
|
+
try {
|
|
643
|
+
lines = inputActions && absolutePath ? controller.currentLines() : [];
|
|
644
|
+
mode = controller.isMode();
|
|
645
|
+
} catch (error) {
|
|
646
|
+
console.error('[file-line-reference] toolbar state read failed:', error);
|
|
647
|
+
}
|
|
648
|
+
const canAdd = lines.length > 0;
|
|
649
|
+
|
|
650
|
+
function performInsert() {
|
|
651
|
+
const { sessionId, inputActions: actions, absolutePath: path } = latest.current;
|
|
652
|
+
if (!actions || !path) return false;
|
|
653
|
+
const picked = controller.currentLines();
|
|
654
|
+
if (picked.length === 0) return false;
|
|
655
|
+
const cwd = sessionCwd(sessionId);
|
|
656
|
+
const relative = FlrCore.relativeMentionPath(path, cwd);
|
|
657
|
+
const mention = FlrCore.formatMention(relative, FlrCore.mergeLines(picked));
|
|
658
|
+
if (!mention) return false;
|
|
659
|
+
const span = actions.captureInsertion();
|
|
660
|
+
// Always pad: a leading space unless the mention opens the draft, and
|
|
661
|
+
// always a trailing space so text typed next cannot glue to the path.
|
|
662
|
+
const text = FlrCore.padInsertion('', span.start, mention);
|
|
663
|
+
if (!actions.insertText(text, span)) {
|
|
664
|
+
const retry = actions.captureInsertion();
|
|
665
|
+
if (!actions.insertText(FlrCore.padInsertion('', retry.start, mention), retry)) {
|
|
666
|
+
console.warn('[file-line-reference] composer rejected the line reference insertion');
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return true;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// The global hotkey resolves through the mounted toolbar's inserter.
|
|
674
|
+
React.useEffect(() => controller.setInserter(() => {
|
|
675
|
+
try {
|
|
676
|
+
return performInsert();
|
|
677
|
+
} catch (error) {
|
|
678
|
+
console.warn('[file-line-reference] insertion attempt failed:', error);
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
}), []);
|
|
682
|
+
|
|
683
|
+
return [
|
|
684
|
+
h('button', {
|
|
685
|
+
key: 'add',
|
|
686
|
+
className: 'flr-tool-btn',
|
|
687
|
+
type: 'button',
|
|
688
|
+
title: t('action.addLinesTitle'),
|
|
689
|
+
'aria-label': t('action.addLines'),
|
|
690
|
+
disabled: !canAdd,
|
|
691
|
+
// pointerdown reads the drag selection before the browser collapses
|
|
692
|
+
// it on mousedown; click would already see an empty selection.
|
|
693
|
+
// Keyboard activation (Enter/Space) never fires pointerdown, so the
|
|
694
|
+
// keydown path below is the keyboard's way in.
|
|
695
|
+
onPointerDown: (event) => {
|
|
696
|
+
if (event.button === 0) performInsert();
|
|
697
|
+
},
|
|
698
|
+
onKeyDown: (event) => {
|
|
699
|
+
if (event.key === 'Enter' || event.key === ' ') performInsert();
|
|
700
|
+
}
|
|
701
|
+
}, ICON_CHAT),
|
|
702
|
+
h('button', {
|
|
703
|
+
key: 'mode',
|
|
704
|
+
className: 'flr-tool-btn',
|
|
705
|
+
type: 'button',
|
|
706
|
+
title: t('action.lineModeHint'),
|
|
707
|
+
'aria-label': t('action.lineMode'),
|
|
708
|
+
'aria-pressed': mode,
|
|
709
|
+
onClick: () => controller.setMode(!mode)
|
|
710
|
+
}, ICON_ROWS),
|
|
711
|
+
h('button', {
|
|
712
|
+
key: 'numbers',
|
|
713
|
+
className: 'flr-tool-btn',
|
|
714
|
+
type: 'button',
|
|
715
|
+
title: t('action.numbersHint'),
|
|
716
|
+
'aria-label': t('action.numbers'),
|
|
717
|
+
'aria-pressed': numbers,
|
|
718
|
+
onClick: () => controller.setNumbers(!numbers)
|
|
719
|
+
}, ICON_NUMBERS)
|
|
720
|
+
];
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** `conversation.user.text.node` occupant for the `file-lines` id. */
|
|
724
|
+
function FileLinesChip(props) {
|
|
725
|
+
const node = props.node;
|
|
726
|
+
const data = node?.data;
|
|
727
|
+
if (!data) return null;
|
|
728
|
+
const lines = data.lineStart === data.lineEnd ? `${data.lineStart}` : `${data.lineStart}-${data.lineEnd}`;
|
|
729
|
+
const interactive = props.interactive === true && typeof props.openAtLine === 'function';
|
|
730
|
+
const content = [
|
|
731
|
+
ICON_FILE,
|
|
732
|
+
h('span', { key: 'label', className: 'flr-chip-label' }, data.label),
|
|
733
|
+
h('span', { key: 'lines', className: 'flr-chip-lines' }, `:${lines}`)
|
|
734
|
+
];
|
|
735
|
+
if (!interactive) {
|
|
736
|
+
return h('span', { className: 'flr-chip', title: props.raw }, content);
|
|
737
|
+
}
|
|
738
|
+
return h('button', {
|
|
739
|
+
className: 'flr-chip',
|
|
740
|
+
type: 'button',
|
|
741
|
+
title: props.raw,
|
|
742
|
+
onClick: () => {
|
|
743
|
+
try {
|
|
744
|
+
props.openAtLine(data.path, data.lineStart);
|
|
745
|
+
} catch {
|
|
746
|
+
// Navigation failures keep the chip; the sidebar surfaces its own dialog.
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}, content);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/* ------------------------------------------------------------------ apply -- */
|
|
753
|
+
|
|
754
|
+
function apply(ctx) {
|
|
755
|
+
console.info('[file-line-reference] apply started');
|
|
756
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'flr: dictionaries');
|
|
757
|
+
const t = ctx.locale.bind(NS);
|
|
758
|
+
const sessions = ctx.get('sessions');
|
|
759
|
+
sessionsFacade = sessions;
|
|
760
|
+
|
|
761
|
+
ctx.effect(
|
|
762
|
+
() => ctx.uiConversation.userText.register({
|
|
763
|
+
id: 'file-lines',
|
|
764
|
+
order: 20,
|
|
765
|
+
match(text, from) {
|
|
766
|
+
// The pipeline treats only `undefined` as "no match"; coerce any
|
|
767
|
+
// nullish result so `node.start` is never read on a miss.
|
|
768
|
+
return FlrCore.matchFileLinesNode(text, from) ?? undefined;
|
|
769
|
+
}
|
|
770
|
+
}),
|
|
771
|
+
'flr: file-lines rule'
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
ctx.slots.inject('conversation.user.text.node', function* () {
|
|
775
|
+
yield ctx.slots.register({
|
|
776
|
+
name: 'conversation.user.text.node',
|
|
777
|
+
key: 'file-lines',
|
|
778
|
+
priority: 100,
|
|
779
|
+
locale: NS,
|
|
780
|
+
inject: (sessionId) => ({
|
|
781
|
+
openAtLine: (path, line) => {
|
|
782
|
+
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd;
|
|
783
|
+
ctx.sidebarRight.openResource(FlrCore.fileAddress(sessionId, cwd, path), { params: { file: { line } } });
|
|
784
|
+
}
|
|
785
|
+
})
|
|
786
|
+
}, FileLinesChip);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
ctx.slots.inject('sidebar.right.tab.document.actions', function* () {
|
|
790
|
+
yield ctx.slots.register(
|
|
791
|
+
{ name: 'sidebar.right.tab.document.actions', id: 'flr-add-lines', order: 10, locale: NS },
|
|
792
|
+
ToolbarActionsGuarded
|
|
793
|
+
);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
ctx.inject(['shortcuts'], (scope) => {
|
|
797
|
+
scope.effect(() => {
|
|
798
|
+
const command = {
|
|
799
|
+
id: 'fileLineReference.addToChat',
|
|
800
|
+
label: () => t('shortcut.label'),
|
|
801
|
+
aliases: ['add selected lines'],
|
|
802
|
+
defaults: {
|
|
803
|
+
'desktop:macos': { code: 'KeyL', modifiers: ['primary', 'shift'] },
|
|
804
|
+
'desktop:windows': { code: 'KeyL', modifiers: ['primary', 'shift'] },
|
|
805
|
+
'desktop:linux': { code: 'KeyL', modifiers: ['primary', 'shift'] },
|
|
806
|
+
'web:macos': { code: 'KeyL', modifiers: ['primary', 'shift'] },
|
|
807
|
+
'web:windows': { code: 'KeyL', modifiers: ['primary', 'shift'] }
|
|
808
|
+
},
|
|
809
|
+
regions: ['page', 'editable'],
|
|
810
|
+
modals: [],
|
|
811
|
+
resolve: (context) => {
|
|
812
|
+
// Never act behind an open modal (desktop priority bypasses the
|
|
813
|
+
// modal check, so this is the only guard).
|
|
814
|
+
if (context && context.modal) return { status: 'pass' };
|
|
815
|
+
const perform = controller.currentInserter();
|
|
816
|
+
if (!perform || controller.currentLines().length === 0) return { status: 'pass' };
|
|
817
|
+
return { status: 'handled', run: perform };
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
// A conflicting default binding throws; fall back to an unbound
|
|
821
|
+
// command the user can assign in the shortcut settings.
|
|
822
|
+
try {
|
|
823
|
+
scope.shortcuts.register(command);
|
|
824
|
+
} catch (error) {
|
|
825
|
+
console.warn('[file-line-reference] shortcut defaults rejected, retrying unbound:', error);
|
|
826
|
+
try {
|
|
827
|
+
scope.shortcuts.register({ ...command, defaults: {} });
|
|
828
|
+
} catch (fallbackError) {
|
|
829
|
+
console.warn('[file-line-reference] shortcut registration failed:', fallbackError);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}, 'flr: shortcut');
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
ctx.effect(() => {
|
|
836
|
+
const removeStyles = injectStylesheet();
|
|
837
|
+
return () => {
|
|
838
|
+
removeStyles();
|
|
839
|
+
document.documentElement.classList.remove(NUMBERS_CLASS);
|
|
840
|
+
};
|
|
841
|
+
}, 'flr: styles');
|
|
842
|
+
console.info('[file-line-reference] apply finished');
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
exports.apply = apply;
|
|
846
|
+
exports.inject = inject;
|
|
847
|
+
return module.exports;
|
|
848
|
+
}
|
|
849
|
+
});
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
2
|
+
<rect x="10" y="6" width="32" height="42" rx="4" fill="#247bbf" opacity="0.14"/>
|
|
3
|
+
<rect x="10" y="6" width="32" height="42" rx="4" fill="none" stroke="#247bbf" stroke-width="3"/>
|
|
4
|
+
<line x1="17" y1="17" x2="35" y2="17" stroke="#247bbf" stroke-width="3" stroke-linecap="round"/>
|
|
5
|
+
<line x1="17" y1="25" x2="31" y2="25" stroke="#247bbf" stroke-width="3" stroke-linecap="round"/>
|
|
6
|
+
<rect x="15" y="31" width="22" height="9" rx="2.5" fill="#247bbf" opacity="0.45"/>
|
|
7
|
+
<path d="M32 42h16a7 7 0 0 1 7 7v6a7 7 0 0 1-7 7h-5l-7 5v-5h-4a7 7 0 0 1-7-7v-6a7 7 0 0 1 7-7z" fill="#247bbf"/>
|
|
8
|
+
<circle cx="40" cy="52" r="2" fill="#ffffff"/>
|
|
9
|
+
<circle cx="47" cy="52" r="2" fill="#ffffff"/>
|
|
10
|
+
<circle cx="54" cy="52" r="2" fill="#ffffff"/>
|
|
11
|
+
</svg>
|
package/index.js
ADDED
package/locale/en.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "File Line Reference",
|
|
4
|
+
"description": "Select file lines and insert @path:start-end references into the chat composer; click a reference chip in a message to reopen the file at its start line. Currently supports the sidebar file preview."
|
|
5
|
+
}
|
|
6
|
+
}
|
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dybai/file-line-reference",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Select file lines and add @path:start-end line-range references to the chat composer; click a reference chip to reopen the file at its start line",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test",
|
|
8
|
+
"build": "node scripts/build.mjs"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"dsh": ">=0.2.1-rc.5"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./index.js",
|
|
15
|
+
"./client": "./client.js",
|
|
16
|
+
"./locale/*.json": "./locale/*.json",
|
|
17
|
+
"./icon": "./icon.svg"
|
|
18
|
+
},
|
|
19
|
+
"files": ["index.js", "client.js", "cordis.patch.yml", "locale/*.json", "icon.svg"],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/dybai/file-line-reference.git"
|
|
23
|
+
},
|
|
24
|
+
"keywords": ["dsh", "deepseek-harness", "line-reference", "file-preview"],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"dsh": {
|
|
27
|
+
"bundle": { "patch": "./cordis.patch.yml" },
|
|
28
|
+
"client": {
|
|
29
|
+
"platform": "web",
|
|
30
|
+
"immediately": true,
|
|
31
|
+
"inject": [
|
|
32
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right",
|
|
33
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
34
|
+
"@deepseek-ai/dsh-client-ui-sidebar-documentpreview"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|