pi-session-peek 0.2.1 → 0.3.1
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 +9 -0
- package/LICENSE +21 -21
- package/README.md +17 -1
- package/README.zh-CN.md +17 -1
- package/index.ts +8 -4
- package/package.json +1 -1
- package/src/i18n.ts +4 -2
- package/src/peek-component.ts +502 -500
- package/src/query.ts +11 -1
- package/src/sessions.ts +77 -45
package/src/peek-component.ts
CHANGED
|
@@ -1,500 +1,502 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Input,
|
|
3
|
-
Key,
|
|
4
|
-
Markdown,
|
|
5
|
-
matchesKey,
|
|
6
|
-
truncateToWidth,
|
|
7
|
-
visibleWidth,
|
|
8
|
-
type Component,
|
|
9
|
-
type Focusable,
|
|
10
|
-
type MarkdownTheme,
|
|
11
|
-
} from "@earendil-works/pi-tui";
|
|
12
|
-
import { msg } from "./i18n.ts";
|
|
13
|
-
import { anyKw, highlight, lineHasKw, parseQuery, snippet, type ParsedQuery } from "./query.ts";
|
|
14
|
-
import type { PeekMsg, PeekSession } from "./sessions.ts";
|
|
15
|
-
import { fmtTime, normPath, padEndVisible } from "./text.ts";
|
|
16
|
-
|
|
17
|
-
// 双栏选择器。文件操作(删除 / 重命名 / 分叉)不在这里做,由 index.ts 通过回调注入
|
|
18
|
-
|
|
19
|
-
export class PeekComponent implements Component, Focusable {
|
|
20
|
-
private input: Input;
|
|
21
|
-
private scope: "current" | "all";
|
|
22
|
-
private filtered: PeekSession[] = [];
|
|
23
|
-
private selected = 0;
|
|
24
|
-
private listOffset = 0;
|
|
25
|
-
private previewOffset = 0;
|
|
26
|
-
private previewKey = ""; // 预览缓存的键(会话 + 关键词 + 宽度),置空强制重建
|
|
27
|
-
private previewLines: string[] = [];
|
|
28
|
-
private matchLines: number[] = []; // 预览里含关键词的行(升序),Ctrl+N / Ctrl+P 用
|
|
29
|
-
private rendered = new WeakMap<PeekMsg, { w: number; lines: string[] }>(); // 每条消息渲染好的行,按宽度缓存
|
|
30
|
-
private confirmingDelete = false; // Ctrl+D 之后等待确认
|
|
31
|
-
private renaming = false; // Ctrl+R 之后正在输入名字
|
|
32
|
-
private renameInput: Input;
|
|
33
|
-
private cachedWidth = -1;
|
|
34
|
-
private cachedLines: string[] = [];
|
|
35
|
-
private _focused = false;
|
|
36
|
-
|
|
37
|
-
// 由 index.ts 注入
|
|
38
|
-
onResume?: (s: PeekSession) => void;
|
|
39
|
-
onFork?: (s: PeekSession) => void;
|
|
40
|
-
onCancel?: () => void;
|
|
41
|
-
onDelete?: (s: PeekSession) => Promise<boolean>;
|
|
42
|
-
onRename?: (s: PeekSession, name: string) => Promise<boolean>;
|
|
43
|
-
requestRender?: () => void; // 异步操作完成后让 TUI 重画
|
|
44
|
-
|
|
45
|
-
constructor(
|
|
46
|
-
private all: PeekSession[],
|
|
47
|
-
private currentCwd: string,
|
|
48
|
-
private theme: any,
|
|
49
|
-
private mdTheme: MarkdownTheme,
|
|
50
|
-
private termRows: number,
|
|
51
|
-
initialQuery: string,
|
|
52
|
-
) {
|
|
53
|
-
this.input = new Input({ placeholder: msg("searchPlaceholder") });
|
|
54
|
-
this.renameInput = new Input({ placeholder: msg("renamePlaceholder") });
|
|
55
|
-
if (initialQuery) this.input.setValue(initialQuery);
|
|
56
|
-
// 当前目录树下有会话就默认当前目录,否则全局
|
|
57
|
-
this.scope = all.some((s) => this.inCurrentTree(s)) ? "current" : "all";
|
|
58
|
-
this.refilter();
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// 焦点要落到真正在用的那个输入框上,否则 IME 候选框位置不对
|
|
62
|
-
get focused(): boolean {
|
|
63
|
-
return this._focused;
|
|
64
|
-
}
|
|
65
|
-
set focused(v: boolean) {
|
|
66
|
-
this._focused = v;
|
|
67
|
-
this.input.focused = v && !this.renaming;
|
|
68
|
-
this.renameInput.focused = v && this.renaming;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
getQuery(): string {
|
|
72
|
-
return this.input.getValue().trim();
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
private query(): ParsedQuery {
|
|
76
|
-
return parseQuery(this.getQuery());
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// 等于当前目录,或在它的子目录下
|
|
80
|
-
private inCurrentTree(s: PeekSession): boolean {
|
|
81
|
-
const cur = normPath(this.currentCwd);
|
|
82
|
-
const c = normPath(s.cwd);
|
|
83
|
-
return c === cur || c.startsWith(cur + "/");
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// keepSelection:尽量保持原来选中的会话,重命名后用
|
|
87
|
-
private refilter(keepSelection = false): void {
|
|
88
|
-
const prev = keepSelection ? this.filtered[this.selected] : undefined;
|
|
89
|
-
const { kws, since } = this.query();
|
|
90
|
-
let out = this.all;
|
|
91
|
-
if (this.scope === "current") out = out.filter((s) => this.inCurrentTree(s));
|
|
92
|
-
if (since) out = out.filter((s) => s.mtime >= since);
|
|
93
|
-
if (kws.length)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
this.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
this.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
i = this.
|
|
151
|
-
if (i >= 0) this.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
this.
|
|
180
|
-
this.
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
this.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
this.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
this.
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
this.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
this.
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
this.
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
target =
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
rows.push(padEndVisible(
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
this.
|
|
345
|
-
this.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
this.previewOffset = Math.max(0,
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
t.fg("
|
|
434
|
-
|
|
435
|
-
t.fg("
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
out.push(
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
1
|
+
import {
|
|
2
|
+
Input,
|
|
3
|
+
Key,
|
|
4
|
+
Markdown,
|
|
5
|
+
matchesKey,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
visibleWidth,
|
|
8
|
+
type Component,
|
|
9
|
+
type Focusable,
|
|
10
|
+
type MarkdownTheme,
|
|
11
|
+
} from "@earendil-works/pi-tui";
|
|
12
|
+
import { msg } from "./i18n.ts";
|
|
13
|
+
import { anyKw, highlight, kwRegExps, lineHasKw, matchesSession, parseQuery, snippet, type ParsedQuery } from "./query.ts";
|
|
14
|
+
import type { PeekMsg, PeekSession } from "./sessions.ts";
|
|
15
|
+
import { fmtTime, normPath, padEndVisible } from "./text.ts";
|
|
16
|
+
|
|
17
|
+
// 双栏选择器。文件操作(删除 / 重命名 / 分叉)不在这里做,由 index.ts 通过回调注入
|
|
18
|
+
|
|
19
|
+
export class PeekComponent implements Component, Focusable {
|
|
20
|
+
private input: Input;
|
|
21
|
+
private scope: "current" | "all";
|
|
22
|
+
private filtered: PeekSession[] = [];
|
|
23
|
+
private selected = 0;
|
|
24
|
+
private listOffset = 0;
|
|
25
|
+
private previewOffset = 0;
|
|
26
|
+
private previewKey = ""; // 预览缓存的键(会话 + 关键词 + 宽度),置空强制重建
|
|
27
|
+
private previewLines: string[] = [];
|
|
28
|
+
private matchLines: number[] = []; // 预览里含关键词的行(升序),Ctrl+N / Ctrl+P 用
|
|
29
|
+
private rendered = new WeakMap<PeekMsg, { w: number; lines: string[] }>(); // 每条消息渲染好的行,按宽度缓存
|
|
30
|
+
private confirmingDelete = false; // Ctrl+D 之后等待确认
|
|
31
|
+
private renaming = false; // Ctrl+R 之后正在输入名字
|
|
32
|
+
private renameInput: Input;
|
|
33
|
+
private cachedWidth = -1;
|
|
34
|
+
private cachedLines: string[] = [];
|
|
35
|
+
private _focused = false;
|
|
36
|
+
|
|
37
|
+
// 由 index.ts 注入
|
|
38
|
+
onResume?: (s: PeekSession) => void;
|
|
39
|
+
onFork?: (s: PeekSession) => void;
|
|
40
|
+
onCancel?: () => void;
|
|
41
|
+
onDelete?: (s: PeekSession) => Promise<boolean>;
|
|
42
|
+
onRename?: (s: PeekSession, name: string) => Promise<boolean>;
|
|
43
|
+
requestRender?: () => void; // 异步操作完成后让 TUI 重画
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
private all: PeekSession[],
|
|
47
|
+
private currentCwd: string,
|
|
48
|
+
private theme: any,
|
|
49
|
+
private mdTheme: MarkdownTheme,
|
|
50
|
+
private termRows: number,
|
|
51
|
+
initialQuery: string,
|
|
52
|
+
) {
|
|
53
|
+
this.input = new Input({ placeholder: msg("searchPlaceholder") });
|
|
54
|
+
this.renameInput = new Input({ placeholder: msg("renamePlaceholder") });
|
|
55
|
+
if (initialQuery) this.input.setValue(initialQuery);
|
|
56
|
+
// 当前目录树下有会话就默认当前目录,否则全局
|
|
57
|
+
this.scope = all.some((s) => this.inCurrentTree(s)) ? "current" : "all";
|
|
58
|
+
this.refilter();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 焦点要落到真正在用的那个输入框上,否则 IME 候选框位置不对
|
|
62
|
+
get focused(): boolean {
|
|
63
|
+
return this._focused;
|
|
64
|
+
}
|
|
65
|
+
set focused(v: boolean) {
|
|
66
|
+
this._focused = v;
|
|
67
|
+
this.input.focused = v && !this.renaming;
|
|
68
|
+
this.renameInput.focused = v && this.renaming;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getQuery(): string {
|
|
72
|
+
return this.input.getValue().trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private query(): ParsedQuery {
|
|
76
|
+
return parseQuery(this.getQuery());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 等于当前目录,或在它的子目录下
|
|
80
|
+
private inCurrentTree(s: PeekSession): boolean {
|
|
81
|
+
const cur = normPath(this.currentCwd);
|
|
82
|
+
const c = normPath(s.cwd);
|
|
83
|
+
return c === cur || c.startsWith(cur + "/");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// keepSelection:尽量保持原来选中的会话,重命名后用
|
|
87
|
+
private refilter(keepSelection = false): void {
|
|
88
|
+
const prev = keepSelection ? this.filtered[this.selected] : undefined;
|
|
89
|
+
const { kws, since } = this.query();
|
|
90
|
+
let out = this.all;
|
|
91
|
+
if (this.scope === "current") out = out.filter((s) => this.inCurrentTree(s));
|
|
92
|
+
if (since) out = out.filter((s) => s.mtime >= since);
|
|
93
|
+
if (kws.length) {
|
|
94
|
+
const res = kwRegExps(kws);
|
|
95
|
+
out = out.filter((s) => matchesSession(s, res));
|
|
96
|
+
}
|
|
97
|
+
this.filtered = out;
|
|
98
|
+
const idx = prev ? out.indexOf(prev) : -1;
|
|
99
|
+
this.selected = idx >= 0 ? idx : keepSelection ? Math.min(this.selected, Math.max(0, out.length - 1)) : 0;
|
|
100
|
+
if (!keepSelection) this.listOffset = 0;
|
|
101
|
+
this.previewKey = "";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private bodyHeight(): number {
|
|
105
|
+
const rows = process.stdout.rows || this.termRows || 24; // 每次都读,终端拉伸后布局跟着变
|
|
106
|
+
return Math.max(8, Math.min(rows - 12, 30));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
handleInput(data: string): void {
|
|
110
|
+
// 重命名输入中:Enter 提交,Esc / Ctrl+C 取消,其他键交给输入框
|
|
111
|
+
if (this.renaming) {
|
|
112
|
+
if (matchesKey(data, Key.enter)) {
|
|
113
|
+
const s = this.filtered[this.selected];
|
|
114
|
+
const name = this.renameInput.getValue().trim();
|
|
115
|
+
this.renaming = false;
|
|
116
|
+
this.focused = this._focused;
|
|
117
|
+
this.invalidate();
|
|
118
|
+
if (s && name && this.onRename) {
|
|
119
|
+
void this.onRename(s, name).then((ok) => {
|
|
120
|
+
if (ok) {
|
|
121
|
+
s.name = name;
|
|
122
|
+
// 名字参与搜索,改完可能就不再匹配当前关键词了
|
|
123
|
+
this.refilter(true);
|
|
124
|
+
}
|
|
125
|
+
this.invalidate();
|
|
126
|
+
this.requestRender?.();
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
132
|
+
this.renaming = false;
|
|
133
|
+
this.focused = this._focused;
|
|
134
|
+
this.invalidate();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.renameInput.handleInput(data);
|
|
138
|
+
this.invalidate();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// 删除确认中:y / Enter 执行,其他任何键取消
|
|
142
|
+
if (this.confirmingDelete) {
|
|
143
|
+
this.confirmingDelete = false;
|
|
144
|
+
this.invalidate();
|
|
145
|
+
if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
|
|
146
|
+
const s = this.filtered[this.selected];
|
|
147
|
+
if (s && this.onDelete) {
|
|
148
|
+
void this.onDelete(s).then((ok) => {
|
|
149
|
+
if (ok) {
|
|
150
|
+
let i = this.all.indexOf(s);
|
|
151
|
+
if (i >= 0) this.all.splice(i, 1);
|
|
152
|
+
i = this.filtered.indexOf(s);
|
|
153
|
+
if (i >= 0) this.filtered.splice(i, 1);
|
|
154
|
+
// 选中位置留在原地
|
|
155
|
+
this.selected = Math.min(this.selected, Math.max(0, this.filtered.length - 1));
|
|
156
|
+
this.previewKey = "";
|
|
157
|
+
}
|
|
158
|
+
this.invalidate();
|
|
159
|
+
this.requestRender?.();
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
166
|
+
this.onCancel?.();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (matchesKey(data, Key.ctrl("d"))) {
|
|
170
|
+
if (this.filtered.length && this.onDelete) {
|
|
171
|
+
this.confirmingDelete = true;
|
|
172
|
+
this.invalidate();
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (matchesKey(data, Key.ctrl("r"))) {
|
|
177
|
+
const s = this.filtered[this.selected];
|
|
178
|
+
if (s && this.onRename) {
|
|
179
|
+
this.renaming = true;
|
|
180
|
+
this.renameInput.setValue(s.name || "");
|
|
181
|
+
this.focused = this._focused;
|
|
182
|
+
this.invalidate();
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (matchesKey(data, Key.tab)) {
|
|
187
|
+
this.scope = this.scope === "all" ? "current" : "all";
|
|
188
|
+
this.refilter();
|
|
189
|
+
this.invalidate();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (matchesKey(data, Key.up)) {
|
|
193
|
+
if (this.selected > 0) {
|
|
194
|
+
this.selected--;
|
|
195
|
+
this.previewKey = "";
|
|
196
|
+
this.invalidate();
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (matchesKey(data, Key.down)) {
|
|
201
|
+
if (this.selected < this.filtered.length - 1) {
|
|
202
|
+
this.selected++;
|
|
203
|
+
this.previewKey = "";
|
|
204
|
+
this.invalidate();
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("u"))) {
|
|
209
|
+
const step = matchesKey(data, Key.pageUp) ? this.bodyHeight() : Math.max(1, this.bodyHeight() >> 1);
|
|
210
|
+
this.previewOffset = Math.max(0, this.previewOffset - step);
|
|
211
|
+
this.invalidate();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) {
|
|
215
|
+
const step = matchesKey(data, Key.pageDown) ? this.bodyHeight() : Math.max(1, this.bodyHeight() >> 1);
|
|
216
|
+
this.previewOffset += step;
|
|
217
|
+
this.invalidate();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (matchesKey(data, Key.shift("up"))) {
|
|
221
|
+
this.previewOffset = Math.max(0, this.previewOffset - 3);
|
|
222
|
+
this.invalidate();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (matchesKey(data, Key.shift("down"))) {
|
|
226
|
+
this.previewOffset += 3;
|
|
227
|
+
this.invalidate();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (matchesKey(data, Key.ctrl("n"))) {
|
|
231
|
+
this.jumpMatch(1);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (matchesKey(data, Key.ctrl("p"))) {
|
|
235
|
+
this.jumpMatch(-1);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (matchesKey(data, Key.enter)) {
|
|
239
|
+
const s = this.filtered[this.selected];
|
|
240
|
+
if (s) this.onResume?.(s);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (matchesKey(data, Key.ctrl("o"))) {
|
|
244
|
+
const s = this.filtered[this.selected];
|
|
245
|
+
if (s) this.onFork?.(s);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// 其余按键交给搜索框
|
|
249
|
+
const before = this.input.getValue();
|
|
250
|
+
this.input.handleInput(data);
|
|
251
|
+
if (this.input.getValue() !== before) this.refilter(); // 光标移动不算输入,别重置列表
|
|
252
|
+
this.invalidate();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 预览里跳到当前视口之外的下一个 / 上一个命中行,到头了回绕。同一屏里的多个命中算一处,
|
|
256
|
+
// 否则一段里连着几行都命中要按好几次才过得去
|
|
257
|
+
private jumpMatch(dir: 1 | -1): void {
|
|
258
|
+
if (!this.matchLines.length) return;
|
|
259
|
+
const H = this.bodyHeight();
|
|
260
|
+
const top = this.previewOffset;
|
|
261
|
+
const bottom = Math.min(top + H, this.previewLines.length) - 1;
|
|
262
|
+
let target: number | undefined;
|
|
263
|
+
if (dir === 1) {
|
|
264
|
+
target = this.matchLines.find((l) => l > bottom) ?? this.matchLines[0];
|
|
265
|
+
} else {
|
|
266
|
+
target = [...this.matchLines].reverse().find((l) => l < top) ?? this.matchLines[this.matchLines.length - 1];
|
|
267
|
+
}
|
|
268
|
+
if (target !== undefined) {
|
|
269
|
+
this.previewOffset = Math.max(0, target - 2);
|
|
270
|
+
this.invalidate();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 左栏,每个会话两行
|
|
275
|
+
private buildList(height: number, lw: number): string[] {
|
|
276
|
+
const t = this.theme;
|
|
277
|
+
const { kws } = this.query();
|
|
278
|
+
const visible = Math.max(1, Math.floor(height / 2));
|
|
279
|
+
if (this.selected < this.listOffset) this.listOffset = this.selected;
|
|
280
|
+
if (this.selected >= this.listOffset + visible) {
|
|
281
|
+
this.listOffset = this.selected - visible + 1;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const rows: string[] = [];
|
|
285
|
+
for (let i = 0; i < visible; i++) {
|
|
286
|
+
const idx = this.listOffset + i;
|
|
287
|
+
const s = this.filtered[idx];
|
|
288
|
+
if (!s) {
|
|
289
|
+
rows.push(" ".repeat(lw), " ".repeat(lw));
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const sel = idx === this.selected;
|
|
293
|
+
const time = fmtTime(s.time);
|
|
294
|
+
const cwdTail = s.cwd.replace(/\\/g, "/").split("/").slice(-2).join("/");
|
|
295
|
+
// 有关键词时显示命中的消息数
|
|
296
|
+
const hitBadge = kws.length
|
|
297
|
+
? ` ·${s.msgs.reduce((n, m) => n + (anyKw(m.text.toLowerCase(), kws) ? 1 : 0), 0)}`
|
|
298
|
+
: "";
|
|
299
|
+
const l1 = `${sel ? "›" : " "} ${time} ${cwdTail}${hitBadge}`;
|
|
300
|
+
// 第二行:有关键词时显示命中片段,否则显示首条消息
|
|
301
|
+
const snip = snippet(s.msgs, kws, lw);
|
|
302
|
+
const l2 = snip !== undefined
|
|
303
|
+
? ` ${highlight(snip, kws, t)}`
|
|
304
|
+
: ` ${s.first}${s.name ? ` [${s.name}]` : ""}`;
|
|
305
|
+
if (sel) {
|
|
306
|
+
rows.push(t.bg("selectedBg", padEndVisible(t.fg("accent", truncateToWidth(l1, lw)), lw)));
|
|
307
|
+
rows.push(t.bg("selectedBg", padEndVisible(truncateToWidth(l2, lw), lw)));
|
|
308
|
+
} else {
|
|
309
|
+
rows.push(padEndVisible(l1, lw));
|
|
310
|
+
rows.push(padEndVisible(t.fg("dim", truncateToWidth(l2, lw)), lw));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// 每项两行,height 是奇数时会少一行,补空行,否则最后一行右栏会顶到左边
|
|
314
|
+
while (rows.length < height) rows.push(" ".repeat(lw));
|
|
315
|
+
return rows.slice(0, height);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 用 pi 自己的 Markdown 组件渲染一条消息,和主界面里的对话长得一样;参数照抄 pi 的
|
|
319
|
+
// user-message / assistant-message 组件。渲染比较贵,按消息和宽度缓存,换关键词时不用重来
|
|
320
|
+
private renderMsg(m: PeekMsg, rw: number): string[] {
|
|
321
|
+
const hit = this.rendered.get(m);
|
|
322
|
+
if (hit && hit.w === rw) return hit.lines;
|
|
323
|
+
const t = this.theme;
|
|
324
|
+
const md =
|
|
325
|
+
m.role === "user"
|
|
326
|
+
? new Markdown(
|
|
327
|
+
m.text,
|
|
328
|
+
1,
|
|
329
|
+
0,
|
|
330
|
+
this.mdTheme,
|
|
331
|
+
{ color: (s) => t.fg("userMessageText", s), bgColor: (s) => t.bg("userMessageBg", s) },
|
|
332
|
+
{ preserveOrderedListMarkers: true, preserveBackslashEscapes: true },
|
|
333
|
+
)
|
|
334
|
+
: new Markdown(m.text, 1, 0, this.mdTheme);
|
|
335
|
+
const lines = md.render(rw);
|
|
336
|
+
this.rendered.set(m, { w: rw, lines });
|
|
337
|
+
return lines;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 右栏。有关键词时滚到第一个命中处,没有时滚到底部看最新消息
|
|
341
|
+
private buildPreview(rw: number): void {
|
|
342
|
+
const t = this.theme;
|
|
343
|
+
const s = this.filtered[this.selected];
|
|
344
|
+
const key = s ? `${s.path}|${this.getQuery()}|${rw}` : "none";
|
|
345
|
+
if (key === this.previewKey) return;
|
|
346
|
+
this.previewKey = key;
|
|
347
|
+
this.matchLines = [];
|
|
348
|
+
|
|
349
|
+
if (!s) {
|
|
350
|
+
this.previewLines = [t.fg("dim", msg("noMatch"))];
|
|
351
|
+
this.previewOffset = 0;
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const lines: string[] = [];
|
|
356
|
+
lines.push(
|
|
357
|
+
truncateToWidth(
|
|
358
|
+
t.fg("dim", `${s.cwd} • ${msg("msgCount", { n: s.msgs.length })}${s.name ? " • " + s.name : ""}`),
|
|
359
|
+
rw,
|
|
360
|
+
),
|
|
361
|
+
);
|
|
362
|
+
lines.push("");
|
|
363
|
+
|
|
364
|
+
const { kws } = this.query();
|
|
365
|
+
const MAX_MSGS = 500;
|
|
366
|
+
const truncated = s.msgs.length > MAX_MSGS;
|
|
367
|
+
const msgs = truncated ? s.msgs.slice(-MAX_MSGS) : s.msgs;
|
|
368
|
+
if (truncated) {
|
|
369
|
+
lines.push(t.fg("dim", msg("truncated", { n: MAX_MSGS })));
|
|
370
|
+
lines.push("");
|
|
371
|
+
}
|
|
372
|
+
if (kws.length && !msgs.some((m) => anyKw(m.text.toLowerCase(), kws))) {
|
|
373
|
+
const inTruncated = truncated && s.msgs.some((m) => anyKw(m.text.toLowerCase(), kws));
|
|
374
|
+
lines.push(
|
|
375
|
+
t.fg(
|
|
376
|
+
"warning",
|
|
377
|
+
inTruncated ? msg("hitInTruncated") : msg("hitOnlyMeta"),
|
|
378
|
+
),
|
|
379
|
+
);
|
|
380
|
+
lines.push("");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// 用户消息整块背景色,AI 消息只有一条细线标签
|
|
384
|
+
const fillLabel = (label: string): string => {
|
|
385
|
+
const w = visibleWidth(label);
|
|
386
|
+
return truncateToWidth(label + " " + "─".repeat(Math.max(2, rw - w - 1)), rw);
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
for (const m of msgs) {
|
|
390
|
+
const isMatch = kws.length > 0 && anyKw(m.text.toLowerCase(), kws);
|
|
391
|
+
const head = lines.length;
|
|
392
|
+
if (m.role === "user") {
|
|
393
|
+
lines.push(
|
|
394
|
+
t.bg("userMessageBg", padEndVisible(t.bold(t.fg("accent", fillLabel(msg("you")))), rw)),
|
|
395
|
+
);
|
|
396
|
+
} else {
|
|
397
|
+
lines.push(t.fg("muted", fillLabel(msg("ai"))));
|
|
398
|
+
}
|
|
399
|
+
// 先渲染再高亮:往 markdown 源码里插转义码会把链接、代码块的语法弄坏
|
|
400
|
+
const before = this.matchLines.length;
|
|
401
|
+
for (const l of this.renderMsg(m, rw)) {
|
|
402
|
+
if (isMatch && lineHasKw(l, kws)) this.matchLines.push(lines.length);
|
|
403
|
+
lines.push(isMatch ? highlight(l, kws, t) : l);
|
|
404
|
+
}
|
|
405
|
+
// 关键词被折行拆开时哪一行都找不到,退回到消息标题行,别把这条消息漏掉
|
|
406
|
+
if (isMatch && this.matchLines.length === before) this.matchLines.push(head);
|
|
407
|
+
lines.push("");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
this.previewLines = lines;
|
|
411
|
+
|
|
412
|
+
if (this.matchLines.length) {
|
|
413
|
+
this.previewOffset = Math.max(0, this.matchLines[0] - 2);
|
|
414
|
+
} else {
|
|
415
|
+
this.previewOffset = Math.max(0, lines.length - this.bodyHeight());
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
render(width: number): string[] {
|
|
420
|
+
const H = this.bodyHeight();
|
|
421
|
+
const cacheKey = width * 1000 + H; // 宽或高变了都要重画
|
|
422
|
+
if (this.cachedWidth === cacheKey) return this.cachedLines;
|
|
423
|
+
const t = this.theme;
|
|
424
|
+
const lw = Math.max(26, Math.min(56, Math.floor(width * 0.4)));
|
|
425
|
+
const rw = Math.max(20, width - lw - 3);
|
|
426
|
+
|
|
427
|
+
const out: string[] = [];
|
|
428
|
+
|
|
429
|
+
// 头部:标题、范围、时间过滤、匹配数,然后是搜索框
|
|
430
|
+
const scopeLabel = this.scope === "all" ? msg("scopeAll") : msg("scopeCurrent");
|
|
431
|
+
const { sinceLabel } = this.query();
|
|
432
|
+
const head =
|
|
433
|
+
t.fg("accent", t.bold(msg("title"))) +
|
|
434
|
+
t.fg("dim", msg("scopeLabel")) +
|
|
435
|
+
t.fg("warning", scopeLabel) +
|
|
436
|
+
(sinceLabel ? t.fg("dim", msg("timeLabel")) + t.fg("warning", sinceLabel) : "") +
|
|
437
|
+
t.fg("dim", msg("matchCount", { n: this.filtered.length, total: this.all.length }));
|
|
438
|
+
out.push(truncateToWidth(head, width));
|
|
439
|
+
out.push(this.input.render(width)[0] ?? "");
|
|
440
|
+
out.push(t.fg("borderMuted", "─".repeat(width)));
|
|
441
|
+
|
|
442
|
+
// 双栏
|
|
443
|
+
this.buildPreview(rw);
|
|
444
|
+
const leftRows = this.buildList(H, lw);
|
|
445
|
+
|
|
446
|
+
const maxOff = Math.max(0, this.previewLines.length - H);
|
|
447
|
+
if (this.previewOffset > maxOff) this.previewOffset = maxOff;
|
|
448
|
+
const rightRows = this.previewLines.slice(this.previewOffset, this.previewOffset + H);
|
|
449
|
+
// 右上角:有命中时显示"第几处/共几处"(视口内第一个命中的序号),行数多时再加行号范围
|
|
450
|
+
const end = Math.min(this.previewOffset + H, this.previewLines.length);
|
|
451
|
+
let info = "";
|
|
452
|
+
if (this.matchLines.length) {
|
|
453
|
+
const i = this.matchLines.findIndex((l) => l >= this.previewOffset && l < end);
|
|
454
|
+
info += msg("hitPos", { k: i >= 0 ? String(i + 1) : "-", n: this.matchLines.length });
|
|
455
|
+
}
|
|
456
|
+
if (this.previewLines.length > H) info += ` (${this.previewOffset + 1}-${end}/${this.previewLines.length})`;
|
|
457
|
+
const scrollInfo = info ? t.fg("dim", info) : "";
|
|
458
|
+
|
|
459
|
+
for (let i = 0; i < H; i++) {
|
|
460
|
+
const l = leftRows[i] ?? " ".repeat(lw);
|
|
461
|
+
let r = rightRows[i] ?? "";
|
|
462
|
+
if (i === 0 && scrollInfo) {
|
|
463
|
+
r = truncateToWidth(r, Math.max(0, rw - visibleWidth(scrollInfo))) + scrollInfo;
|
|
464
|
+
}
|
|
465
|
+
// 左栏在 buildList 里已经 pad 到 lw,这里再 pad 会把带样式的行截坏
|
|
466
|
+
out.push(l + t.fg("borderMuted", " │ ") + padEndVisible(r, rw));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// 底部:重命名和删除确认时换成对应的操作行
|
|
470
|
+
out.push(t.fg("borderMuted", "─".repeat(width)));
|
|
471
|
+
if (this.renaming) {
|
|
472
|
+
const prefix = msg("renamePrefix");
|
|
473
|
+
const inputLine = this.renameInput.render(Math.max(10, width - visibleWidth(prefix)))[0] ?? "";
|
|
474
|
+
out.push(truncateToWidth(t.fg("warning", prefix) + inputLine, width));
|
|
475
|
+
} else if (this.confirmingDelete) {
|
|
476
|
+
const s = this.filtered[this.selected];
|
|
477
|
+
const desc = s ? `${fmtTime(s.time, true)} ${s.first.slice(0, 30)}` : "";
|
|
478
|
+
out.push(
|
|
479
|
+
truncateToWidth(
|
|
480
|
+
t.fg("error", t.bold(msg("confirmDelete", { desc }))),
|
|
481
|
+
width,
|
|
482
|
+
),
|
|
483
|
+
);
|
|
484
|
+
} else {
|
|
485
|
+
out.push(
|
|
486
|
+
truncateToWidth(
|
|
487
|
+
t.fg("dim", msg("footer")),
|
|
488
|
+
width,
|
|
489
|
+
),
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
this.cachedWidth = cacheKey;
|
|
494
|
+
this.cachedLines = out;
|
|
495
|
+
return out;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
invalidate(): void {
|
|
499
|
+
this.cachedWidth = -1;
|
|
500
|
+
this.input.invalidate();
|
|
501
|
+
}
|
|
502
|
+
}
|