pi-session-peek 0.5.0 → 0.5.2
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/CHANGELOG.md +15 -0
- package/README.md +15 -9
- package/README.zh-CN.md +15 -9
- package/index.ts +7 -3
- package/package.json +1 -1
- package/src/ansi.ts +73 -0
- package/src/drag-select.ts +119 -0
- package/src/i18n.ts +2 -2
- package/src/layout.ts +18 -0
- package/src/list.ts +64 -0
- package/src/peek-component.ts +113 -307
- package/src/preview.ts +139 -0
- package/src/query.ts +12 -58
- package/src/selection.ts +7 -11
- package/src/sessions.ts +42 -19
- package/src/theme.ts +12 -0
package/src/peek-component.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Input,
|
|
3
3
|
Key,
|
|
4
|
-
Markdown,
|
|
5
4
|
matchesKey,
|
|
6
5
|
truncateToWidth,
|
|
7
6
|
visibleWidth,
|
|
@@ -12,17 +11,21 @@ import {
|
|
|
12
11
|
type TuiMouseEventResult,
|
|
13
12
|
} from "@earendil-works/pi-tui";
|
|
14
13
|
import { msg } from "./i18n.ts";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import
|
|
14
|
+
import { DragSelect } from "./drag-select.ts";
|
|
15
|
+
import { BODY_TOP, bodyRows, paneWidths } from "./layout.ts";
|
|
16
|
+
import { buildList, followSelection, listRows, visibleItems } from "./list.ts";
|
|
17
|
+
import { buildPreview, initialOffset, nextMatchTarget, type RenderCache } from "./preview.ts";
|
|
18
|
+
import { matchesSession, parseQuery, roleTerms, type ParsedQuery, type RoleTerms } from "./query.ts";
|
|
19
|
+
import { bounds, highlightColumns, rowColumns, selectionText, type Selection } from "./selection.ts";
|
|
20
|
+
import type { PeekSession } from "./sessions.ts";
|
|
18
21
|
import { fmtTime, normPath, padEndVisible } from "./text.ts";
|
|
22
|
+
import type { PeekTheme } from "./theme.ts";
|
|
19
23
|
|
|
20
|
-
// 双栏选择器。文件操作(删除 / 重命名 / 分叉)不在这里做,由 index.ts
|
|
24
|
+
// 双栏选择器。文件操作(删除 / 重命名 / 分叉)不在这里做,由 index.ts 通过回调注入。
|
|
25
|
+
// 几何常量在 layout.ts,左栏在 list.ts,右栏在 preview.ts,拖选状态机在 drag-select.ts
|
|
21
26
|
|
|
22
|
-
const BODY_TOP = 3; // 头部、搜索框、分隔线之后才是双栏主体
|
|
23
|
-
const SEP = 3; // 两栏之间 " │ " 占的列数
|
|
24
27
|
const WHEEL_LINES = 3; // 滚轮每格滚几行预览,和 Shift+↑↓ 一样
|
|
25
|
-
const
|
|
28
|
+
const WHEEL_ITEMS = 1; // 滚轮每格滚几个列表项(每项两行)。只滚视图,不换选中项,像浏览器的滚动条
|
|
26
29
|
const HINT_MS = 1500; // 底部"已复制"提示停留时间
|
|
27
30
|
|
|
28
31
|
export class PeekComponent implements Component, Focusable {
|
|
@@ -31,25 +34,31 @@ export class PeekComponent implements Component, Focusable {
|
|
|
31
34
|
private filtered: PeekSession[] = [];
|
|
32
35
|
private selected = 0;
|
|
33
36
|
private listOffset = 0;
|
|
37
|
+
private followSel = true; // 下次 render 把选中项带回视野:换选中项、重新过滤、删除后置真,滚轮滚列表不置
|
|
34
38
|
private previewOffset = 0;
|
|
35
39
|
private previewKey = ""; // 预览缓存的键(会话 + 关键词 + 宽度 + 工具开关),置空强制重建
|
|
36
40
|
private showTools = false; // Ctrl+T:预览里显示每次工具调用的一行摘要
|
|
37
41
|
private previewLines: string[] = [];
|
|
38
42
|
private matchLines: number[] = []; // 预览里含关键词的行(升序),Ctrl+N / Ctrl+P 用
|
|
39
|
-
private rendered = new WeakMap
|
|
40
|
-
|
|
41
|
-
private
|
|
43
|
+
private rendered: RenderCache = new WeakMap(); // 每条消息渲染好的行,按宽度缓存
|
|
44
|
+
// 三个互斥的输入状态:搜索(默认)、Ctrl+R 之后在输入新名字、Ctrl+D 之后等待确认。改名和确认只从搜索进入、退回搜索
|
|
45
|
+
private mode: "search" | "rename" | "confirmDelete" = "search";
|
|
46
|
+
private busy = false; // 删除 / 重命名的异步回调还没回来:期间不能再删、改名、进入或分叉,免得对同一个文件动两次
|
|
42
47
|
private renameInput: Input;
|
|
43
48
|
private cachedWidth = -1;
|
|
44
49
|
private cachedLines: string[] = [];
|
|
45
50
|
private layout = { lw: 0, rw: 0, H: 0 }; // 最近一次 render 的分栏尺寸,鼠标定位用
|
|
46
51
|
private scopeSpan: [number, number] = [0, 0]; // 头部"范围"字样占的列区间,点击切换范围
|
|
47
|
-
|
|
48
|
-
private
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
// 拖选状态机。分栏尺寸和滚动位置惰性从组件读,自动滚动时把新的预览偏移写回来
|
|
53
|
+
private drag = new DragSelect({
|
|
54
|
+
geometry: () => ({ ...this.layout, listOffset: this.listOffset, previewOffset: this.previewOffset }),
|
|
55
|
+
previewLength: () => this.previewLines.length,
|
|
56
|
+
setPreviewOffset: (n) => void (this.previewOffset = n),
|
|
57
|
+
changed: () => {
|
|
58
|
+
this.invalidate();
|
|
59
|
+
this.requestRender?.();
|
|
60
|
+
},
|
|
61
|
+
});
|
|
53
62
|
private hint?: { text: string; ok: boolean }; // 底部一闪而过的复制结果
|
|
54
63
|
private hintTimer?: ReturnType<typeof setTimeout>;
|
|
55
64
|
private _focused = false;
|
|
@@ -68,7 +77,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
68
77
|
constructor(
|
|
69
78
|
private all: PeekSession[],
|
|
70
79
|
private currentCwd: string,
|
|
71
|
-
private theme:
|
|
80
|
+
private theme: PeekTheme,
|
|
72
81
|
private mdTheme: MarkdownTheme,
|
|
73
82
|
private termRows: number,
|
|
74
83
|
initialQuery: string,
|
|
@@ -87,8 +96,8 @@ export class PeekComponent implements Component, Focusable {
|
|
|
87
96
|
}
|
|
88
97
|
set focused(v: boolean) {
|
|
89
98
|
this._focused = v;
|
|
90
|
-
this.input.focused = v &&
|
|
91
|
-
this.renameInput.focused = v && this.
|
|
99
|
+
this.input.focused = v && this.mode !== "rename";
|
|
100
|
+
this.renameInput.focused = v && this.mode === "rename";
|
|
92
101
|
}
|
|
93
102
|
|
|
94
103
|
getQuery(): string {
|
|
@@ -118,57 +127,52 @@ export class PeekComponent implements Component, Focusable {
|
|
|
118
127
|
const idx = prev ? out.indexOf(prev) : -1;
|
|
119
128
|
this.selected = idx >= 0 ? idx : keepSelection ? Math.min(this.selected, Math.max(0, out.length - 1)) : 0;
|
|
120
129
|
if (!keepSelection) this.listOffset = 0;
|
|
130
|
+
this.followSel = true;
|
|
121
131
|
this.previewKey = "";
|
|
122
|
-
this.
|
|
132
|
+
this.drag.clear();
|
|
123
133
|
}
|
|
124
134
|
|
|
125
|
-
//
|
|
135
|
+
// 换选中的会话:预览重建,拖选的选区作废,视野跟过去
|
|
126
136
|
private select(idx: number): void {
|
|
127
137
|
this.selected = idx;
|
|
138
|
+
this.followSel = true;
|
|
128
139
|
this.previewKey = "";
|
|
129
|
-
this.
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
private clearSelection(): void {
|
|
133
|
-
this.sel = undefined;
|
|
134
|
-
this.dragging = false;
|
|
135
|
-
this.selMoved = false;
|
|
136
|
-
this.stopAutoScroll();
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// 拖过的才算选区,只按下没拖的是单击
|
|
140
|
-
private activeSel(): Selection | undefined {
|
|
141
|
-
return this.sel && this.selMoved ? this.sel : undefined;
|
|
140
|
+
this.drag.clear();
|
|
142
141
|
}
|
|
143
142
|
|
|
144
143
|
dispose(): void {
|
|
145
|
-
this.
|
|
144
|
+
this.drag.dispose();
|
|
146
145
|
if (this.hintTimer) clearTimeout(this.hintTimer);
|
|
147
146
|
this.hintTimer = undefined;
|
|
148
147
|
this.onDispose?.();
|
|
149
148
|
}
|
|
150
149
|
|
|
151
150
|
private bodyHeight(): number {
|
|
152
|
-
|
|
153
|
-
return Math.max(8, Math.min(rows - 12, 30));
|
|
151
|
+
return bodyRows(this.termRows);
|
|
154
152
|
}
|
|
155
153
|
|
|
156
154
|
handleInput(data: string): void {
|
|
157
155
|
// 重命名输入中:Enter 提交,Esc / Ctrl+C 取消,其他键交给输入框
|
|
158
|
-
if (this.
|
|
156
|
+
if (this.mode === "rename") {
|
|
159
157
|
if (matchesKey(data, Key.enter)) {
|
|
160
158
|
const s = this.filtered[this.selected];
|
|
161
159
|
const name = this.renameInput.getValue().trim();
|
|
162
|
-
this.
|
|
160
|
+
this.mode = "search";
|
|
163
161
|
this.focused = this._focused;
|
|
164
162
|
this.invalidate();
|
|
165
|
-
if (s && name && this.onRename) {
|
|
163
|
+
if (s && name && this.onRename && !this.busy) {
|
|
164
|
+
this.busy = true;
|
|
166
165
|
void this.onRename(s, name).then((ok) => {
|
|
167
166
|
if (ok) {
|
|
168
167
|
s.name = name;
|
|
169
168
|
// 名字参与搜索,改完可能就不再匹配当前关键词了
|
|
170
169
|
this.refilter(true);
|
|
171
170
|
}
|
|
171
|
+
}).catch(() => {
|
|
172
|
+
// 回调自己负责提示;这里吞掉,别变成 unhandled rejection
|
|
173
|
+
}).finally(() => {
|
|
174
|
+
// 放在 finally:回调抛了错也要解锁,否则删除 / 改名 / 进入全部永久失效
|
|
175
|
+
this.busy = false;
|
|
172
176
|
this.invalidate();
|
|
173
177
|
this.requestRender?.();
|
|
174
178
|
});
|
|
@@ -176,7 +180,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
176
180
|
return;
|
|
177
181
|
}
|
|
178
182
|
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
179
|
-
this.
|
|
183
|
+
this.mode = "search";
|
|
180
184
|
this.focused = this._focused;
|
|
181
185
|
this.invalidate();
|
|
182
186
|
return;
|
|
@@ -186,12 +190,13 @@ export class PeekComponent implements Component, Focusable {
|
|
|
186
190
|
return;
|
|
187
191
|
}
|
|
188
192
|
// 删除确认中:y / Enter 执行,其他任何键取消
|
|
189
|
-
if (this.
|
|
190
|
-
this.
|
|
193
|
+
if (this.mode === "confirmDelete") {
|
|
194
|
+
this.mode = "search";
|
|
191
195
|
this.invalidate();
|
|
192
196
|
if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
|
|
193
197
|
const s = this.filtered[this.selected];
|
|
194
|
-
if (s && this.onDelete) {
|
|
198
|
+
if (s && this.onDelete && !this.busy) {
|
|
199
|
+
this.busy = true;
|
|
195
200
|
void this.onDelete(s).then((ok) => {
|
|
196
201
|
if (ok) {
|
|
197
202
|
let i = this.all.indexOf(s);
|
|
@@ -200,9 +205,13 @@ export class PeekComponent implements Component, Focusable {
|
|
|
200
205
|
if (i >= 0) this.filtered.splice(i, 1);
|
|
201
206
|
// 选中位置留在原地
|
|
202
207
|
this.selected = Math.min(this.selected, Math.max(0, this.filtered.length - 1));
|
|
208
|
+
this.followSel = true;
|
|
203
209
|
this.previewKey = "";
|
|
204
|
-
this.
|
|
210
|
+
this.drag.clear();
|
|
205
211
|
}
|
|
212
|
+
}).catch(() => {
|
|
213
|
+
}).finally(() => {
|
|
214
|
+
this.busy = false;
|
|
206
215
|
this.invalidate();
|
|
207
216
|
this.requestRender?.();
|
|
208
217
|
});
|
|
@@ -212,7 +221,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
212
221
|
}
|
|
213
222
|
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
214
223
|
// 有拖选的选区时 Ctrl+C 是复制,和终端里的习惯一样;没有才是关闭
|
|
215
|
-
if (matchesKey(data, Key.ctrl("c")) && this.
|
|
224
|
+
if (matchesKey(data, Key.ctrl("c")) && this.drag.active()) {
|
|
216
225
|
this.copySelection();
|
|
217
226
|
return;
|
|
218
227
|
}
|
|
@@ -220,16 +229,16 @@ export class PeekComponent implements Component, Focusable {
|
|
|
220
229
|
return;
|
|
221
230
|
}
|
|
222
231
|
if (matchesKey(data, Key.ctrl("d"))) {
|
|
223
|
-
if (this.filtered.length && this.onDelete) {
|
|
224
|
-
this.
|
|
232
|
+
if (this.filtered.length && this.onDelete && !this.busy) {
|
|
233
|
+
this.mode = "confirmDelete";
|
|
225
234
|
this.invalidate();
|
|
226
235
|
}
|
|
227
236
|
return;
|
|
228
237
|
}
|
|
229
238
|
if (matchesKey(data, Key.ctrl("r"))) {
|
|
230
239
|
const s = this.filtered[this.selected];
|
|
231
|
-
if (s && this.onRename) {
|
|
232
|
-
this.
|
|
240
|
+
if (s && this.onRename && !this.busy) {
|
|
241
|
+
this.mode = "rename";
|
|
233
242
|
this.renameInput.setValue(s.name || "");
|
|
234
243
|
this.focused = this._focused;
|
|
235
244
|
this.invalidate();
|
|
@@ -281,7 +290,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
281
290
|
if (matchesKey(data, Key.ctrl("t"))) {
|
|
282
291
|
this.showTools = !this.showTools;
|
|
283
292
|
this.previewKey = "";
|
|
284
|
-
this.
|
|
293
|
+
this.drag.clear();
|
|
285
294
|
this.invalidate();
|
|
286
295
|
return;
|
|
287
296
|
}
|
|
@@ -295,12 +304,12 @@ export class PeekComponent implements Component, Focusable {
|
|
|
295
304
|
}
|
|
296
305
|
if (matchesKey(data, Key.enter)) {
|
|
297
306
|
const s = this.filtered[this.selected];
|
|
298
|
-
if (s) this.onResume?.(s);
|
|
307
|
+
if (s && !this.busy) this.onResume?.(s);
|
|
299
308
|
return;
|
|
300
309
|
}
|
|
301
310
|
if (matchesKey(data, Key.ctrl("o"))) {
|
|
302
311
|
const s = this.filtered[this.selected];
|
|
303
|
-
if (s) this.onFork?.(s);
|
|
312
|
+
if (s && !this.busy) this.onFork?.(s);
|
|
304
313
|
return;
|
|
305
314
|
}
|
|
306
315
|
// 其余按键交给搜索框
|
|
@@ -313,7 +322,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
313
322
|
// 硬件光标在组件内的行号:搜索框在第 1 行,改名时在最后一行。常规模式的鼠标桥接靠它算组件顶行
|
|
314
323
|
cursorRow(): number | undefined {
|
|
315
324
|
if (!this._focused || !this.layout.H) return undefined;
|
|
316
|
-
return this.
|
|
325
|
+
return this.mode === "rename" ? BODY_TOP + this.layout.H + 1 : 1;
|
|
317
326
|
}
|
|
318
327
|
|
|
319
328
|
// 鼠标:左栏按下选中、双击进入、滚轮换选中项;右栏滚轮滚预览;点头部"范围"切换;点输入框移光标;
|
|
@@ -330,16 +339,15 @@ export class PeekComponent implements Component, Focusable {
|
|
|
330
339
|
if (ev.type === "wheel") {
|
|
331
340
|
const delta = ev.wheelDelta ?? 0;
|
|
332
341
|
if (!delta) return undefined;
|
|
333
|
-
|
|
334
|
-
|
|
342
|
+
// 和按键一样,滚一下就算取消确认。只退出确认,改名中滚轮不退出改名
|
|
343
|
+
let changed = this.cancelConfirm();
|
|
335
344
|
if (inList) {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
345
|
+
// 只滚视图,选中项不动;↑↓、点击、重新过滤会把选中项带回视野
|
|
346
|
+
const maxOff = Math.max(0, this.filtered.length - visibleItems(H));
|
|
347
|
+
const next = Math.max(0, Math.min(maxOff, this.listOffset + delta * WHEEL_ITEMS));
|
|
348
|
+
if (next !== this.listOffset) {
|
|
349
|
+
this.listOffset = next;
|
|
350
|
+
changed = true;
|
|
343
351
|
}
|
|
344
352
|
} else {
|
|
345
353
|
const maxOff = Math.max(0, this.previewLines.length - H);
|
|
@@ -353,38 +361,34 @@ export class PeekComponent implements Component, Focusable {
|
|
|
353
361
|
return { handled: true, render: changed };
|
|
354
362
|
}
|
|
355
363
|
if (ev.type === "drag") {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
//
|
|
361
|
-
if (
|
|
364
|
+
const d = this.drag;
|
|
365
|
+
if (!d.dragging || !d.sel || ev.button !== "left") return undefined;
|
|
366
|
+
const pane = d.sel.pane;
|
|
367
|
+
const changed = d.move(ev.x, bodyRow);
|
|
368
|
+
// 右栏拖出上下边就自动滚;左栏不滚
|
|
369
|
+
if (pane === "preview") d.autoScroll(bodyRow < 0 ? -1 : bodyRow >= H ? 1 : 0);
|
|
362
370
|
if (changed) this.invalidate();
|
|
363
371
|
return { handled: true, render: changed };
|
|
364
372
|
}
|
|
365
373
|
if (ev.button !== "left") return undefined;
|
|
366
374
|
if (ev.type === "release") {
|
|
367
|
-
if (!this.
|
|
368
|
-
this.dragging = false;
|
|
369
|
-
this.stopAutoScroll();
|
|
370
|
-
if (!this.selMoved) this.sel = undefined; // 没拖过就是单击
|
|
375
|
+
if (!this.drag.release()) return undefined;
|
|
371
376
|
return { handled: true, render: false };
|
|
372
377
|
}
|
|
373
378
|
if (ev.type === "click") {
|
|
374
379
|
// 单击在 press 里已经选中了,双击才进入
|
|
375
380
|
const s = this.filtered[itemAt()];
|
|
376
381
|
if (!s) return undefined;
|
|
377
|
-
if (!this.
|
|
382
|
+
if (this.mode !== "rename" && !this.busy && (ev.clickCount ?? 1) >= 2) this.onResume?.(s);
|
|
378
383
|
return { handled: true, render: false };
|
|
379
384
|
}
|
|
380
385
|
if (ev.type !== "press") return undefined;
|
|
381
386
|
|
|
382
387
|
// 删除确认和已有的选区:点一下就没了,和按键一样
|
|
383
|
-
let changed = this.
|
|
384
|
-
this.
|
|
385
|
-
this.clearSelection();
|
|
388
|
+
let changed = this.cancelConfirm() || this.drag.active() !== undefined;
|
|
389
|
+
this.drag.clear();
|
|
386
390
|
let handled = changed;
|
|
387
|
-
if (this.
|
|
391
|
+
if (this.mode === "rename") {
|
|
388
392
|
if (ev.y === BODY_TOP + H + 1) {
|
|
389
393
|
// 改名行:点哪里光标就去哪里
|
|
390
394
|
this.renameInput.handleMouse({ ...ev, x: ev.x - visibleWidth(msg("renamePrefix")), y: 0 });
|
|
@@ -408,11 +412,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
408
412
|
}
|
|
409
413
|
if (inBody) {
|
|
410
414
|
// 两栏里按下都可能是拖选的起点,拖起来才算数;接管后面的拖动和松开
|
|
411
|
-
|
|
412
|
-
const cell = this.cellAt(pane, ev.x, bodyRow);
|
|
413
|
-
this.sel = { pane, anchor: cell, focus: { ...cell } };
|
|
414
|
-
this.dragging = true;
|
|
415
|
-
this.dragX = ev.x;
|
|
415
|
+
this.drag.begin(inList ? "list" : "preview", ev.x, bodyRow);
|
|
416
416
|
handled = true;
|
|
417
417
|
}
|
|
418
418
|
if (!handled) return undefined;
|
|
@@ -420,55 +420,13 @@ export class PeekComponent implements Component, Focusable {
|
|
|
420
420
|
return inBody ? { handled: true, capture: true, render: changed } : { handled: true, render: changed };
|
|
421
421
|
}
|
|
422
422
|
|
|
423
|
-
//
|
|
424
|
-
private
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
if (pane === "list") return { row: this.listOffset * 2 + row, col: Math.max(0, Math.min(lw - 1, x)) };
|
|
428
|
-
return { row: this.previewOffset + row, col: Math.max(0, Math.min(rw - 1, x - lw - SEP)) };
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
// 拖到某个位置:焦点跟过去。返回有没有变
|
|
432
|
-
private dragTo(x: number, y: number): boolean {
|
|
433
|
-
if (!this.sel) return false;
|
|
434
|
-
const cell = this.cellAt(this.sel.pane, x, y - BODY_TOP);
|
|
435
|
-
if (cell.row === this.sel.focus.row && cell.col === this.sel.focus.col) return false;
|
|
436
|
-
this.sel.focus = cell;
|
|
423
|
+
// 鼠标滚轮 / 按下时退出删除确认(只退出确认,改名状态不动)。返回有没有退出
|
|
424
|
+
private cancelConfirm(): boolean {
|
|
425
|
+
if (this.mode !== "confirmDelete") return false;
|
|
426
|
+
this.mode = "search";
|
|
437
427
|
return true;
|
|
438
428
|
}
|
|
439
429
|
|
|
440
|
-
private setAutoScroll(dir: number): void {
|
|
441
|
-
this.autoScrollDir = dir;
|
|
442
|
-
if (!dir) {
|
|
443
|
-
this.stopAutoScroll();
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
446
|
-
if (this.autoScrollTimer) return;
|
|
447
|
-
this.autoScrollTimer = setInterval(() => this.autoScrollTick(), AUTO_SCROLL_MS);
|
|
448
|
-
this.autoScrollTimer.unref?.();
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// 每次滚一行,焦点跟着指针:指针在上边就是视口第一行,在下边就是最后一行
|
|
452
|
-
private autoScrollTick(): void {
|
|
453
|
-
const { H } = this.layout;
|
|
454
|
-
const maxOff = Math.max(0, this.previewLines.length - H);
|
|
455
|
-
const next = Math.max(0, Math.min(maxOff, this.previewOffset + this.autoScrollDir));
|
|
456
|
-
if (!this.sel || !this.dragging || next === this.previewOffset) {
|
|
457
|
-
this.stopAutoScroll();
|
|
458
|
-
return;
|
|
459
|
-
}
|
|
460
|
-
this.previewOffset = next;
|
|
461
|
-
if (this.dragTo(this.dragX, this.autoScrollDir < 0 ? BODY_TOP - 1 : BODY_TOP + H)) this.selMoved = true;
|
|
462
|
-
this.invalidate();
|
|
463
|
-
this.requestRender?.();
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
private stopAutoScroll(): void {
|
|
467
|
-
if (this.autoScrollTimer) clearInterval(this.autoScrollTimer);
|
|
468
|
-
this.autoScrollTimer = undefined;
|
|
469
|
-
this.autoScrollDir = 0;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
430
|
// 选区里的纯文本
|
|
473
431
|
private selectionToText(sel: Selection): string {
|
|
474
432
|
const { lw, rw } = this.layout;
|
|
@@ -476,17 +434,17 @@ export class PeekComponent implements Component, Focusable {
|
|
|
476
434
|
const { rt, hasBody } = this.listTerms();
|
|
477
435
|
return selectionText(sel, lw, (row) => {
|
|
478
436
|
const s = this.filtered[row >> 1];
|
|
479
|
-
return s &&
|
|
437
|
+
return s && listRows(s, (row >> 1) === this.selected, lw, rt, hasBody, this.theme)[row & 1];
|
|
480
438
|
});
|
|
481
439
|
}
|
|
482
440
|
|
|
483
441
|
// Ctrl+C:复制选区,清掉高亮,底部闪一下结果
|
|
484
442
|
private copySelection(): void {
|
|
485
|
-
const sel = this.
|
|
443
|
+
const sel = this.drag.active();
|
|
486
444
|
if (!sel) return;
|
|
487
445
|
const text = this.selectionToText(sel);
|
|
488
446
|
const n = text.split("\n").length;
|
|
489
|
-
this.
|
|
447
|
+
this.drag.clear();
|
|
490
448
|
this.invalidate();
|
|
491
449
|
if (!text.trim() || !this.onCopy) return;
|
|
492
450
|
void this.onCopy(text).then((ok) => this.showHint(ok ? msg("copied", { n }) : msg("copyFailed"), ok));
|
|
@@ -506,19 +464,12 @@ export class PeekComponent implements Component, Focusable {
|
|
|
506
464
|
this.requestRender?.();
|
|
507
465
|
}
|
|
508
466
|
|
|
509
|
-
// 预览里跳到当前视口之外的下一个 /
|
|
510
|
-
// 否则一段里连着几行都命中要按好几次才过得去
|
|
467
|
+
// 预览里跳到当前视口之外的下一个 / 上一个命中行,到头了回绕
|
|
511
468
|
private jumpMatch(dir: 1 | -1): void {
|
|
512
|
-
if (!this.matchLines.length) return;
|
|
513
469
|
const H = this.bodyHeight();
|
|
514
470
|
const top = this.previewOffset;
|
|
515
471
|
const bottom = Math.min(top + H, this.previewLines.length) - 1;
|
|
516
|
-
|
|
517
|
-
if (dir === 1) {
|
|
518
|
-
target = this.matchLines.find((l) => l > bottom) ?? this.matchLines[0];
|
|
519
|
-
} else {
|
|
520
|
-
target = [...this.matchLines].reverse().find((l) => l < top) ?? this.matchLines[this.matchLines.length - 1];
|
|
521
|
-
}
|
|
472
|
+
const target = nextMatchTarget(this.matchLines, top, bottom, dir);
|
|
522
473
|
if (target !== undefined) {
|
|
523
474
|
this.previewOffset = Math.max(0, target - 2);
|
|
524
475
|
this.invalidate();
|
|
@@ -531,168 +482,16 @@ export class PeekComponent implements Component, Focusable {
|
|
|
531
482
|
return { rt, hasBody: rt.user.length > 0 || rt.assistant.length > 0 };
|
|
532
483
|
}
|
|
533
484
|
|
|
534
|
-
//
|
|
535
|
-
private
|
|
536
|
-
const t = this.theme;
|
|
537
|
-
const time = fmtTime(s.time);
|
|
538
|
-
const cwdTail = s.cwd.replace(/\\/g, "/").split("/").slice(-2).join("/");
|
|
539
|
-
const hitBadge = hasBody
|
|
540
|
-
? ` ·${s.msgs.reduce((n, m) => n + (anyMatch(m.text, rt[m.role]) ? 1 : 0), 0)}`
|
|
541
|
-
: "";
|
|
542
|
-
const l1 = `${sel ? "›" : " "} ${time} ${cwdTail}${hitBadge}`;
|
|
543
|
-
const snip = hasBody ? snippet(s.msgs, rt, lw) : undefined;
|
|
544
|
-
const l2 = snip !== undefined
|
|
545
|
-
? ` ${highlight(snip.text, rt[snip.role], t)}`
|
|
546
|
-
: ` ${s.first}${s.name ? ` [${s.name}]` : ""}`;
|
|
547
|
-
if (sel) {
|
|
548
|
-
return [
|
|
549
|
-
t.bg("selectedBg", padEndVisible(t.fg("accent", truncateToWidth(l1, lw)), lw)),
|
|
550
|
-
t.bg("selectedBg", padEndVisible(truncateToWidth(l2, lw), lw)),
|
|
551
|
-
];
|
|
552
|
-
}
|
|
553
|
-
return [padEndVisible(l1, lw), padEndVisible(t.fg("dim", truncateToWidth(l2, lw)), lw)];
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// 左栏,每个会话两行
|
|
557
|
-
private buildList(height: number, lw: number): string[] {
|
|
558
|
-
const { rt, hasBody } = this.listTerms();
|
|
559
|
-
const visible = Math.max(1, Math.floor(height / 2));
|
|
560
|
-
if (this.selected < this.listOffset) this.listOffset = this.selected;
|
|
561
|
-
if (this.selected >= this.listOffset + visible) {
|
|
562
|
-
this.listOffset = this.selected - visible + 1;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
const rows: string[] = [];
|
|
566
|
-
for (let i = 0; i < visible; i++) {
|
|
567
|
-
const idx = this.listOffset + i;
|
|
568
|
-
const s = this.filtered[idx];
|
|
569
|
-
if (!s) {
|
|
570
|
-
rows.push(" ".repeat(lw), " ".repeat(lw));
|
|
571
|
-
continue;
|
|
572
|
-
}
|
|
573
|
-
rows.push(...this.listRows(s, idx === this.selected, lw, rt, hasBody));
|
|
574
|
-
}
|
|
575
|
-
// 每项两行,height 是奇数时会少一行,补空行,否则最后一行右栏会顶到左边
|
|
576
|
-
while (rows.length < height) rows.push(" ".repeat(lw));
|
|
577
|
-
return rows.slice(0, height);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// 用 pi 自己的 Markdown 组件渲染一条消息,和主界面里的对话长得一样;参数照抄 pi 的
|
|
581
|
-
// user-message / assistant-message 组件。渲染比较贵,按消息和宽度缓存,换关键词时不用重来
|
|
582
|
-
private renderMsg(m: PeekMsg, rw: number): string[] {
|
|
583
|
-
const hit = this.rendered.get(m);
|
|
584
|
-
if (hit && hit.w === rw) return hit.lines;
|
|
585
|
-
const t = this.theme;
|
|
586
|
-
const md =
|
|
587
|
-
m.role === "user"
|
|
588
|
-
? new Markdown(
|
|
589
|
-
m.text,
|
|
590
|
-
1,
|
|
591
|
-
0,
|
|
592
|
-
this.mdTheme,
|
|
593
|
-
{ color: (s) => t.fg("userMessageText", s), bgColor: (s) => t.bg("userMessageBg", s) },
|
|
594
|
-
{ preserveOrderedListMarkers: true, preserveBackslashEscapes: true },
|
|
595
|
-
)
|
|
596
|
-
: new Markdown(m.text, 1, 0, this.mdTheme);
|
|
597
|
-
const lines = md.render(rw);
|
|
598
|
-
this.rendered.set(m, { w: rw, lines });
|
|
599
|
-
return lines;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
// 右栏。有关键词时滚到第一个命中处,没有时滚到底部看最新消息
|
|
603
|
-
private buildPreview(rw: number): void {
|
|
604
|
-
const t = this.theme;
|
|
485
|
+
// 右栏。预览键(会话 + 关键词 + 宽度 + 工具开关)没变就复用;变了重建,滚动位置放到首个命中处或底部
|
|
486
|
+
private rebuildPreview(rw: number, rt: RoleTerms): void {
|
|
605
487
|
const s = this.filtered[this.selected];
|
|
606
488
|
const key = s ? `${s.path}|${this.getQuery()}|${rw}|${this.showTools ? "t" : ""}` : "none";
|
|
607
489
|
if (key === this.previewKey) return;
|
|
608
490
|
this.previewKey = key;
|
|
609
|
-
this.
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
this.previewOffset = 0;
|
|
614
|
-
return;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
const lines: string[] = [];
|
|
618
|
-
// 消息数只算有文字的,纯工具轮次不计
|
|
619
|
-
const textCount = s.msgs.reduce((n, m) => n + (m.text ? 1 : 0), 0);
|
|
620
|
-
lines.push(
|
|
621
|
-
truncateToWidth(
|
|
622
|
-
t.fg("dim", `${s.cwd} • ${msg("msgCount", { n: textCount })}${s.name ? " • " + s.name : ""}`),
|
|
623
|
-
rw,
|
|
624
|
-
),
|
|
625
|
-
);
|
|
626
|
-
lines.push("");
|
|
627
|
-
|
|
628
|
-
const rt = roleTerms(this.query().terms);
|
|
629
|
-
const hasBody = rt.user.length > 0 || rt.assistant.length > 0;
|
|
630
|
-
const MAX_MSGS = 500;
|
|
631
|
-
const truncated = s.msgs.length > MAX_MSGS;
|
|
632
|
-
const msgs = truncated ? s.msgs.slice(-MAX_MSGS) : s.msgs;
|
|
633
|
-
if (truncated) {
|
|
634
|
-
lines.push(t.fg("dim", msg("truncated", { n: MAX_MSGS })));
|
|
635
|
-
lines.push("");
|
|
636
|
-
}
|
|
637
|
-
if (hasBody && !msgs.some((m) => anyMatch(m.text, rt[m.role]))) {
|
|
638
|
-
const inTruncated = truncated && s.msgs.some((m) => anyMatch(m.text, rt[m.role]));
|
|
639
|
-
lines.push(
|
|
640
|
-
t.fg(
|
|
641
|
-
"warning",
|
|
642
|
-
inTruncated ? msg("hitInTruncated") : msg("hitOnlyMeta"),
|
|
643
|
-
),
|
|
644
|
-
);
|
|
645
|
-
lines.push("");
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
// 用户消息整块背景色,AI 消息只有一条细线标签
|
|
649
|
-
const fillLabel = (label: string): string => {
|
|
650
|
-
const w = visibleWidth(label);
|
|
651
|
-
return truncateToWidth(label + " " + "─".repeat(Math.max(2, rw - w - 1)), rw);
|
|
652
|
-
};
|
|
653
|
-
|
|
654
|
-
let prevRole: PeekMsg["role"] | undefined; // 上一条画出来的消息的角色
|
|
655
|
-
for (const m of msgs) {
|
|
656
|
-
const tools = this.showTools ? (m.tools ?? []) : [];
|
|
657
|
-
if (!m.text && !tools.length) continue; // 工具隐藏时,只有工具调用的 AI 轮次整条跳过
|
|
658
|
-
const terms = rt[m.role]; // user: 的词只在用户消息里高亮,ai: 的只在 AI 回复里
|
|
659
|
-
const isMatch = terms.length > 0 && anyMatch(m.text, terms);
|
|
660
|
-
// 紧跟在 AI 文字后面、只有工具调用的轮次不重复画标签,接在上一条正文下面,看起来是同一轮
|
|
661
|
-
const continues = m.role === "assistant" && prevRole === "assistant" && !m.text;
|
|
662
|
-
if (continues) {
|
|
663
|
-
lines.pop();
|
|
664
|
-
} else if (m.role === "user") {
|
|
665
|
-
lines.push(
|
|
666
|
-
t.bg("userMessageBg", padEndVisible(t.bold(t.fg("accent", fillLabel(msg("you")))), rw)),
|
|
667
|
-
);
|
|
668
|
-
} else {
|
|
669
|
-
lines.push(t.fg("muted", fillLabel(msg("ai"))));
|
|
670
|
-
}
|
|
671
|
-
const head = lines.length - 1;
|
|
672
|
-
// 先渲染再高亮:往 markdown 源码里插转义码会把链接、代码块的语法弄坏
|
|
673
|
-
const before = this.matchLines.length;
|
|
674
|
-
if (m.text) {
|
|
675
|
-
for (const l of this.renderMsg(m, rw)) {
|
|
676
|
-
if (isMatch && lineHasMatch(l, terms)) this.matchLines.push(lines.length);
|
|
677
|
-
lines.push(isMatch ? highlight(l, terms, t) : l);
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
// 关键词被折行拆开时哪一行都找不到,退回到消息标题行,别把这条消息漏掉
|
|
681
|
-
if (isMatch && this.matchLines.length === before) this.matchLines.push(head);
|
|
682
|
-
for (const tool of tools) {
|
|
683
|
-
lines.push(t.fg("dim", truncateToWidth(` ⚙ ${tool.name}${tool.summary ? " " + tool.summary : ""}`, rw)));
|
|
684
|
-
}
|
|
685
|
-
lines.push("");
|
|
686
|
-
prevRole = m.role;
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
this.previewLines = lines;
|
|
690
|
-
|
|
691
|
-
if (this.matchLines.length) {
|
|
692
|
-
this.previewOffset = Math.max(0, this.matchLines[0] - 2);
|
|
693
|
-
} else {
|
|
694
|
-
this.previewOffset = Math.max(0, lines.length - this.bodyHeight());
|
|
695
|
-
}
|
|
491
|
+
const p = buildPreview(s, rt, rw, this.showTools, this.theme, this.mdTheme, this.rendered);
|
|
492
|
+
this.previewLines = p.lines;
|
|
493
|
+
this.matchLines = p.matchLines;
|
|
494
|
+
this.previewOffset = initialOffset(p, this.bodyHeight());
|
|
696
495
|
}
|
|
697
496
|
|
|
698
497
|
render(width: number): string[] {
|
|
@@ -704,8 +503,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
704
503
|
return this.cachedLines;
|
|
705
504
|
}
|
|
706
505
|
const t = this.theme;
|
|
707
|
-
const lw
|
|
708
|
-
const rw = Math.max(20, width - lw - 3);
|
|
506
|
+
const { lw, rw } = paneWidths(width);
|
|
709
507
|
this.layout = { lw, rw, H };
|
|
710
508
|
|
|
711
509
|
const out: string[] = [];
|
|
@@ -727,8 +525,16 @@ export class PeekComponent implements Component, Focusable {
|
|
|
727
525
|
out.push(t.fg("borderMuted", "─".repeat(width)));
|
|
728
526
|
|
|
729
527
|
// 双栏
|
|
730
|
-
this.
|
|
731
|
-
|
|
528
|
+
const { rt, hasBody } = this.listTerms();
|
|
529
|
+
this.rebuildPreview(rw, rt);
|
|
530
|
+
// 选中项变了才把视野挪过去;滚轮滚出去的视野留着。列表变短、终端变高后别在底下留空白
|
|
531
|
+
const visible = visibleItems(H);
|
|
532
|
+
if (this.followSel) {
|
|
533
|
+
this.listOffset = followSelection(this.selected, this.listOffset, visible);
|
|
534
|
+
this.followSel = false;
|
|
535
|
+
}
|
|
536
|
+
this.listOffset = Math.min(this.listOffset, Math.max(0, this.filtered.length - visible));
|
|
537
|
+
const leftRows = buildList(this.filtered, this.selected, this.listOffset, H, lw, rt, hasBody, t);
|
|
732
538
|
|
|
733
539
|
const maxOff = Math.max(0, this.previewLines.length - H);
|
|
734
540
|
if (this.previewOffset > maxOff) this.previewOffset = maxOff;
|
|
@@ -743,7 +549,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
743
549
|
if (this.previewLines.length > H) info += ` (${this.previewOffset + 1}-${end}/${this.previewLines.length})`;
|
|
744
550
|
const scrollInfo = info ? t.fg("dim", info) : "";
|
|
745
551
|
|
|
746
|
-
const sel = this.
|
|
552
|
+
const sel = this.drag.active();
|
|
747
553
|
for (let i = 0; i < H; i++) {
|
|
748
554
|
let l = leftRows[i] ?? " ".repeat(lw);
|
|
749
555
|
let r = rightRows[i] ?? "";
|
|
@@ -764,11 +570,11 @@ export class PeekComponent implements Component, Focusable {
|
|
|
764
570
|
|
|
765
571
|
// 底部:重命名和删除确认时换成对应的操作行,复制结果和选区提示也在这里
|
|
766
572
|
out.push(t.fg("borderMuted", "─".repeat(width)));
|
|
767
|
-
if (this.
|
|
573
|
+
if (this.mode === "rename") {
|
|
768
574
|
const prefix = msg("renamePrefix");
|
|
769
575
|
const inputLine = this.renameInput.render(Math.max(10, width - visibleWidth(prefix)))[0] ?? "";
|
|
770
576
|
out.push(truncateToWidth(t.fg("warning", prefix) + inputLine, width));
|
|
771
|
-
} else if (this.
|
|
577
|
+
} else if (this.mode === "confirmDelete") {
|
|
772
578
|
const s = this.filtered[this.selected];
|
|
773
579
|
const desc = s ? `${fmtTime(s.time, true)} ${s.first.slice(0, 30)}` : "";
|
|
774
580
|
out.push(
|