pi-session-peek 0.1.0 → 0.2.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 +10 -0
- package/README.md +16 -2
- package/README.zh-CN.md +16 -1
- package/docs/screenshot.png +0 -0
- package/index.ts +13 -12
- package/package.json +4 -3
- package/src/i18n.ts +126 -0
- package/src/peek-component.ts +500 -464
- package/src/query.ts +121 -18
- package/src/text.ts +1 -10
package/src/query.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { msg, type MsgKey } from "./i18n.ts";
|
|
1
2
|
import type { PeekMsg } from "./sessions.ts";
|
|
2
3
|
|
|
3
4
|
// 搜索框语法:空格分隔多个词,都要命中;@7d / @24h / @2w / @1m 限定最近活动时间
|
|
@@ -9,7 +10,7 @@ export interface ParsedQuery {
|
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
const TIME_UNIT_MS: Record<string, number> = { h: 3600e3, d: 86400e3, w: 7 * 86400e3, m: 30 * 86400e3 };
|
|
12
|
-
const TIME_UNIT_LABEL: Record<string,
|
|
13
|
+
const TIME_UNIT_LABEL: Record<string, MsgKey> = { h: "unitH", d: "unitD", w: "unitW", m: "unitM" };
|
|
13
14
|
|
|
14
15
|
// "foo bar @7d" → kws = [foo, bar],since = 7 天前
|
|
15
16
|
export function parseQuery(q: string): ParsedQuery {
|
|
@@ -23,7 +24,7 @@ export function parseQuery(q: string): ParsedQuery {
|
|
|
23
24
|
const n = Number(m[1]);
|
|
24
25
|
if (n > 0) {
|
|
25
26
|
since = Date.now() - n * TIME_UNIT_MS[m[2]];
|
|
26
|
-
sinceLabel =
|
|
27
|
+
sinceLabel = msg("since", { n, unit: msg(TIME_UNIT_LABEL[m[2]]) });
|
|
27
28
|
}
|
|
28
29
|
continue;
|
|
29
30
|
}
|
|
@@ -52,26 +53,128 @@ function nextMatch(lower: string, kws: string[], i: number): [number, number] {
|
|
|
52
53
|
return [best, len];
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
//
|
|
56
|
+
// pi-tui 会产生的三类转义序列:CSI(颜色等)、OSC(超链接)、APC
|
|
57
|
+
const ESC_RE = /\x1b\[[0-9;?]*[A-Za-z]|\x1b[\]_][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
58
|
+
|
|
59
|
+
// 把样式函数拆出前缀,pi 的 Markdown 组件也是这么做的
|
|
60
|
+
function stylePrefix(fn: (s: string) => string): string {
|
|
61
|
+
const s = fn(" ");
|
|
62
|
+
const i = s.indexOf(" ");
|
|
63
|
+
return i >= 0 ? s.slice(0, i) : "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 只跟踪高亮会碰到的几项:前景色、粗体 / 暗淡、下划线
|
|
67
|
+
interface Sgr {
|
|
68
|
+
fg: string;
|
|
69
|
+
bold: boolean;
|
|
70
|
+
dim: boolean;
|
|
71
|
+
underline: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function applySgr(st: Sgr, seq: string): void {
|
|
75
|
+
const m = /^\x1b\[([0-9;]*)m$/.exec(seq);
|
|
76
|
+
if (!m) return;
|
|
77
|
+
const p = m[1] === "" ? [0] : m[1].split(";").map(Number);
|
|
78
|
+
for (let i = 0; i < p.length; i++) {
|
|
79
|
+
const c = p[i];
|
|
80
|
+
if (c === 0) {
|
|
81
|
+
st.fg = "";
|
|
82
|
+
st.bold = st.dim = st.underline = false;
|
|
83
|
+
} else if (c === 1) st.bold = true;
|
|
84
|
+
else if (c === 2) st.dim = true;
|
|
85
|
+
else if (c === 22) st.bold = st.dim = false;
|
|
86
|
+
else if (c === 4) st.underline = true;
|
|
87
|
+
else if (c === 24) st.underline = false;
|
|
88
|
+
else if ((c >= 30 && c <= 37) || (c >= 90 && c <= 97)) st.fg = `\x1b[${c}m`;
|
|
89
|
+
else if (c === 39) st.fg = "";
|
|
90
|
+
else if (c === 38 || c === 48) {
|
|
91
|
+
// 扩展色 38;5;n / 38;2;r;g;b;背景色(48)只需要跳过它的参数
|
|
92
|
+
const n = p[i + 1] === 5 ? 2 : p[i + 1] === 2 ? 4 : 0;
|
|
93
|
+
if (c === 38 && n) st.fg = `\x1b[${p.slice(i, i + n + 1).join(";")}m`;
|
|
94
|
+
i += n;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 渲染后的一行(可带 ANSI)里有没有关键词,预览按行记命中用
|
|
100
|
+
export function lineHasKw(line: string, kws: string[]): boolean {
|
|
101
|
+
return kws.length > 0 && anyKw(line.replace(ESC_RE, "").toLowerCase(), kws);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 在一行文字里高亮关键词,返回带 ANSI 的字符串。
|
|
105
|
+
// 输入可以是已经渲染过、带样式的行:转义序列不参与匹配也不会被切断;高亮结束时把原来的
|
|
106
|
+
// 前景色 / 粗体 / 下划线恢复回去,所以放在标题、链接等样式里面也不会把后面的文字弄丢样式。
|
|
56
107
|
// 不用主题自带的 searchMatchBg:dark/light 主题里它和 selectedBg 同色,看不出来
|
|
57
|
-
export function highlight(
|
|
58
|
-
if (!kws.length) return
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
108
|
+
export function highlight(line: string, kws: string[], theme: any): string {
|
|
109
|
+
if (!kws.length || !line) return line;
|
|
110
|
+
|
|
111
|
+
// 拆成可见文本和转义序列两类片段
|
|
112
|
+
const segs: { text: string; esc: boolean }[] = [];
|
|
113
|
+
let last = 0;
|
|
114
|
+
for (const m of line.matchAll(ESC_RE)) {
|
|
115
|
+
if (m.index > last) segs.push({ text: line.slice(last, m.index), esc: false });
|
|
116
|
+
segs.push({ text: m[0], esc: true });
|
|
117
|
+
last = m.index + m[0].length;
|
|
118
|
+
}
|
|
119
|
+
if (last < line.length) segs.push({ text: line.slice(last), esc: false });
|
|
120
|
+
|
|
121
|
+
let visible = "";
|
|
122
|
+
for (const s of segs) if (!s.esc) visible += s.text;
|
|
123
|
+
const lower = visible.toLowerCase();
|
|
124
|
+
if (lower.length !== visible.length) return line; // 个别字符小写后长度会变,位置对不上就不高亮
|
|
125
|
+
|
|
126
|
+
const ranges: [number, number][] = [];
|
|
127
|
+
for (let i = 0; ; ) {
|
|
63
128
|
const [j, len] = nextMatch(lower, kws, i);
|
|
64
|
-
if (j === -1)
|
|
65
|
-
|
|
66
|
-
break;
|
|
67
|
-
}
|
|
68
|
-
out +=
|
|
69
|
-
text.slice(i, j) +
|
|
70
|
-
"\x1b[4m" +
|
|
71
|
-
theme.bold(theme.fg("warning", text.slice(j, j + len))) +
|
|
72
|
-
"\x1b[24m";
|
|
129
|
+
if (j === -1) break;
|
|
130
|
+
ranges.push([j, j + len]);
|
|
73
131
|
i = j + len;
|
|
74
132
|
}
|
|
133
|
+
if (!ranges.length) return line;
|
|
134
|
+
|
|
135
|
+
const on = "\x1b[1m\x1b[4m" + stylePrefix((s) => theme.fg("warning", s));
|
|
136
|
+
const st: Sgr = { fg: "", bold: false, dim: false, underline: false };
|
|
137
|
+
const off = () =>
|
|
138
|
+
(st.underline ? "" : "\x1b[24m") +
|
|
139
|
+
(st.bold ? "" : "\x1b[22m" + (st.dim ? "\x1b[2m" : "")) +
|
|
140
|
+
(st.fg || "\x1b[39m");
|
|
141
|
+
|
|
142
|
+
let out = "";
|
|
143
|
+
let pos = 0; // 当前片段在 visible 里的起点
|
|
144
|
+
let r = 0; // 当前区间下标
|
|
145
|
+
let inside = false;
|
|
146
|
+
for (const seg of segs) {
|
|
147
|
+
if (seg.esc) {
|
|
148
|
+
applySgr(st, seg.text);
|
|
149
|
+
out += seg.text;
|
|
150
|
+
if (inside) out += on; // 序列可能重置了样式,补回来
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const end = pos + seg.text.length;
|
|
154
|
+
let i = pos;
|
|
155
|
+
while (i < end) {
|
|
156
|
+
if (inside) {
|
|
157
|
+
const stop = Math.min(ranges[r][1], end);
|
|
158
|
+
out += visible.slice(i, stop);
|
|
159
|
+
i = stop;
|
|
160
|
+
if (stop === ranges[r][1]) {
|
|
161
|
+
out += off();
|
|
162
|
+
inside = false;
|
|
163
|
+
r++;
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
const start = r < ranges.length ? ranges[r][0] : end;
|
|
167
|
+
const stop = Math.min(start, end);
|
|
168
|
+
out += visible.slice(i, stop);
|
|
169
|
+
i = stop;
|
|
170
|
+
if (stop === start && stop < end) {
|
|
171
|
+
out += on;
|
|
172
|
+
inside = true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
pos = end;
|
|
177
|
+
}
|
|
75
178
|
return out;
|
|
76
179
|
}
|
|
77
180
|
|
package/src/text.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { truncateToWidth, visibleWidth
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
2
|
|
|
3
3
|
// Windows 路径大小写不敏感,统一小写再比
|
|
4
4
|
export const normPath = (p: string) => p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
@@ -17,12 +17,3 @@ export function padEndVisible(s: string, w: number): string {
|
|
|
17
17
|
const v = visibleWidth(s);
|
|
18
18
|
return v >= w ? truncateToWidth(s, w) : s + " ".repeat(w - v);
|
|
19
19
|
}
|
|
20
|
-
|
|
21
|
-
export function wrapLines(text: string, width: number): string[] {
|
|
22
|
-
const out: string[] = [];
|
|
23
|
-
for (const part of text.split("\n")) {
|
|
24
|
-
const wrapped = wrapTextWithAnsi(part, width);
|
|
25
|
-
out.push(...(wrapped.length ? wrapped : [""]));
|
|
26
|
-
}
|
|
27
|
-
return out;
|
|
28
|
-
}
|