pi-lazy-panel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/README.zh.md +104 -0
- package/docs/design.md +235 -0
- package/docs/keybindings.md +172 -0
- package/docs/keybindings.zh.md +172 -0
- package/package.json +67 -0
- package/src/actions/session-actions.ts +585 -0
- package/src/actions/tree-actions.ts +161 -0
- package/src/config/config.ts +155 -0
- package/src/config/keymap.ts +219 -0
- package/src/config/keys.ts +279 -0
- package/src/config/pi-settings.ts +54 -0
- package/src/constants.ts +48 -0
- package/src/data/changelog.ts +63 -0
- package/src/data/content.ts +178 -0
- package/src/data/search.ts +183 -0
- package/src/data/sessions.ts +148 -0
- package/src/data/tree-fold.ts +163 -0
- package/src/data/tree.ts +258 -0
- package/src/i18n/index.ts +103 -0
- package/src/i18n/locales/en.json +341 -0
- package/src/i18n/locales/zh.json +341 -0
- package/src/index.ts +155 -0
- package/src/types.ts +346 -0
- package/src/ui/app.ts +2892 -0
- package/src/ui/frame.ts +116 -0
- package/src/ui/mouse-input.ts +197 -0
- package/src/ui/mouse.ts +99 -0
- package/src/ui/panes/content-pane.ts +132 -0
- package/src/ui/panes/sessions-pane.ts +161 -0
- package/src/ui/panes/tree-pane.ts +152 -0
- package/src/ui/search-highlight.ts +108 -0
- package/src/ui/tree-lines.ts +111 -0
- package/src/ui/tree-outline.ts +80 -0
- package/src/ui/widgets/changelog-dialog.ts +196 -0
- package/src/ui/widgets/compact-dialog.ts +29 -0
- package/src/ui/widgets/confirm-dialog.ts +98 -0
- package/src/ui/widgets/export-dialog.ts +62 -0
- package/src/ui/widgets/footer.ts +117 -0
- package/src/ui/widgets/fork-dialog.ts +28 -0
- package/src/ui/widgets/help-overlay.ts +220 -0
- package/src/ui/widgets/import-dialog.ts +32 -0
- package/src/ui/widgets/input-dialog.ts +136 -0
- package/src/ui/widgets/label-dialog.ts +28 -0
- package/src/ui/widgets/new-session-dialog.ts +29 -0
- package/src/ui/widgets/prompt-bar.ts +92 -0
- package/src/ui/widgets/rename-dialog.ts +29 -0
- package/src/ui/widgets/restore-dialog.ts +60 -0
- package/src/ui/widgets/search-bar.ts +65 -0
- package/src/ui/widgets/select-dialog.ts +193 -0
- package/src/ui/widgets/session-info-dialog.ts +189 -0
- package/src/ui/widgets/tree-dialog.ts +293 -0
- package/src/utils/format.ts +83 -0
- package/src/utils/paths.ts +33 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search query parsing and matching.
|
|
3
|
+
*
|
|
4
|
+
* Supports GitHub-style qualifiers in addition to free text:
|
|
5
|
+
* name:FilmRecall model:opus path:FilmRecall tag:scan role:user
|
|
6
|
+
* after:2026-09-01 before:2026-09-20
|
|
7
|
+
*
|
|
8
|
+
* Pure functions only — no I/O, no UI.
|
|
9
|
+
*
|
|
10
|
+
* Free text only looks at what identifies a row — its name / title / label —
|
|
11
|
+
* so a bare word never matches invisible or repetitive fields (the home
|
|
12
|
+
* directory, the model id, the role); those need their qualifier. Every
|
|
13
|
+
* free-text token (case-insensitive) must appear, the qualifiers narrow
|
|
14
|
+
* further, and a qualifier a pane has no field for is ignored:
|
|
15
|
+
* - tree rows (`matchTreeRow`, the tree pane and the tree dialog): tokens in
|
|
16
|
+
* `label + text`; `tag:` narrows to labels, `role:` to the role,
|
|
17
|
+
* `after:` / `before:` to the entry time; `name:` / `model:` / `path:`
|
|
18
|
+
* are ignored
|
|
19
|
+
* - session rows (`matchSessionRow`, the sessions pane): tokens in
|
|
20
|
+
* `name + preview` (what the row's title shows); `name:` narrows to the
|
|
21
|
+
* name, `model:` / `path:` are the only way to match the model and the
|
|
22
|
+
* working directory (`path:` accepts the full cwd or its `~` form),
|
|
23
|
+
* `after:` / `before:` bound the last update; `tag:` / `role:` are ignored
|
|
24
|
+
* - content lines (`matchesTokens` on the rendered line): tokens only, no
|
|
25
|
+
* qualifier applies
|
|
26
|
+
* `findMatchRanges` locates the tokens in a piece of text so the panes can
|
|
27
|
+
* highlight them.
|
|
28
|
+
*
|
|
29
|
+
* 三个面板和树对话框共用这里的解析 / 匹配;对话框只用 parseSearchQuery + matchTreeRow。
|
|
30
|
+
* 普通词只搜 名称 / 标题 / label;模型、路径、角色、时间都要加限定词。
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { SearchQuery, SessionRow, TreeRow } from "../types.ts";
|
|
34
|
+
import { shortenPath } from "../utils/format.ts";
|
|
35
|
+
|
|
36
|
+
/** Qualifiers `parseSearchQuery` understands, in `key:value` form. */
|
|
37
|
+
const QUALIFIERS = new Set(["name", "model", "path", "tag", "role", "after", "before"]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse raw input typed after `/` into a structured query: whitespace-separated
|
|
41
|
+
* tokens, `key:value` for a known qualifier (the last one wins), everything
|
|
42
|
+
* else joined back into `text`. An unparsable date is kept as text.
|
|
43
|
+
*/
|
|
44
|
+
export function parseSearchQuery(raw: string): SearchQuery {
|
|
45
|
+
const query: SearchQuery = { text: "" };
|
|
46
|
+
const words: string[] = [];
|
|
47
|
+
for (const token of raw.trim().split(/\s+/).filter(Boolean)) {
|
|
48
|
+
const colon = token.indexOf(":");
|
|
49
|
+
const key = colon > 0 ? token.slice(0, colon).toLowerCase() : "";
|
|
50
|
+
const value = colon > 0 ? token.slice(colon + 1) : "";
|
|
51
|
+
if (!QUALIFIERS.has(key) || !value) {
|
|
52
|
+
words.push(token);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (key === "after" || key === "before") {
|
|
56
|
+
const date = parseDate(value);
|
|
57
|
+
if (!date) {
|
|
58
|
+
words.push(token);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
query[key] = date;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
query[key as "name" | "model" | "path" | "tag" | "role"] = value;
|
|
65
|
+
}
|
|
66
|
+
query.text = words.join(" ");
|
|
67
|
+
return query;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `2026-09-01` → local midnight of that day; anything `Date` cannot parse → undefined. */
|
|
71
|
+
function parseDate(value: string): Date | undefined {
|
|
72
|
+
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(value);
|
|
73
|
+
const date = m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : new Date(value);
|
|
74
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Free-text tokens of a query, lower-cased (empty when the query has no text). */
|
|
78
|
+
export function searchTokens(query: SearchQuery): string[] {
|
|
79
|
+
return query.text.toLowerCase().split(/\s+/).filter(Boolean);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Does `text` contain every token (case-insensitive)? An empty token list matches anything. */
|
|
83
|
+
export function matchesTokens(text: string, tokens: string[]): boolean {
|
|
84
|
+
if (tokens.length === 0) return true;
|
|
85
|
+
const haystack = text.toLowerCase();
|
|
86
|
+
return tokens.every((t) => haystack.includes(t));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Does `value` contain `needle`, case-insensitively? A missing value never does. */
|
|
90
|
+
function containsCI(value: string | undefined, needle: string): boolean {
|
|
91
|
+
return (value ?? "").toLowerCase().includes(needle.toLowerCase());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Does a session row satisfy the query? Free text: every token is contained in
|
|
96
|
+
* `name preview` — the text the row's title is made of. The model and the
|
|
97
|
+
* working directory are deliberately left out: a bare word would otherwise
|
|
98
|
+
* match a fragment of the home directory (`he` in `/home/cheng`) or of the
|
|
99
|
+
* model id (`us` in `opus`) on every session, invisibly. `model:` / `path:`
|
|
100
|
+
* reach those fields on purpose (`path:` matches the full cwd or its `~` form),
|
|
101
|
+
* `name:` needs the name to contain the value, `after:` / `before:` bound the
|
|
102
|
+
* last update. `tag:` only means something for tree rows and is ignored.
|
|
103
|
+
*
|
|
104
|
+
* SESSIONS 面板的匹配:普通词只看 名称 + 首条消息预览(标题那一行的内容);
|
|
105
|
+
* 模型和路径只通过 model: / path: 匹配,避免家目录、模型名里的碎片让所有会话都命中。
|
|
106
|
+
*/
|
|
107
|
+
export function matchSessionRow(row: SessionRow, query: SearchQuery): boolean {
|
|
108
|
+
if (query.name !== undefined && !containsCI(row.name, query.name)) return false;
|
|
109
|
+
if (query.model !== undefined && !containsCI(row.model, query.model)) return false;
|
|
110
|
+
if (query.path !== undefined && !containsCI(row.cwd, query.path) && !containsCI(shortenPath(row.cwd), query.path)) return false;
|
|
111
|
+
if (query.after !== undefined && row.updatedAt < query.after.getTime()) return false;
|
|
112
|
+
if (query.before !== undefined && row.updatedAt >= query.before.getTime()) return false;
|
|
113
|
+
return matchesTokens(`${row.name ?? ""} ${row.preview}`, searchTokens(query));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Does a tree row satisfy the query? Free text: every token is contained in
|
|
118
|
+
* `label text`; `tag:` needs a label containing it, `role:` a role containing
|
|
119
|
+
* it (`user`, `assistant`, `system`, `tool`); `after:` / `before:` bound the
|
|
120
|
+
* entry time. Session-only qualifiers are ignored. Unlike pi's /tree, the role
|
|
121
|
+
* is not part of the free-text haystack (`us` would hit every user row).
|
|
122
|
+
*
|
|
123
|
+
* TREE 的匹配:普通词只看 label + 正文;角色要用 role:,标签可用 tag: 单独限定。
|
|
124
|
+
*/
|
|
125
|
+
export function matchTreeRow(row: TreeRow, query: SearchQuery): boolean {
|
|
126
|
+
if (query.tag !== undefined && !containsCI(row.label, query.tag)) return false;
|
|
127
|
+
if (query.role !== undefined && !containsCI(row.role, query.role)) return false;
|
|
128
|
+
if (query.after !== undefined && row.timestamp < query.after.getTime()) return false;
|
|
129
|
+
if (query.before !== undefined && row.timestamp >= query.before.getTime()) return false;
|
|
130
|
+
return matchesTokens(`${row.label ?? ""} ${row.text}`, searchTokens(query));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Everything worth highlighting on a matching row: the free-text tokens plus the
|
|
135
|
+
* values of the text qualifiers (`name:` / `model:` / `path:` / `tag:` /
|
|
136
|
+
* `role:`), so a `model:opus` hit lights up "opus" in the row. Dates have no
|
|
137
|
+
* text to mark.
|
|
138
|
+
*/
|
|
139
|
+
export function highlightTerms(query: SearchQuery): string[] {
|
|
140
|
+
const out = searchTokens(query);
|
|
141
|
+
for (const value of [query.name, query.model, query.path, query.tag, query.role]) {
|
|
142
|
+
if (value) out.push(value);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A half-open range of code units inside a string (`start` inclusive, `end` exclusive). */
|
|
148
|
+
export interface MatchRange {
|
|
149
|
+
start: number;
|
|
150
|
+
end: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Where the `terms` occur in `text` (case-insensitive, every occurrence),
|
|
155
|
+
* merged when they overlap or touch and sorted by position, as code-unit
|
|
156
|
+
* ranges into `text`. Used to highlight matches; empty terms are skipped.
|
|
157
|
+
*
|
|
158
|
+
* 命中位置:每个词在文本里的所有出现(不区分大小写),重叠 / 相邻的段合并,按位置排序。
|
|
159
|
+
*/
|
|
160
|
+
export function findMatchRanges(text: string, terms: string[]): MatchRange[] {
|
|
161
|
+
const found: MatchRange[] = [];
|
|
162
|
+
for (const term of terms) {
|
|
163
|
+
if (!term) continue;
|
|
164
|
+
// 用正则的 i / u 标志做大小写无关匹配,位置直接落在原文上,不会被 toLowerCase 改变长度的字符带偏。
|
|
165
|
+
const re = new RegExp(escapeRegExp(term), "giu");
|
|
166
|
+
for (const m of text.matchAll(re)) {
|
|
167
|
+
if (m[0].length === 0) break;
|
|
168
|
+
found.push({ start: m.index, end: m.index + m[0].length });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
found.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
172
|
+
const out: MatchRange[] = [];
|
|
173
|
+
for (const r of found) {
|
|
174
|
+
const last = out[out.length - 1];
|
|
175
|
+
if (last && r.start <= last.end) last.end = Math.max(last.end, r.end);
|
|
176
|
+
else out.push({ ...r });
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function escapeRegExp(text: string): string {
|
|
182
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
183
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessions data adapter.
|
|
3
|
+
*
|
|
4
|
+
* Read-only bridge between pi's `SessionManager.list()` / `listAll()` and the
|
|
5
|
+
* `SessionRow` shape used by the sessions pane. No UI code here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import type { ListScope, SessionRow, SessionSortMode } from "../types.ts";
|
|
11
|
+
import { singleLine } from "../utils/format.ts";
|
|
12
|
+
|
|
13
|
+
export interface ListSessionsOptions {
|
|
14
|
+
cwd: string;
|
|
15
|
+
scope: ListScope;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** List sessions for the given scope. */
|
|
19
|
+
export async function listSessions(options: ListSessionsOptions): Promise<SessionRow[]> {
|
|
20
|
+
const infos =
|
|
21
|
+
options.scope === "all" ? await SessionManager.listAll() : await SessionManager.list(options.cwd);
|
|
22
|
+
|
|
23
|
+
return infos.map((info) => {
|
|
24
|
+
const row: SessionRow = {
|
|
25
|
+
file: info.path,
|
|
26
|
+
id: info.id,
|
|
27
|
+
cwd: info.cwd,
|
|
28
|
+
preview: singleLine(info.firstMessage),
|
|
29
|
+
createdAt: info.created.getTime(),
|
|
30
|
+
updatedAt: info.modified.getTime(),
|
|
31
|
+
messageCount: info.messageCount,
|
|
32
|
+
};
|
|
33
|
+
if (info.name) row.name = info.name;
|
|
34
|
+
if (info.parentSessionPath) row.parentFile = info.parentSessionPath;
|
|
35
|
+
const model = readLastModel(info.path);
|
|
36
|
+
if (model) row.model = model;
|
|
37
|
+
return row;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Model id of the last `model_change` entry in the session file, if any.
|
|
43
|
+
* `SessionInfo` does not carry it, so we do one cheap read of the file.
|
|
44
|
+
*/
|
|
45
|
+
function readLastModel(file: string): string | undefined {
|
|
46
|
+
try {
|
|
47
|
+
const manager = SessionManager.open(file);
|
|
48
|
+
let model: string | undefined;
|
|
49
|
+
for (const entry of manager.getEntries()) {
|
|
50
|
+
if (entry.type === "model_change") model = entry.modelId;
|
|
51
|
+
}
|
|
52
|
+
return model;
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Index of the row whose file is `sessionFile` (paths compared after `resolve`,
|
|
60
|
+
* same rule as `isCurrentSession`), or -1. Used to put the cursor on pi's
|
|
61
|
+
* current session when the panel opens.
|
|
62
|
+
*/
|
|
63
|
+
export function findSessionIndex(rows: SessionRow[], sessionFile: string | undefined): number {
|
|
64
|
+
if (!sessionFile) return -1;
|
|
65
|
+
const target = resolve(sessionFile);
|
|
66
|
+
return rows.findIndex((row) => resolve(row.file) === target);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Return a sorted copy of the rows.
|
|
71
|
+
*
|
|
72
|
+
* recent last update, newest first (the default)
|
|
73
|
+
* created creation time, newest first
|
|
74
|
+
* title by the title the pane shows (name, else first-message preview), A–Z
|
|
75
|
+
* case-insensitive; English (ASCII) before CJK, empty sessions last,
|
|
76
|
+
* ties by recency
|
|
77
|
+
* threaded forks indented under their parent (pi's /resume), roots by recency
|
|
78
|
+
*/
|
|
79
|
+
export function sortSessions(rows: SessionRow[], mode: SessionSortMode): SessionRow[] {
|
|
80
|
+
const byRecent = (a: SessionRow, b: SessionRow) => b.updatedAt - a.updatedAt;
|
|
81
|
+
switch (mode) {
|
|
82
|
+
case "recent":
|
|
83
|
+
return [...rows].sort(byRecent);
|
|
84
|
+
case "created":
|
|
85
|
+
return [...rows].sort((a, b) => b.createdAt - a.createdAt || byRecent(a, b));
|
|
86
|
+
case "title":
|
|
87
|
+
return [...rows].sort((a, b) => compareTitles(a, b) || byRecent(a, b));
|
|
88
|
+
case "threaded":
|
|
89
|
+
return sortThreaded(rows, byRecent);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The title the sessions pane prints for a row: its name, else the first-message preview. */
|
|
94
|
+
export function sessionTitle(row: SessionRow): string {
|
|
95
|
+
return row.name?.trim() || row.preview.trim();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 按面板上显示的标题比较(所见即所排):没起名字的会话用首条消息预览参与排序,
|
|
100
|
+
* 而不是一律排到后面;只有既没名字也没预览的空会话才落到最后。
|
|
101
|
+
* 英文(ASCII)标题排在中文等非 ASCII 标题之前,组内再各自不分大小写 A–Z,
|
|
102
|
+
* 避免在中文 locale 下 localeCompare 把中文排到英文前面。
|
|
103
|
+
*/
|
|
104
|
+
function compareTitles(a: SessionRow, b: SessionRow): number {
|
|
105
|
+
const at = sessionTitle(a);
|
|
106
|
+
const bt = sessionTitle(b);
|
|
107
|
+
if (at && bt) return titleGroup(at) - titleGroup(bt) || at.localeCompare(bt, undefined, { sensitivity: "base" });
|
|
108
|
+
if (at) return -1;
|
|
109
|
+
if (bt) return 1;
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 分组键:首字符是 ASCII(英文/数字/符号)为 0 排前,其余(中文等)为 1 排后。 */
|
|
114
|
+
function titleGroup(title: string): number {
|
|
115
|
+
return (title.codePointAt(0) ?? 0) < 0x80 ? 0 : 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Threaded: roots ordered by recency, each followed (depth-first) by the
|
|
120
|
+
* sessions forked from it. Rows whose parent is unknown are treated as roots.
|
|
121
|
+
*/
|
|
122
|
+
function sortThreaded(rows: SessionRow[], cmp: (a: SessionRow, b: SessionRow) => number): SessionRow[] {
|
|
123
|
+
const byFile = new Map(rows.map((r) => [r.file, r]));
|
|
124
|
+
const children = new Map<string, SessionRow[]>();
|
|
125
|
+
const roots: SessionRow[] = [];
|
|
126
|
+
for (const row of rows) {
|
|
127
|
+
const parent = row.parentFile;
|
|
128
|
+
if (parent && byFile.has(parent) && parent !== row.file) {
|
|
129
|
+
const list = children.get(parent) ?? [];
|
|
130
|
+
list.push(row);
|
|
131
|
+
children.set(parent, list);
|
|
132
|
+
} else {
|
|
133
|
+
roots.push(row);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const out: SessionRow[] = [];
|
|
137
|
+
const seen = new Set<string>();
|
|
138
|
+
const visit = (row: SessionRow, depth: number) => {
|
|
139
|
+
if (seen.has(row.file)) return;
|
|
140
|
+
seen.add(row.file);
|
|
141
|
+
out.push({ ...row, threadDepth: depth });
|
|
142
|
+
for (const child of (children.get(row.file) ?? []).sort(cmp)) visit(child, depth + 1);
|
|
143
|
+
};
|
|
144
|
+
for (const root of roots.sort(cmp)) visit(root, 0);
|
|
145
|
+
// Defensive: rows in a parent cycle never became reachable from a root.
|
|
146
|
+
for (const row of [...rows].sort(cmp)) visit(row, 0);
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folding and filtering of tree rows (the `z` key of the pane and the tree
|
|
3
|
+
* dialog, the dialog's search, `⊞` in its guide lines).
|
|
4
|
+
*
|
|
5
|
+
* The unit of folding is a branch segment, as in pi's own /tree: a row can be
|
|
6
|
+
* folded when it starts a segment — its parent has several children, or it is
|
|
7
|
+
* one of several roots — and it has children of its own. Folding hides every
|
|
8
|
+
* descendant. Unlike pi, a lone root is not foldable (that would hide the whole
|
|
9
|
+
* tree), so a linear conversation has nothing to fold and nothing to mark.
|
|
10
|
+
*
|
|
11
|
+
* Pure functions over pre-order `TreeRow[]` (parents precede children, see
|
|
12
|
+
* ./tree.ts); the panel keeps the folded ids in its state, the pane and the
|
|
13
|
+
* dialog only ever see the rows that survive `applyTreeFold`. `filterTreeRows`
|
|
14
|
+
* is the generic "drop rows, keep a forest" step behind the tree filters and
|
|
15
|
+
* the dialog's search.
|
|
16
|
+
*
|
|
17
|
+
* 折叠的单位是"分支段":父节点有多个子节点的那个子节点(或多根时的根)且自己有后代
|
|
18
|
+
* 才能折叠,折叠后隐藏它的全部后代。默认把不在活动分支上的段都折起来。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { TreeRow } from "../types.ts";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Keep the rows `keep` accepts, re-parenting each survivor to its nearest kept
|
|
25
|
+
* ancestor (the tree filters and the dialog's search both go through here).
|
|
26
|
+
* Rows that end up unchanged are returned as-is.
|
|
27
|
+
*
|
|
28
|
+
* 过滤掉中间节点后,子节点挂到最近一个保留下来的祖先上,树线才画得出来。
|
|
29
|
+
*/
|
|
30
|
+
export function filterTreeRows(rows: TreeRow[], keep: (row: TreeRow) => boolean): TreeRow[] {
|
|
31
|
+
// rows 是先序排列的,父节点一定先于子节点出现,所以一遍就能算出新的 parentId。
|
|
32
|
+
const nearestKept = new Map<string, string | undefined>();
|
|
33
|
+
const out: TreeRow[] = [];
|
|
34
|
+
for (const r of rows) {
|
|
35
|
+
const parent = r.parentId ? nearestKept.get(r.parentId) : undefined;
|
|
36
|
+
if (!keep(r)) {
|
|
37
|
+
nearestKept.set(r.entryId, parent);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
nearestKept.set(r.entryId, r.entryId);
|
|
41
|
+
if (parent === r.parentId) out.push(r);
|
|
42
|
+
else {
|
|
43
|
+
const { parentId: _dropped, ...rest } = r;
|
|
44
|
+
out.push(parent ? { ...rest, parentId: parent } : rest);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Children of every row keyed by parent id (`undefined` = roots), in row order. A parent that is not a row counts as none. */
|
|
51
|
+
export function treeChildren(rows: TreeRow[]): Map<string | undefined, TreeRow[]> {
|
|
52
|
+
const ids = new Set(rows.map((r) => r.entryId));
|
|
53
|
+
const children = new Map<string | undefined, TreeRow[]>();
|
|
54
|
+
for (const r of rows) {
|
|
55
|
+
const key = r.parentId !== undefined && ids.has(r.parentId) ? r.parentId : undefined;
|
|
56
|
+
const list = children.get(key);
|
|
57
|
+
if (list) list.push(r);
|
|
58
|
+
else children.set(key, [r]);
|
|
59
|
+
}
|
|
60
|
+
return children;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Rows that start a branch segment: children of a node with several children, or the roots when there are several. */
|
|
64
|
+
export function forkChildIds(rows: TreeRow[], children = treeChildren(rows)): Set<string> {
|
|
65
|
+
const out = new Set<string>();
|
|
66
|
+
for (const kids of children.values()) {
|
|
67
|
+
if (kids.length > 1) for (const k of kids) out.add(k.entryId);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Segment starts that have descendants — the rows `z` can fold. */
|
|
73
|
+
export function foldableIds(rows: TreeRow[]): Set<string> {
|
|
74
|
+
const children = treeChildren(rows);
|
|
75
|
+
const out = new Set<string>();
|
|
76
|
+
for (const id of forkChildIds(rows, children)) {
|
|
77
|
+
if (children.get(id)?.length) out.add(id);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Fold state a freshly loaded tree opens with: every foldable row off the active branch. */
|
|
83
|
+
export function defaultFolded(rows: TreeRow[]): Set<string> {
|
|
84
|
+
const foldable = foldableIds(rows);
|
|
85
|
+
const out = new Set<string>();
|
|
86
|
+
for (const r of rows) {
|
|
87
|
+
if (foldable.has(r.entryId) && !r.onActiveBranch) out.add(r.entryId);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Rows still shown once the descendants of every folded row are hidden. Ids in
|
|
94
|
+
* `folded` that are not foldable (stale after a reload) are ignored.
|
|
95
|
+
*/
|
|
96
|
+
export function applyTreeFold(rows: TreeRow[], folded: ReadonlySet<string>): TreeRow[] {
|
|
97
|
+
if (folded.size === 0) return rows;
|
|
98
|
+
const foldable = foldableIds(rows);
|
|
99
|
+
const hidden = new Set<string>();
|
|
100
|
+
const out: TreeRow[] = [];
|
|
101
|
+
// rows 是先序的:父节点先出现,所以看父节点是否被折叠 / 已隐藏就能一遍算完。
|
|
102
|
+
for (const r of rows) {
|
|
103
|
+
const parent = r.parentId;
|
|
104
|
+
if (parent !== undefined && (hidden.has(parent) || (folded.has(parent) && foldable.has(parent)))) {
|
|
105
|
+
hidden.add(r.entryId);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
out.push(r);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Row `z` acts on when the cursor is on `entryId`: the row itself when it is
|
|
115
|
+
* foldable, otherwise the nearest foldable ancestor (the head of the segment
|
|
116
|
+
* the row is in); `undefined` on the trunk of a single-root tree, where nothing
|
|
117
|
+
* folds.
|
|
118
|
+
*/
|
|
119
|
+
export function foldTarget(rows: TreeRow[], entryId: string): string | undefined {
|
|
120
|
+
const foldable = foldableIds(rows);
|
|
121
|
+
const parents = new Map(rows.map((r) => [r.entryId, r.parentId]));
|
|
122
|
+
let id: string | undefined = entryId;
|
|
123
|
+
while (id !== undefined && parents.has(id)) {
|
|
124
|
+
if (foldable.has(id)) return id;
|
|
125
|
+
id = parents.get(id);
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Index in `rows` of `entryId`, or of its nearest ancestor (walking `tree`,
|
|
132
|
+
* the full pre-order rows) that is still listed, or the last row when nothing
|
|
133
|
+
* on its path is (pi's `findNearestVisibleIndex`). Used to keep the dialog
|
|
134
|
+
* cursor sensible when a search / filter / fold hides the row it was on.
|
|
135
|
+
*/
|
|
136
|
+
export function nearestListedIndex(rows: TreeRow[], tree: TreeRow[], entryId: string | undefined): number {
|
|
137
|
+
if (rows.length === 0) return 0;
|
|
138
|
+
const index = new Map(rows.map((r, i) => [r.entryId, i]));
|
|
139
|
+
const parents = new Map(tree.map((r) => [r.entryId, r.parentId]));
|
|
140
|
+
let id = entryId;
|
|
141
|
+
while (id !== undefined) {
|
|
142
|
+
const i = index.get(id);
|
|
143
|
+
if (i !== undefined) return i;
|
|
144
|
+
id = parents.get(id);
|
|
145
|
+
}
|
|
146
|
+
return rows.length - 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Folded ancestors of `entryId` in `rows` (the full tree): unfolding these
|
|
151
|
+
* makes the row visible again, e.g. when the tree dialog closes on a row the
|
|
152
|
+
* pane keeps inside a folded branch.
|
|
153
|
+
*/
|
|
154
|
+
export function foldedAncestors(rows: TreeRow[], entryId: string, folded: ReadonlySet<string>): string[] {
|
|
155
|
+
const parents = new Map(rows.map((r) => [r.entryId, r.parentId]));
|
|
156
|
+
const out: string[] = [];
|
|
157
|
+
let id = parents.get(entryId);
|
|
158
|
+
while (id !== undefined) {
|
|
159
|
+
if (folded.has(id)) out.push(id);
|
|
160
|
+
id = parents.get(id);
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|