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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -0
  3. package/README.zh.md +104 -0
  4. package/docs/design.md +235 -0
  5. package/docs/keybindings.md +172 -0
  6. package/docs/keybindings.zh.md +172 -0
  7. package/package.json +67 -0
  8. package/src/actions/session-actions.ts +585 -0
  9. package/src/actions/tree-actions.ts +161 -0
  10. package/src/config/config.ts +155 -0
  11. package/src/config/keymap.ts +219 -0
  12. package/src/config/keys.ts +279 -0
  13. package/src/config/pi-settings.ts +54 -0
  14. package/src/constants.ts +48 -0
  15. package/src/data/changelog.ts +63 -0
  16. package/src/data/content.ts +178 -0
  17. package/src/data/search.ts +183 -0
  18. package/src/data/sessions.ts +148 -0
  19. package/src/data/tree-fold.ts +163 -0
  20. package/src/data/tree.ts +258 -0
  21. package/src/i18n/index.ts +103 -0
  22. package/src/i18n/locales/en.json +341 -0
  23. package/src/i18n/locales/zh.json +341 -0
  24. package/src/index.ts +155 -0
  25. package/src/types.ts +346 -0
  26. package/src/ui/app.ts +2892 -0
  27. package/src/ui/frame.ts +116 -0
  28. package/src/ui/mouse-input.ts +197 -0
  29. package/src/ui/mouse.ts +99 -0
  30. package/src/ui/panes/content-pane.ts +132 -0
  31. package/src/ui/panes/sessions-pane.ts +161 -0
  32. package/src/ui/panes/tree-pane.ts +152 -0
  33. package/src/ui/search-highlight.ts +108 -0
  34. package/src/ui/tree-lines.ts +111 -0
  35. package/src/ui/tree-outline.ts +80 -0
  36. package/src/ui/widgets/changelog-dialog.ts +196 -0
  37. package/src/ui/widgets/compact-dialog.ts +29 -0
  38. package/src/ui/widgets/confirm-dialog.ts +98 -0
  39. package/src/ui/widgets/export-dialog.ts +62 -0
  40. package/src/ui/widgets/footer.ts +117 -0
  41. package/src/ui/widgets/fork-dialog.ts +28 -0
  42. package/src/ui/widgets/help-overlay.ts +220 -0
  43. package/src/ui/widgets/import-dialog.ts +32 -0
  44. package/src/ui/widgets/input-dialog.ts +136 -0
  45. package/src/ui/widgets/label-dialog.ts +28 -0
  46. package/src/ui/widgets/new-session-dialog.ts +29 -0
  47. package/src/ui/widgets/prompt-bar.ts +92 -0
  48. package/src/ui/widgets/rename-dialog.ts +29 -0
  49. package/src/ui/widgets/restore-dialog.ts +60 -0
  50. package/src/ui/widgets/search-bar.ts +65 -0
  51. package/src/ui/widgets/select-dialog.ts +193 -0
  52. package/src/ui/widgets/session-info-dialog.ts +189 -0
  53. package/src/ui/widgets/tree-dialog.ts +293 -0
  54. package/src/utils/format.ts +83 -0
  55. 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
+ }