pi-session-peek 0.5.1 → 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 +6 -0
- package/README.md +14 -8
- package/README.zh-CN.md +14 -8
- 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 +96 -301
- package/src/preview.ts +139 -0
- package/src/query.ts +12 -58
- package/src/selection.ts +7 -11
- package/src/sessions.ts +1 -5
- 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,26 +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";
|
|
42
46
|
private busy = false; // 删除 / 重命名的异步回调还没回来:期间不能再删、改名、进入或分叉,免得对同一个文件动两次
|
|
43
47
|
private renameInput: Input;
|
|
44
48
|
private cachedWidth = -1;
|
|
45
49
|
private cachedLines: string[] = [];
|
|
46
50
|
private layout = { lw: 0, rw: 0, H: 0 }; // 最近一次 render 的分栏尺寸,鼠标定位用
|
|
47
51
|
private scopeSpan: [number, number] = [0, 0]; // 头部"范围"字样占的列区间,点击切换范围
|
|
48
|
-
|
|
49
|
-
private
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
});
|
|
54
62
|
private hint?: { text: string; ok: boolean }; // 底部一闪而过的复制结果
|
|
55
63
|
private hintTimer?: ReturnType<typeof setTimeout>;
|
|
56
64
|
private _focused = false;
|
|
@@ -69,7 +77,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
69
77
|
constructor(
|
|
70
78
|
private all: PeekSession[],
|
|
71
79
|
private currentCwd: string,
|
|
72
|
-
private theme:
|
|
80
|
+
private theme: PeekTheme,
|
|
73
81
|
private mdTheme: MarkdownTheme,
|
|
74
82
|
private termRows: number,
|
|
75
83
|
initialQuery: string,
|
|
@@ -88,8 +96,8 @@ export class PeekComponent implements Component, Focusable {
|
|
|
88
96
|
}
|
|
89
97
|
set focused(v: boolean) {
|
|
90
98
|
this._focused = v;
|
|
91
|
-
this.input.focused = v &&
|
|
92
|
-
this.renameInput.focused = v && this.
|
|
99
|
+
this.input.focused = v && this.mode !== "rename";
|
|
100
|
+
this.renameInput.focused = v && this.mode === "rename";
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
getQuery(): string {
|
|
@@ -119,48 +127,37 @@ export class PeekComponent implements Component, Focusable {
|
|
|
119
127
|
const idx = prev ? out.indexOf(prev) : -1;
|
|
120
128
|
this.selected = idx >= 0 ? idx : keepSelection ? Math.min(this.selected, Math.max(0, out.length - 1)) : 0;
|
|
121
129
|
if (!keepSelection) this.listOffset = 0;
|
|
130
|
+
this.followSel = true;
|
|
122
131
|
this.previewKey = "";
|
|
123
|
-
this.
|
|
132
|
+
this.drag.clear();
|
|
124
133
|
}
|
|
125
134
|
|
|
126
|
-
//
|
|
135
|
+
// 换选中的会话:预览重建,拖选的选区作废,视野跟过去
|
|
127
136
|
private select(idx: number): void {
|
|
128
137
|
this.selected = idx;
|
|
138
|
+
this.followSel = true;
|
|
129
139
|
this.previewKey = "";
|
|
130
|
-
this.
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
private clearSelection(): void {
|
|
134
|
-
this.sel = undefined;
|
|
135
|
-
this.dragging = false;
|
|
136
|
-
this.selMoved = false;
|
|
137
|
-
this.stopAutoScroll();
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// 拖过的才算选区,只按下没拖的是单击
|
|
141
|
-
private activeSel(): Selection | undefined {
|
|
142
|
-
return this.sel && this.selMoved ? this.sel : undefined;
|
|
140
|
+
this.drag.clear();
|
|
143
141
|
}
|
|
144
142
|
|
|
145
143
|
dispose(): void {
|
|
146
|
-
this.
|
|
144
|
+
this.drag.dispose();
|
|
147
145
|
if (this.hintTimer) clearTimeout(this.hintTimer);
|
|
148
146
|
this.hintTimer = undefined;
|
|
149
147
|
this.onDispose?.();
|
|
150
148
|
}
|
|
151
149
|
|
|
152
150
|
private bodyHeight(): number {
|
|
153
|
-
|
|
154
|
-
return Math.max(8, Math.min(rows - 12, 30));
|
|
151
|
+
return bodyRows(this.termRows);
|
|
155
152
|
}
|
|
156
153
|
|
|
157
154
|
handleInput(data: string): void {
|
|
158
155
|
// 重命名输入中:Enter 提交,Esc / Ctrl+C 取消,其他键交给输入框
|
|
159
|
-
if (this.
|
|
156
|
+
if (this.mode === "rename") {
|
|
160
157
|
if (matchesKey(data, Key.enter)) {
|
|
161
158
|
const s = this.filtered[this.selected];
|
|
162
159
|
const name = this.renameInput.getValue().trim();
|
|
163
|
-
this.
|
|
160
|
+
this.mode = "search";
|
|
164
161
|
this.focused = this._focused;
|
|
165
162
|
this.invalidate();
|
|
166
163
|
if (s && name && this.onRename && !this.busy) {
|
|
@@ -183,7 +180,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
183
180
|
return;
|
|
184
181
|
}
|
|
185
182
|
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
186
|
-
this.
|
|
183
|
+
this.mode = "search";
|
|
187
184
|
this.focused = this._focused;
|
|
188
185
|
this.invalidate();
|
|
189
186
|
return;
|
|
@@ -193,8 +190,8 @@ export class PeekComponent implements Component, Focusable {
|
|
|
193
190
|
return;
|
|
194
191
|
}
|
|
195
192
|
// 删除确认中:y / Enter 执行,其他任何键取消
|
|
196
|
-
if (this.
|
|
197
|
-
this.
|
|
193
|
+
if (this.mode === "confirmDelete") {
|
|
194
|
+
this.mode = "search";
|
|
198
195
|
this.invalidate();
|
|
199
196
|
if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
|
|
200
197
|
const s = this.filtered[this.selected];
|
|
@@ -208,8 +205,9 @@ export class PeekComponent implements Component, Focusable {
|
|
|
208
205
|
if (i >= 0) this.filtered.splice(i, 1);
|
|
209
206
|
// 选中位置留在原地
|
|
210
207
|
this.selected = Math.min(this.selected, Math.max(0, this.filtered.length - 1));
|
|
208
|
+
this.followSel = true;
|
|
211
209
|
this.previewKey = "";
|
|
212
|
-
this.
|
|
210
|
+
this.drag.clear();
|
|
213
211
|
}
|
|
214
212
|
}).catch(() => {
|
|
215
213
|
}).finally(() => {
|
|
@@ -223,7 +221,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
223
221
|
}
|
|
224
222
|
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
225
223
|
// 有拖选的选区时 Ctrl+C 是复制,和终端里的习惯一样;没有才是关闭
|
|
226
|
-
if (matchesKey(data, Key.ctrl("c")) && this.
|
|
224
|
+
if (matchesKey(data, Key.ctrl("c")) && this.drag.active()) {
|
|
227
225
|
this.copySelection();
|
|
228
226
|
return;
|
|
229
227
|
}
|
|
@@ -232,7 +230,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
232
230
|
}
|
|
233
231
|
if (matchesKey(data, Key.ctrl("d"))) {
|
|
234
232
|
if (this.filtered.length && this.onDelete && !this.busy) {
|
|
235
|
-
this.
|
|
233
|
+
this.mode = "confirmDelete";
|
|
236
234
|
this.invalidate();
|
|
237
235
|
}
|
|
238
236
|
return;
|
|
@@ -240,7 +238,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
240
238
|
if (matchesKey(data, Key.ctrl("r"))) {
|
|
241
239
|
const s = this.filtered[this.selected];
|
|
242
240
|
if (s && this.onRename && !this.busy) {
|
|
243
|
-
this.
|
|
241
|
+
this.mode = "rename";
|
|
244
242
|
this.renameInput.setValue(s.name || "");
|
|
245
243
|
this.focused = this._focused;
|
|
246
244
|
this.invalidate();
|
|
@@ -292,7 +290,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
292
290
|
if (matchesKey(data, Key.ctrl("t"))) {
|
|
293
291
|
this.showTools = !this.showTools;
|
|
294
292
|
this.previewKey = "";
|
|
295
|
-
this.
|
|
293
|
+
this.drag.clear();
|
|
296
294
|
this.invalidate();
|
|
297
295
|
return;
|
|
298
296
|
}
|
|
@@ -324,7 +322,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
324
322
|
// 硬件光标在组件内的行号:搜索框在第 1 行,改名时在最后一行。常规模式的鼠标桥接靠它算组件顶行
|
|
325
323
|
cursorRow(): number | undefined {
|
|
326
324
|
if (!this._focused || !this.layout.H) return undefined;
|
|
327
|
-
return this.
|
|
325
|
+
return this.mode === "rename" ? BODY_TOP + this.layout.H + 1 : 1;
|
|
328
326
|
}
|
|
329
327
|
|
|
330
328
|
// 鼠标:左栏按下选中、双击进入、滚轮换选中项;右栏滚轮滚预览;点头部"范围"切换;点输入框移光标;
|
|
@@ -341,16 +339,15 @@ export class PeekComponent implements Component, Focusable {
|
|
|
341
339
|
if (ev.type === "wheel") {
|
|
342
340
|
const delta = ev.wheelDelta ?? 0;
|
|
343
341
|
if (!delta) return undefined;
|
|
344
|
-
|
|
345
|
-
|
|
342
|
+
// 和按键一样,滚一下就算取消确认。只退出确认,改名中滚轮不退出改名
|
|
343
|
+
let changed = this.cancelConfirm();
|
|
346
344
|
if (inList) {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
}
|
|
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;
|
|
354
351
|
}
|
|
355
352
|
} else {
|
|
356
353
|
const maxOff = Math.max(0, this.previewLines.length - H);
|
|
@@ -364,38 +361,34 @@ export class PeekComponent implements Component, Focusable {
|
|
|
364
361
|
return { handled: true, render: changed };
|
|
365
362
|
}
|
|
366
363
|
if (ev.type === "drag") {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
//
|
|
372
|
-
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);
|
|
373
370
|
if (changed) this.invalidate();
|
|
374
371
|
return { handled: true, render: changed };
|
|
375
372
|
}
|
|
376
373
|
if (ev.button !== "left") return undefined;
|
|
377
374
|
if (ev.type === "release") {
|
|
378
|
-
if (!this.
|
|
379
|
-
this.dragging = false;
|
|
380
|
-
this.stopAutoScroll();
|
|
381
|
-
if (!this.selMoved) this.sel = undefined; // 没拖过就是单击
|
|
375
|
+
if (!this.drag.release()) return undefined;
|
|
382
376
|
return { handled: true, render: false };
|
|
383
377
|
}
|
|
384
378
|
if (ev.type === "click") {
|
|
385
379
|
// 单击在 press 里已经选中了,双击才进入
|
|
386
380
|
const s = this.filtered[itemAt()];
|
|
387
381
|
if (!s) return undefined;
|
|
388
|
-
if (
|
|
382
|
+
if (this.mode !== "rename" && !this.busy && (ev.clickCount ?? 1) >= 2) this.onResume?.(s);
|
|
389
383
|
return { handled: true, render: false };
|
|
390
384
|
}
|
|
391
385
|
if (ev.type !== "press") return undefined;
|
|
392
386
|
|
|
393
387
|
// 删除确认和已有的选区:点一下就没了,和按键一样
|
|
394
|
-
let changed = this.
|
|
395
|
-
this.
|
|
396
|
-
this.clearSelection();
|
|
388
|
+
let changed = this.cancelConfirm() || this.drag.active() !== undefined;
|
|
389
|
+
this.drag.clear();
|
|
397
390
|
let handled = changed;
|
|
398
|
-
if (this.
|
|
391
|
+
if (this.mode === "rename") {
|
|
399
392
|
if (ev.y === BODY_TOP + H + 1) {
|
|
400
393
|
// 改名行:点哪里光标就去哪里
|
|
401
394
|
this.renameInput.handleMouse({ ...ev, x: ev.x - visibleWidth(msg("renamePrefix")), y: 0 });
|
|
@@ -419,11 +412,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
419
412
|
}
|
|
420
413
|
if (inBody) {
|
|
421
414
|
// 两栏里按下都可能是拖选的起点,拖起来才算数;接管后面的拖动和松开
|
|
422
|
-
|
|
423
|
-
const cell = this.cellAt(pane, ev.x, bodyRow);
|
|
424
|
-
this.sel = { pane, anchor: cell, focus: { ...cell } };
|
|
425
|
-
this.dragging = true;
|
|
426
|
-
this.dragX = ev.x;
|
|
415
|
+
this.drag.begin(inList ? "list" : "preview", ev.x, bodyRow);
|
|
427
416
|
handled = true;
|
|
428
417
|
}
|
|
429
418
|
if (!handled) return undefined;
|
|
@@ -431,55 +420,13 @@ export class PeekComponent implements Component, Focusable {
|
|
|
431
420
|
return inBody ? { handled: true, capture: true, render: changed } : { handled: true, render: changed };
|
|
432
421
|
}
|
|
433
422
|
|
|
434
|
-
//
|
|
435
|
-
private
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
if (pane === "list") return { row: this.listOffset * 2 + row, col: Math.max(0, Math.min(lw - 1, x)) };
|
|
439
|
-
return { row: this.previewOffset + row, col: Math.max(0, Math.min(rw - 1, x - lw - SEP)) };
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// 拖到某个位置:焦点跟过去。返回有没有变
|
|
443
|
-
private dragTo(x: number, y: number): boolean {
|
|
444
|
-
if (!this.sel) return false;
|
|
445
|
-
const cell = this.cellAt(this.sel.pane, x, y - BODY_TOP);
|
|
446
|
-
if (cell.row === this.sel.focus.row && cell.col === this.sel.focus.col) return false;
|
|
447
|
-
this.sel.focus = cell;
|
|
423
|
+
// 鼠标滚轮 / 按下时退出删除确认(只退出确认,改名状态不动)。返回有没有退出
|
|
424
|
+
private cancelConfirm(): boolean {
|
|
425
|
+
if (this.mode !== "confirmDelete") return false;
|
|
426
|
+
this.mode = "search";
|
|
448
427
|
return true;
|
|
449
428
|
}
|
|
450
429
|
|
|
451
|
-
private setAutoScroll(dir: number): void {
|
|
452
|
-
this.autoScrollDir = dir;
|
|
453
|
-
if (!dir) {
|
|
454
|
-
this.stopAutoScroll();
|
|
455
|
-
return;
|
|
456
|
-
}
|
|
457
|
-
if (this.autoScrollTimer) return;
|
|
458
|
-
this.autoScrollTimer = setInterval(() => this.autoScrollTick(), AUTO_SCROLL_MS);
|
|
459
|
-
this.autoScrollTimer.unref?.();
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// 每次滚一行,焦点跟着指针:指针在上边就是视口第一行,在下边就是最后一行
|
|
463
|
-
private autoScrollTick(): void {
|
|
464
|
-
const { H } = this.layout;
|
|
465
|
-
const maxOff = Math.max(0, this.previewLines.length - H);
|
|
466
|
-
const next = Math.max(0, Math.min(maxOff, this.previewOffset + this.autoScrollDir));
|
|
467
|
-
if (!this.sel || !this.dragging || next === this.previewOffset) {
|
|
468
|
-
this.stopAutoScroll();
|
|
469
|
-
return;
|
|
470
|
-
}
|
|
471
|
-
this.previewOffset = next;
|
|
472
|
-
if (this.dragTo(this.dragX, this.autoScrollDir < 0 ? BODY_TOP - 1 : BODY_TOP + H)) this.selMoved = true;
|
|
473
|
-
this.invalidate();
|
|
474
|
-
this.requestRender?.();
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
private stopAutoScroll(): void {
|
|
478
|
-
if (this.autoScrollTimer) clearInterval(this.autoScrollTimer);
|
|
479
|
-
this.autoScrollTimer = undefined;
|
|
480
|
-
this.autoScrollDir = 0;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
430
|
// 选区里的纯文本
|
|
484
431
|
private selectionToText(sel: Selection): string {
|
|
485
432
|
const { lw, rw } = this.layout;
|
|
@@ -487,17 +434,17 @@ export class PeekComponent implements Component, Focusable {
|
|
|
487
434
|
const { rt, hasBody } = this.listTerms();
|
|
488
435
|
return selectionText(sel, lw, (row) => {
|
|
489
436
|
const s = this.filtered[row >> 1];
|
|
490
|
-
return s &&
|
|
437
|
+
return s && listRows(s, (row >> 1) === this.selected, lw, rt, hasBody, this.theme)[row & 1];
|
|
491
438
|
});
|
|
492
439
|
}
|
|
493
440
|
|
|
494
441
|
// Ctrl+C:复制选区,清掉高亮,底部闪一下结果
|
|
495
442
|
private copySelection(): void {
|
|
496
|
-
const sel = this.
|
|
443
|
+
const sel = this.drag.active();
|
|
497
444
|
if (!sel) return;
|
|
498
445
|
const text = this.selectionToText(sel);
|
|
499
446
|
const n = text.split("\n").length;
|
|
500
|
-
this.
|
|
447
|
+
this.drag.clear();
|
|
501
448
|
this.invalidate();
|
|
502
449
|
if (!text.trim() || !this.onCopy) return;
|
|
503
450
|
void this.onCopy(text).then((ok) => this.showHint(ok ? msg("copied", { n }) : msg("copyFailed"), ok));
|
|
@@ -517,19 +464,12 @@ export class PeekComponent implements Component, Focusable {
|
|
|
517
464
|
this.requestRender?.();
|
|
518
465
|
}
|
|
519
466
|
|
|
520
|
-
// 预览里跳到当前视口之外的下一个 /
|
|
521
|
-
// 否则一段里连着几行都命中要按好几次才过得去
|
|
467
|
+
// 预览里跳到当前视口之外的下一个 / 上一个命中行,到头了回绕
|
|
522
468
|
private jumpMatch(dir: 1 | -1): void {
|
|
523
|
-
if (!this.matchLines.length) return;
|
|
524
469
|
const H = this.bodyHeight();
|
|
525
470
|
const top = this.previewOffset;
|
|
526
471
|
const bottom = Math.min(top + H, this.previewLines.length) - 1;
|
|
527
|
-
|
|
528
|
-
if (dir === 1) {
|
|
529
|
-
target = this.matchLines.find((l) => l > bottom) ?? this.matchLines[0];
|
|
530
|
-
} else {
|
|
531
|
-
target = [...this.matchLines].reverse().find((l) => l < top) ?? this.matchLines[this.matchLines.length - 1];
|
|
532
|
-
}
|
|
472
|
+
const target = nextMatchTarget(this.matchLines, top, bottom, dir);
|
|
533
473
|
if (target !== undefined) {
|
|
534
474
|
this.previewOffset = Math.max(0, target - 2);
|
|
535
475
|
this.invalidate();
|
|
@@ -542,168 +482,16 @@ export class PeekComponent implements Component, Focusable {
|
|
|
542
482
|
return { rt, hasBody: rt.user.length > 0 || rt.assistant.length > 0 };
|
|
543
483
|
}
|
|
544
484
|
|
|
545
|
-
//
|
|
546
|
-
private
|
|
547
|
-
const t = this.theme;
|
|
548
|
-
const time = fmtTime(s.time);
|
|
549
|
-
const cwdTail = s.cwd.replace(/\\/g, "/").split("/").slice(-2).join("/");
|
|
550
|
-
const hitBadge = hasBody
|
|
551
|
-
? ` ·${s.msgs.reduce((n, m) => n + (anyMatch(m.text, rt[m.role]) ? 1 : 0), 0)}`
|
|
552
|
-
: "";
|
|
553
|
-
const l1 = `${sel ? "›" : " "} ${time} ${cwdTail}${hitBadge}`;
|
|
554
|
-
const snip = hasBody ? snippet(s.msgs, rt, lw) : undefined;
|
|
555
|
-
const l2 = snip !== undefined
|
|
556
|
-
? ` ${highlight(snip.text, rt[snip.role], t)}`
|
|
557
|
-
: ` ${s.first}${s.name ? ` [${s.name}]` : ""}`;
|
|
558
|
-
if (sel) {
|
|
559
|
-
return [
|
|
560
|
-
t.bg("selectedBg", padEndVisible(t.fg("accent", truncateToWidth(l1, lw)), lw)),
|
|
561
|
-
t.bg("selectedBg", padEndVisible(truncateToWidth(l2, lw), lw)),
|
|
562
|
-
];
|
|
563
|
-
}
|
|
564
|
-
return [padEndVisible(l1, lw), padEndVisible(t.fg("dim", truncateToWidth(l2, lw)), lw)];
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// 左栏,每个会话两行
|
|
568
|
-
private buildList(height: number, lw: number): string[] {
|
|
569
|
-
const { rt, hasBody } = this.listTerms();
|
|
570
|
-
const visible = Math.max(1, Math.floor(height / 2));
|
|
571
|
-
if (this.selected < this.listOffset) this.listOffset = this.selected;
|
|
572
|
-
if (this.selected >= this.listOffset + visible) {
|
|
573
|
-
this.listOffset = this.selected - visible + 1;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
const rows: string[] = [];
|
|
577
|
-
for (let i = 0; i < visible; i++) {
|
|
578
|
-
const idx = this.listOffset + i;
|
|
579
|
-
const s = this.filtered[idx];
|
|
580
|
-
if (!s) {
|
|
581
|
-
rows.push(" ".repeat(lw), " ".repeat(lw));
|
|
582
|
-
continue;
|
|
583
|
-
}
|
|
584
|
-
rows.push(...this.listRows(s, idx === this.selected, lw, rt, hasBody));
|
|
585
|
-
}
|
|
586
|
-
// 每项两行,height 是奇数时会少一行,补空行,否则最后一行右栏会顶到左边
|
|
587
|
-
while (rows.length < height) rows.push(" ".repeat(lw));
|
|
588
|
-
return rows.slice(0, height);
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
// 用 pi 自己的 Markdown 组件渲染一条消息,和主界面里的对话长得一样;参数照抄 pi 的
|
|
592
|
-
// user-message / assistant-message 组件。渲染比较贵,按消息和宽度缓存,换关键词时不用重来
|
|
593
|
-
private renderMsg(m: PeekMsg, rw: number): string[] {
|
|
594
|
-
const hit = this.rendered.get(m);
|
|
595
|
-
if (hit && hit.w === rw) return hit.lines;
|
|
596
|
-
const t = this.theme;
|
|
597
|
-
const md =
|
|
598
|
-
m.role === "user"
|
|
599
|
-
? new Markdown(
|
|
600
|
-
m.text,
|
|
601
|
-
1,
|
|
602
|
-
0,
|
|
603
|
-
this.mdTheme,
|
|
604
|
-
{ color: (s) => t.fg("userMessageText", s), bgColor: (s) => t.bg("userMessageBg", s) },
|
|
605
|
-
{ preserveOrderedListMarkers: true, preserveBackslashEscapes: true },
|
|
606
|
-
)
|
|
607
|
-
: new Markdown(m.text, 1, 0, this.mdTheme);
|
|
608
|
-
const lines = md.render(rw);
|
|
609
|
-
this.rendered.set(m, { w: rw, lines });
|
|
610
|
-
return lines;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
// 右栏。有关键词时滚到第一个命中处,没有时滚到底部看最新消息
|
|
614
|
-
private buildPreview(rw: number): void {
|
|
615
|
-
const t = this.theme;
|
|
485
|
+
// 右栏。预览键(会话 + 关键词 + 宽度 + 工具开关)没变就复用;变了重建,滚动位置放到首个命中处或底部
|
|
486
|
+
private rebuildPreview(rw: number, rt: RoleTerms): void {
|
|
616
487
|
const s = this.filtered[this.selected];
|
|
617
488
|
const key = s ? `${s.path}|${this.getQuery()}|${rw}|${this.showTools ? "t" : ""}` : "none";
|
|
618
489
|
if (key === this.previewKey) return;
|
|
619
490
|
this.previewKey = key;
|
|
620
|
-
this.
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
this.previewOffset = 0;
|
|
625
|
-
return;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
const lines: string[] = [];
|
|
629
|
-
// 消息数只算有文字的,纯工具轮次不计
|
|
630
|
-
const textCount = s.msgs.reduce((n, m) => n + (m.text ? 1 : 0), 0);
|
|
631
|
-
lines.push(
|
|
632
|
-
truncateToWidth(
|
|
633
|
-
t.fg("dim", `${s.cwd} • ${msg("msgCount", { n: textCount })}${s.name ? " • " + s.name : ""}`),
|
|
634
|
-
rw,
|
|
635
|
-
),
|
|
636
|
-
);
|
|
637
|
-
lines.push("");
|
|
638
|
-
|
|
639
|
-
const rt = roleTerms(this.query().terms);
|
|
640
|
-
const hasBody = rt.user.length > 0 || rt.assistant.length > 0;
|
|
641
|
-
const MAX_MSGS = 500;
|
|
642
|
-
const truncated = s.msgs.length > MAX_MSGS;
|
|
643
|
-
const msgs = truncated ? s.msgs.slice(-MAX_MSGS) : s.msgs;
|
|
644
|
-
if (truncated) {
|
|
645
|
-
lines.push(t.fg("dim", msg("truncated", { n: MAX_MSGS })));
|
|
646
|
-
lines.push("");
|
|
647
|
-
}
|
|
648
|
-
if (hasBody && !msgs.some((m) => anyMatch(m.text, rt[m.role]))) {
|
|
649
|
-
const inTruncated = truncated && s.msgs.some((m) => anyMatch(m.text, rt[m.role]));
|
|
650
|
-
lines.push(
|
|
651
|
-
t.fg(
|
|
652
|
-
"warning",
|
|
653
|
-
inTruncated ? msg("hitInTruncated") : msg("hitOnlyMeta"),
|
|
654
|
-
),
|
|
655
|
-
);
|
|
656
|
-
lines.push("");
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
// 用户消息整块背景色,AI 消息只有一条细线标签
|
|
660
|
-
const fillLabel = (label: string): string => {
|
|
661
|
-
const w = visibleWidth(label);
|
|
662
|
-
return truncateToWidth(label + " " + "─".repeat(Math.max(2, rw - w - 1)), rw);
|
|
663
|
-
};
|
|
664
|
-
|
|
665
|
-
let prevRole: PeekMsg["role"] | undefined; // 上一条画出来的消息的角色
|
|
666
|
-
for (const m of msgs) {
|
|
667
|
-
const tools = this.showTools ? (m.tools ?? []) : [];
|
|
668
|
-
if (!m.text && !tools.length) continue; // 工具隐藏时,只有工具调用的 AI 轮次整条跳过
|
|
669
|
-
const terms = rt[m.role]; // user: 的词只在用户消息里高亮,ai: 的只在 AI 回复里
|
|
670
|
-
const isMatch = terms.length > 0 && anyMatch(m.text, terms);
|
|
671
|
-
// 紧跟在 AI 文字后面、只有工具调用的轮次不重复画标签,接在上一条正文下面,看起来是同一轮
|
|
672
|
-
const continues = m.role === "assistant" && prevRole === "assistant" && !m.text;
|
|
673
|
-
if (continues) {
|
|
674
|
-
lines.pop();
|
|
675
|
-
} else if (m.role === "user") {
|
|
676
|
-
lines.push(
|
|
677
|
-
t.bg("userMessageBg", padEndVisible(t.bold(t.fg("accent", fillLabel(msg("you")))), rw)),
|
|
678
|
-
);
|
|
679
|
-
} else {
|
|
680
|
-
lines.push(t.fg("muted", fillLabel(msg("ai"))));
|
|
681
|
-
}
|
|
682
|
-
const head = lines.length - 1;
|
|
683
|
-
// 先渲染再高亮:往 markdown 源码里插转义码会把链接、代码块的语法弄坏
|
|
684
|
-
const before = this.matchLines.length;
|
|
685
|
-
if (m.text) {
|
|
686
|
-
for (const l of this.renderMsg(m, rw)) {
|
|
687
|
-
if (isMatch && lineHasMatch(l, terms)) this.matchLines.push(lines.length);
|
|
688
|
-
lines.push(isMatch ? highlight(l, terms, t) : l);
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
// 关键词被折行拆开时哪一行都找不到,退回到消息标题行,别把这条消息漏掉
|
|
692
|
-
if (isMatch && this.matchLines.length === before) this.matchLines.push(head);
|
|
693
|
-
for (const tool of tools) {
|
|
694
|
-
lines.push(t.fg("dim", truncateToWidth(` ⚙ ${tool.name}${tool.summary ? " " + tool.summary : ""}`, rw)));
|
|
695
|
-
}
|
|
696
|
-
lines.push("");
|
|
697
|
-
prevRole = m.role;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
this.previewLines = lines;
|
|
701
|
-
|
|
702
|
-
if (this.matchLines.length) {
|
|
703
|
-
this.previewOffset = Math.max(0, this.matchLines[0] - 2);
|
|
704
|
-
} else {
|
|
705
|
-
this.previewOffset = Math.max(0, lines.length - this.bodyHeight());
|
|
706
|
-
}
|
|
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());
|
|
707
495
|
}
|
|
708
496
|
|
|
709
497
|
render(width: number): string[] {
|
|
@@ -715,8 +503,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
715
503
|
return this.cachedLines;
|
|
716
504
|
}
|
|
717
505
|
const t = this.theme;
|
|
718
|
-
const lw
|
|
719
|
-
const rw = Math.max(20, width - lw - 3);
|
|
506
|
+
const { lw, rw } = paneWidths(width);
|
|
720
507
|
this.layout = { lw, rw, H };
|
|
721
508
|
|
|
722
509
|
const out: string[] = [];
|
|
@@ -738,8 +525,16 @@ export class PeekComponent implements Component, Focusable {
|
|
|
738
525
|
out.push(t.fg("borderMuted", "─".repeat(width)));
|
|
739
526
|
|
|
740
527
|
// 双栏
|
|
741
|
-
this.
|
|
742
|
-
|
|
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);
|
|
743
538
|
|
|
744
539
|
const maxOff = Math.max(0, this.previewLines.length - H);
|
|
745
540
|
if (this.previewOffset > maxOff) this.previewOffset = maxOff;
|
|
@@ -754,7 +549,7 @@ export class PeekComponent implements Component, Focusable {
|
|
|
754
549
|
if (this.previewLines.length > H) info += ` (${this.previewOffset + 1}-${end}/${this.previewLines.length})`;
|
|
755
550
|
const scrollInfo = info ? t.fg("dim", info) : "";
|
|
756
551
|
|
|
757
|
-
const sel = this.
|
|
552
|
+
const sel = this.drag.active();
|
|
758
553
|
for (let i = 0; i < H; i++) {
|
|
759
554
|
let l = leftRows[i] ?? " ".repeat(lw);
|
|
760
555
|
let r = rightRows[i] ?? "";
|
|
@@ -775,11 +570,11 @@ export class PeekComponent implements Component, Focusable {
|
|
|
775
570
|
|
|
776
571
|
// 底部:重命名和删除确认时换成对应的操作行,复制结果和选区提示也在这里
|
|
777
572
|
out.push(t.fg("borderMuted", "─".repeat(width)));
|
|
778
|
-
if (this.
|
|
573
|
+
if (this.mode === "rename") {
|
|
779
574
|
const prefix = msg("renamePrefix");
|
|
780
575
|
const inputLine = this.renameInput.render(Math.max(10, width - visibleWidth(prefix)))[0] ?? "";
|
|
781
576
|
out.push(truncateToWidth(t.fg("warning", prefix) + inputLine, width));
|
|
782
|
-
} else if (this.
|
|
577
|
+
} else if (this.mode === "confirmDelete") {
|
|
783
578
|
const s = this.filtered[this.selected];
|
|
784
579
|
const desc = s ? `${fmtTime(s.time, true)} ${s.first.slice(0, 30)}` : "";
|
|
785
580
|
out.push(
|