@adeildo/pi-kit 4.0.0 → 5.0.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 +1 -1
- package/README.md +42 -5
- package/package.json +4 -3
- package/src/app/builder.ts +30 -6
- package/src/app/feature.ts +51 -0
- package/src/app/host.ts +212 -0
- package/src/app/scope.ts +26 -1
- package/src/ask/client.ts +47 -0
- package/src/contracts/ask.ts +79 -0
- package/src/contracts/screen.ts +71 -0
- package/src/control.ts +59 -0
- package/src/decode.ts +19 -1
- package/src/index.ts +10 -1
- package/src/screen/client.ts +75 -0
- package/src/screen/feature.ts +61 -0
- package/src/screen/model.ts +173 -0
- package/src/screen/view.ts +771 -0
- package/src/settings/files.ts +23 -2
- package/src/settings/setting.ts +19 -2
- package/src/settings/store.ts +91 -21
- package/src/testing.ts +31 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// In the harness every piece is an extension of its own, so the screen asks them over pi.events.
|
|
2
|
+
// The bus is synchronous: an app answers by writing on the payload, like the claim.
|
|
3
|
+
import type { Control, Json } from "../control.ts";
|
|
4
|
+
import type { Layer } from "../settings/store.ts";
|
|
5
|
+
|
|
6
|
+
export const LIST = "harness:screen:list";
|
|
7
|
+
export const APPLY = "harness:screen:apply";
|
|
8
|
+
export const RUN = "harness:screen:run";
|
|
9
|
+
export const DONE = "harness:screen:done";
|
|
10
|
+
export const CHANGED = "harness:settings:changed";
|
|
11
|
+
|
|
12
|
+
export type RowKind = "setting" | "value" | "action" | "info";
|
|
13
|
+
|
|
14
|
+
export interface RowView {
|
|
15
|
+
feature: string;
|
|
16
|
+
id: string;
|
|
17
|
+
kind: RowKind;
|
|
18
|
+
section: string;
|
|
19
|
+
label: string;
|
|
20
|
+
description: string;
|
|
21
|
+
control?: Control;
|
|
22
|
+
value?: Json;
|
|
23
|
+
text?: string;
|
|
24
|
+
confirm?: string;
|
|
25
|
+
/** How deep the row sits under its section. Each step indents two columns. */
|
|
26
|
+
indent?: number;
|
|
27
|
+
/** Where a value row applies, when that is not this session. */
|
|
28
|
+
meta?: string;
|
|
29
|
+
layer?: Layer;
|
|
30
|
+
fallback?: Json;
|
|
31
|
+
/** The global value a project value hides. */
|
|
32
|
+
hidden?: Json;
|
|
33
|
+
restart?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TabView {
|
|
37
|
+
title: string;
|
|
38
|
+
sections: string[];
|
|
39
|
+
rows: RowView[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ListRequest {
|
|
43
|
+
tabs: TabView[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `unset` drops the value that wins, so the one below shows. */
|
|
47
|
+
export interface ApplyRequest {
|
|
48
|
+
feature: string;
|
|
49
|
+
id: string;
|
|
50
|
+
op: "set" | "unset";
|
|
51
|
+
value?: Json;
|
|
52
|
+
answer?: { error?: string };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RunRequest {
|
|
56
|
+
feature: string;
|
|
57
|
+
id: string;
|
|
58
|
+
request: string;
|
|
59
|
+
answer?: { error?: string };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface RunDone {
|
|
63
|
+
request: string;
|
|
64
|
+
text?: string;
|
|
65
|
+
error?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface Changed {
|
|
69
|
+
/** The store that wrote, so it skips its own write. */
|
|
70
|
+
source: string;
|
|
71
|
+
}
|
package/src/control.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// How the settings screen edits a value. Plain JSON, because the screen may live in another package.
|
|
2
|
+
export type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
|
|
3
|
+
|
|
4
|
+
export interface ControlOption {
|
|
5
|
+
value: Json;
|
|
6
|
+
label?: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type Control =
|
|
11
|
+
| { type: "toggle" }
|
|
12
|
+
| { type: "choice"; options: ControlOption[]; custom?: boolean }
|
|
13
|
+
| { type: "number"; min?: number; max?: number; step?: number; unit?: string; nullable?: boolean }
|
|
14
|
+
| { type: "text"; multiline?: boolean; presets?: ControlOption[] }
|
|
15
|
+
| { type: "list"; options?: ControlOption[] };
|
|
16
|
+
|
|
17
|
+
export function formatValue(control: Control, value: Json | undefined): string {
|
|
18
|
+
if (value === undefined || value === null) return control.type === "number" ? "none" : "";
|
|
19
|
+
switch (control.type) {
|
|
20
|
+
case "toggle":
|
|
21
|
+
return value === true ? "on" : "off";
|
|
22
|
+
case "choice":
|
|
23
|
+
return optionLabel(control.options, value);
|
|
24
|
+
case "number":
|
|
25
|
+
return control.unit === undefined ? String(value) : `${value}${control.unit}`;
|
|
26
|
+
case "text":
|
|
27
|
+
if (typeof value !== "string") return JSON.stringify(value);
|
|
28
|
+
if (control.presets !== undefined) {
|
|
29
|
+
const preset = control.presets.find((option) => option.value === value);
|
|
30
|
+
if (preset !== undefined) return preset.label ?? String(preset.value);
|
|
31
|
+
if (value.trim() === "") return "(empty)";
|
|
32
|
+
return "custom";
|
|
33
|
+
}
|
|
34
|
+
return firstLine(value);
|
|
35
|
+
case "list":
|
|
36
|
+
if (!Array.isArray(value)) return JSON.stringify(value);
|
|
37
|
+
if (value.length === 0) return "(none)";
|
|
38
|
+
return value.map((entry) => optionLabel(control.options ?? [], entry)).join(", ");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function optionLabel(options: readonly ControlOption[], value: Json): string {
|
|
43
|
+
const option = options.find((entry) => sameJson(entry.value, value));
|
|
44
|
+
return optionText(option ?? { value });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function optionText(option: ControlOption): string {
|
|
48
|
+
if (option.label !== undefined) return option.label;
|
|
49
|
+
return typeof option.value === "string" ? option.value : JSON.stringify(option.value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function sameJson(left: unknown, right: unknown): boolean {
|
|
53
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function firstLine(text: string): string {
|
|
57
|
+
const line = text.split("\n")[0] ?? "";
|
|
58
|
+
return text.includes("\n") ? `${line} …` : line;
|
|
59
|
+
}
|
package/src/decode.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// Decoders for everything that comes from outside: settings files and event payloads from other
|
|
2
|
-
// packages. The only module that inspects `typeof`, and no decoder throws.
|
|
2
|
+
// packages. The only module that inspects `typeof`, and no decoder throws. `control` is how the
|
|
3
|
+
// settings screen edits the value.
|
|
4
|
+
import type { Control } from "./control.ts";
|
|
5
|
+
|
|
3
6
|
export interface Problem {
|
|
4
7
|
path: string;
|
|
5
8
|
message: string;
|
|
@@ -11,6 +14,7 @@ export type Decoded<T> =
|
|
|
11
14
|
|
|
12
15
|
export interface Decoder<T> {
|
|
13
16
|
decode(input: unknown, path: string): Decoded<T>;
|
|
17
|
+
control?: Control;
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
export function pass<T>(value: T, problems: Problem[] = []): Decoded<T> {
|
|
@@ -38,12 +42,14 @@ export function isObject(input: unknown): input is Record<string, unknown> {
|
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
export const string: Decoder<string> = {
|
|
45
|
+
control: { type: "text" },
|
|
41
46
|
decode(input, path) {
|
|
42
47
|
return typeof input === "string" ? pass(input) : fail(problem(path, "expected a string"));
|
|
43
48
|
},
|
|
44
49
|
};
|
|
45
50
|
|
|
46
51
|
export const trimmedString: Decoder<string> = {
|
|
52
|
+
control: { type: "text" },
|
|
47
53
|
decode(input, path) {
|
|
48
54
|
if (typeof input !== "string" || input.trim() === "")
|
|
49
55
|
return fail(problem(path, "expected a non-empty string"));
|
|
@@ -52,12 +58,14 @@ export const trimmedString: Decoder<string> = {
|
|
|
52
58
|
};
|
|
53
59
|
|
|
54
60
|
export const boolean: Decoder<boolean> = {
|
|
61
|
+
control: { type: "toggle" },
|
|
55
62
|
decode(input, path) {
|
|
56
63
|
return typeof input === "boolean" ? pass(input) : fail(problem(path, "expected a boolean"));
|
|
57
64
|
},
|
|
58
65
|
};
|
|
59
66
|
|
|
60
67
|
export const unit: Decoder<number> = {
|
|
68
|
+
control: { type: "number", min: 0, max: 1, step: 0.05 },
|
|
61
69
|
decode(input, path) {
|
|
62
70
|
if (typeof input !== "number" || !Number.isFinite(input) || input < 0 || input > 1)
|
|
63
71
|
return fail(problem(path, "expected a number from 0 to 1"));
|
|
@@ -66,6 +74,7 @@ export const unit: Decoder<number> = {
|
|
|
66
74
|
};
|
|
67
75
|
|
|
68
76
|
export const duration: Decoder<number> = {
|
|
77
|
+
control: { type: "number", min: 0, step: 500, unit: "ms" },
|
|
69
78
|
decode(input, path) {
|
|
70
79
|
if (typeof input !== "number" || !Number.isFinite(input) || input < 0)
|
|
71
80
|
return fail(problem(path, "expected a non-negative number of milliseconds"));
|
|
@@ -76,6 +85,7 @@ export const duration: Decoder<number> = {
|
|
|
76
85
|
/** A whole number from `min` to `max`, inclusive. */
|
|
77
86
|
export function integer(min: number, max: number): Decoder<number> {
|
|
78
87
|
return {
|
|
88
|
+
control: { type: "number", min, max, step: 1 },
|
|
79
89
|
decode(input, path) {
|
|
80
90
|
if (typeof input !== "number" || !Number.isInteger(input) || input < min || input > max) {
|
|
81
91
|
return fail(problem(path, `expected a whole number from ${min} to ${max}`));
|
|
@@ -88,6 +98,7 @@ export function integer(min: number, max: number): Decoder<number> {
|
|
|
88
98
|
/** A string that matches `pattern`, described to the user as `expected`. */
|
|
89
99
|
export function matching(pattern: RegExp, expected: string): Decoder<string> {
|
|
90
100
|
return {
|
|
101
|
+
control: { type: "text" },
|
|
91
102
|
decode(input, path) {
|
|
92
103
|
if (typeof input === "string" && pattern.test(input)) return pass(input);
|
|
93
104
|
return fail(problem(path, `expected ${expected}`));
|
|
@@ -98,6 +109,7 @@ export function matching(pattern: RegExp, expected: string): Decoder<string> {
|
|
|
98
109
|
export function literal<const T extends readonly string[]>(...values: T): Decoder<T[number]> {
|
|
99
110
|
const expected = values.map((value) => `"${value}"`).join(" or ");
|
|
100
111
|
return {
|
|
112
|
+
control: { type: "choice", options: values.map((value) => ({ value })) },
|
|
101
113
|
decode(input, path) {
|
|
102
114
|
if (typeof input === "string" && (values as readonly string[]).includes(input))
|
|
103
115
|
return pass(input as T[number]);
|
|
@@ -107,7 +119,10 @@ export function literal<const T extends readonly string[]>(...values: T): Decode
|
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
export function nullable<T>(inner: Decoder<T>): Decoder<T | null> {
|
|
122
|
+
const control: Control | undefined =
|
|
123
|
+
inner.control?.type === "number" ? { ...inner.control, nullable: true } : inner.control;
|
|
110
124
|
return {
|
|
125
|
+
control,
|
|
111
126
|
decode(input, path) {
|
|
112
127
|
return input === null ? pass(null) : inner.decode(input, path);
|
|
113
128
|
},
|
|
@@ -116,6 +131,7 @@ export function nullable<T>(inner: Decoder<T>): Decoder<T | null> {
|
|
|
116
131
|
|
|
117
132
|
export function withDefaultOf<T>(inner: Decoder<T>, fallback: () => T): Decoder<T> {
|
|
118
133
|
return {
|
|
134
|
+
control: inner.control,
|
|
119
135
|
decode(input, path) {
|
|
120
136
|
if (input === undefined) return pass(fallback());
|
|
121
137
|
const result = inner.decode(input, path);
|
|
@@ -133,6 +149,7 @@ export function stringList(expected: string): Decoder<string[]> {
|
|
|
133
149
|
const dropped = "ignored entries that are not non-empty strings";
|
|
134
150
|
|
|
135
151
|
return {
|
|
152
|
+
control: { type: "list" },
|
|
136
153
|
decode(input, path) {
|
|
137
154
|
if (!Array.isArray(input)) return fail(problem(path, notAList));
|
|
138
155
|
|
|
@@ -155,6 +172,7 @@ export function stringListOrEmpty(
|
|
|
155
172
|
): Decoder<string[]> {
|
|
156
173
|
const parse = stringList(expected);
|
|
157
174
|
return {
|
|
175
|
+
control: parse.control,
|
|
158
176
|
decode(input, path) {
|
|
159
177
|
if (input === undefined) return pass([...fallback]);
|
|
160
178
|
const result = parse.decode(input, path);
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
|
+
export { askQuestions, canAsk } from "./ask/client.ts";
|
|
1
2
|
export * from "./app/builder.ts";
|
|
2
3
|
export * from "./app/feature.ts";
|
|
4
|
+
export * from "./contracts/ask.ts";
|
|
5
|
+
export * from "./contracts/screen.ts";
|
|
6
|
+
export * from "./control.ts";
|
|
3
7
|
export * from "./decode.ts";
|
|
4
8
|
export * from "./events.ts";
|
|
5
|
-
export {
|
|
9
|
+
export {
|
|
10
|
+
globalSettingsPath,
|
|
11
|
+
projectSettingsPath,
|
|
12
|
+
readSettingsFile,
|
|
13
|
+
writeSettingsFile,
|
|
14
|
+
} from "./settings/files.ts";
|
|
6
15
|
export * from "./settings/setting.ts";
|
|
7
16
|
export * from "./settings/store.ts";
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
APPLY,
|
|
5
|
+
type ApplyRequest,
|
|
6
|
+
DONE,
|
|
7
|
+
LIST,
|
|
8
|
+
type ListRequest,
|
|
9
|
+
type RowView,
|
|
10
|
+
RUN,
|
|
11
|
+
type RunDone,
|
|
12
|
+
type RunRequest,
|
|
13
|
+
type TabView,
|
|
14
|
+
} from "../contracts/screen.ts";
|
|
15
|
+
import type { Json } from "../control.ts";
|
|
16
|
+
import { isObject } from "../decode.ts";
|
|
17
|
+
|
|
18
|
+
type Events = ExtensionAPI["events"];
|
|
19
|
+
|
|
20
|
+
/** Tabs with the same title, from features of different packages, become one. */
|
|
21
|
+
export function listTabs(events: Events): TabView[] {
|
|
22
|
+
const request: ListRequest = { tabs: [] };
|
|
23
|
+
events.emit(LIST, request);
|
|
24
|
+
|
|
25
|
+
const merged: TabView[] = [];
|
|
26
|
+
for (const tab of request.tabs) {
|
|
27
|
+
if (tab.rows.length === 0) continue;
|
|
28
|
+
const same = merged.find((entry) => entry.title === tab.title);
|
|
29
|
+
if (same === undefined) {
|
|
30
|
+
merged.push({ title: tab.title, sections: [...tab.sections], rows: [...tab.rows] });
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
for (const section of tab.sections)
|
|
34
|
+
if (!same.sections.includes(section)) same.sections.push(section);
|
|
35
|
+
same.rows.push(...tab.rows);
|
|
36
|
+
}
|
|
37
|
+
return merged;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Returns why the change was refused. */
|
|
41
|
+
export function applyRow(
|
|
42
|
+
events: Events,
|
|
43
|
+
row: RowView,
|
|
44
|
+
op: ApplyRequest["op"],
|
|
45
|
+
value?: Json,
|
|
46
|
+
): string | undefined {
|
|
47
|
+
const request: ApplyRequest = { feature: row.feature, id: row.id, op };
|
|
48
|
+
if (value !== undefined) request.value = value;
|
|
49
|
+
events.emit(APPLY, request);
|
|
50
|
+
if (request.answer === undefined) return `${row.feature} is not running`;
|
|
51
|
+
return request.answer.error;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function runRow(events: Events, row: RowView): Promise<Omit<RunDone, "request">> {
|
|
55
|
+
const id = crypto.randomUUID();
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
const stop = events.on(DONE, (data) => {
|
|
58
|
+
if (!isObject(data) || data.request !== id) return;
|
|
59
|
+
stop();
|
|
60
|
+
resolve({
|
|
61
|
+
...(typeof data.text === "string" ? { text: data.text } : {}),
|
|
62
|
+
...(typeof data.error === "string" ? { error: data.error } : {}),
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const request: RunRequest = { feature: row.feature, id: row.id, request: id };
|
|
67
|
+
events.emit(RUN, request);
|
|
68
|
+
const refused =
|
|
69
|
+
request.answer === undefined ? `${row.feature} is not running` : request.answer.error;
|
|
70
|
+
if (refused !== undefined) {
|
|
71
|
+
stop();
|
|
72
|
+
resolve({ error: refused });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Every app brings this feature, and the claim keeps the first copy.
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Key } from "@earendil-works/pi-tui";
|
|
4
|
+
|
|
5
|
+
import type { Feature, FeatureScope } from "../app/feature.ts";
|
|
6
|
+
import { formatValue } from "../control.ts";
|
|
7
|
+
import { applyRow, listTabs } from "./client.ts";
|
|
8
|
+
import { ScreenModel } from "./model.ts";
|
|
9
|
+
import { type ScreenResult, ScreenView } from "./view.ts";
|
|
10
|
+
|
|
11
|
+
export const settingsScreen: Feature = {
|
|
12
|
+
id: "settings-screen",
|
|
13
|
+
description: "One screen for every setting of the harness",
|
|
14
|
+
setup(scope) {
|
|
15
|
+
scope.registerCommand("harness", {
|
|
16
|
+
description: "Settings for every harness piece (also Alt+S)",
|
|
17
|
+
getArgumentCompletions: (prefix) => {
|
|
18
|
+
const typed = prefix.trim().toLowerCase();
|
|
19
|
+
const tabs = listTabs(scope.events).map((tab) => tab.title.toLowerCase());
|
|
20
|
+
const matches = tabs.filter((tab) => tab.startsWith(typed));
|
|
21
|
+
return matches.length === 0 ? null : matches.map((tab) => ({ value: tab, label: tab }));
|
|
22
|
+
},
|
|
23
|
+
handler: (args, ctx) => openScreen(scope, ctx, args),
|
|
24
|
+
});
|
|
25
|
+
scope.registerShortcut(Key.alt("s"), {
|
|
26
|
+
description: "Settings for every harness piece",
|
|
27
|
+
handler: (ctx) => openScreen(scope, ctx, ""),
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
async function openScreen(scope: FeatureScope, ctx: ExtensionContext, tab: string): Promise<void> {
|
|
33
|
+
if (ctx.mode !== "tui") {
|
|
34
|
+
ctx.ui.notify("the settings screen needs the interactive terminal", "warning");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const model = new ScreenModel(listTabs(scope.events));
|
|
38
|
+
if (tab.trim() !== "") model.openTab(tab);
|
|
39
|
+
|
|
40
|
+
// The editor for long text is pi's own, so the screen closes for it and opens where it was.
|
|
41
|
+
for (;;) {
|
|
42
|
+
// oxlint-disable-next-line no-await-in-loop -- one screen at a time
|
|
43
|
+
const result = await ctx.ui.custom<ScreenResult>(
|
|
44
|
+
(tui, theme, _keybindings, done) =>
|
|
45
|
+
new ScreenView({ tui, theme, events: scope.events, model, done }),
|
|
46
|
+
{ overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left" } },
|
|
47
|
+
);
|
|
48
|
+
if (result?.kind !== "edit") return;
|
|
49
|
+
|
|
50
|
+
const row = result.row;
|
|
51
|
+
const current = typeof row.value === "string" ? row.value : "";
|
|
52
|
+
// oxlint-disable-next-line no-await-in-loop -- the screen waits for the editor
|
|
53
|
+
const text = await ctx.ui.editor(row.label, current);
|
|
54
|
+
if (text !== undefined && text !== current) {
|
|
55
|
+
const error = applyRow(scope.events, row, "set", text);
|
|
56
|
+
const shown = row.control === undefined ? "" : formatValue(row.control, text);
|
|
57
|
+
ctx.ui.notify(error ?? `${row.label}: ${shown}`, error === undefined ? "info" : "error");
|
|
58
|
+
}
|
|
59
|
+
model.refresh(listTabs(scope.events));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { fuzzyFilter } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
import type { RowView, TabView } from "../contracts/screen.ts";
|
|
4
|
+
|
|
5
|
+
export type Line = { kind: "heading"; title: string } | { kind: "row"; row: RowView; tab: string };
|
|
6
|
+
|
|
7
|
+
export interface SectionMark {
|
|
8
|
+
title: string;
|
|
9
|
+
active: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class ScreenModel {
|
|
13
|
+
tabs: TabView[];
|
|
14
|
+
tab = 0;
|
|
15
|
+
query = "";
|
|
16
|
+
#cursor: string | undefined;
|
|
17
|
+
|
|
18
|
+
constructor(tabs: TabView[]) {
|
|
19
|
+
this.tabs = tabs;
|
|
20
|
+
this.#cursor = this.#firstKey();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get searching(): boolean {
|
|
24
|
+
return this.query !== "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Keeps the tab and the row under the cursor. */
|
|
28
|
+
refresh(tabs: TabView[]): void {
|
|
29
|
+
const title = this.tabs[this.tab]?.title;
|
|
30
|
+
this.tabs = tabs;
|
|
31
|
+
const index = tabs.findIndex((entry) => entry.title === title);
|
|
32
|
+
this.tab = index === -1 ? 0 : index;
|
|
33
|
+
if (this.#index() === -1) this.#cursor = this.#firstKey();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
lines(): Line[] {
|
|
37
|
+
return this.searching ? this.#results() : this.#tabLines();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
selected(): Extract<Line, { kind: "row" }> | undefined {
|
|
41
|
+
const line = this.lines()[this.#index()];
|
|
42
|
+
return line?.kind === "row" ? line : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
cursorLine(): number {
|
|
46
|
+
return this.#index();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
move(delta: number): void {
|
|
50
|
+
const rows = this.#rowIndexes();
|
|
51
|
+
if (rows.length === 0) return;
|
|
52
|
+
const at = rows.indexOf(this.#index());
|
|
53
|
+
const next = Math.min(rows.length - 1, Math.max(0, (at === -1 ? 0 : at) + delta));
|
|
54
|
+
this.#select(rows[next]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
moveToEdge(edge: "first" | "last"): void {
|
|
58
|
+
const rows = this.#rowIndexes();
|
|
59
|
+
this.#select(edge === "first" ? rows[0] : rows.at(-1));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
jumpSection(direction: 1 | -1): void {
|
|
63
|
+
const lines = this.lines();
|
|
64
|
+
const starts: number[] = [];
|
|
65
|
+
lines.forEach((line, index) => {
|
|
66
|
+
if (line.kind === "heading" && lines[index + 1]?.kind === "row") starts.push(index + 1);
|
|
67
|
+
});
|
|
68
|
+
if (starts.length === 0) return;
|
|
69
|
+
|
|
70
|
+
const at = this.#index();
|
|
71
|
+
const current = starts.findLastIndex((start) => start <= at);
|
|
72
|
+
const next = (current + direction + starts.length) % starts.length;
|
|
73
|
+
this.#select(starts[next]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
switchTab(delta: number): void {
|
|
77
|
+
if (this.searching || this.tabs.length === 0) return;
|
|
78
|
+
this.tab = (this.tab + delta + this.tabs.length) % this.tabs.length;
|
|
79
|
+
this.#cursor = this.#firstKey();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
openTab(title: string): boolean {
|
|
83
|
+
const wanted = title.trim().toLowerCase();
|
|
84
|
+
const index = this.tabs.findIndex((entry) => entry.title.toLowerCase().startsWith(wanted));
|
|
85
|
+
if (index === -1) return false;
|
|
86
|
+
this.tab = index;
|
|
87
|
+
this.#cursor = this.#firstKey();
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
setQuery(query: string): void {
|
|
92
|
+
this.query = query;
|
|
93
|
+
this.#cursor = this.#firstKey();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
sections(): SectionMark[] {
|
|
97
|
+
const lines = this.lines();
|
|
98
|
+
const at = this.#index();
|
|
99
|
+
let active = "";
|
|
100
|
+
lines.forEach((line, index) => {
|
|
101
|
+
if (line.kind === "heading" && index <= at) active = line.title;
|
|
102
|
+
});
|
|
103
|
+
return lines
|
|
104
|
+
.filter((line): line is Extract<Line, { kind: "heading" }> => line.kind === "heading")
|
|
105
|
+
.map((line) => ({ title: line.title, active: line.title === active }));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#tabLines(): Line[] {
|
|
109
|
+
const tab = this.tabs[this.tab];
|
|
110
|
+
if (tab === undefined) return [];
|
|
111
|
+
const lines: Line[] = [];
|
|
112
|
+
for (const section of tab.sections) {
|
|
113
|
+
const rows = tab.rows.filter((row) => row.section === section);
|
|
114
|
+
if (rows.length === 0) continue;
|
|
115
|
+
lines.push({ kind: "heading", title: section });
|
|
116
|
+
for (const row of rows) lines.push({ kind: "row", row, tab: tab.title });
|
|
117
|
+
}
|
|
118
|
+
return lines;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#results(): Line[] {
|
|
122
|
+
const all = this.tabs.flatMap((tab) => tab.rows.map((row) => ({ row, tab: tab.title })));
|
|
123
|
+
// The query picks the rows. The order stays the tab's.
|
|
124
|
+
const matched = new Set(
|
|
125
|
+
fuzzyFilter(all, this.query, ({ row, tab }) =>
|
|
126
|
+
[row.label, row.section, tab, row.id].join(" "),
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
const found = all.filter((hit) => matched.has(hit));
|
|
130
|
+
|
|
131
|
+
const groups = new Map<string, typeof found>();
|
|
132
|
+
for (const hit of found) {
|
|
133
|
+
const title = `${hit.tab} \u203a ${hit.row.section}`;
|
|
134
|
+
groups.set(title, [...(groups.get(title) ?? []), hit]);
|
|
135
|
+
}
|
|
136
|
+
const lines: Line[] = [];
|
|
137
|
+
for (const [title, hits] of groups) {
|
|
138
|
+
lines.push({ kind: "heading", title });
|
|
139
|
+
for (const hit of hits) lines.push({ kind: "row", ...hit });
|
|
140
|
+
}
|
|
141
|
+
return lines;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#rowIndexes(): number[] {
|
|
145
|
+
const indexes: number[] = [];
|
|
146
|
+
this.lines().forEach((line, index) => {
|
|
147
|
+
if (line.kind === "row") indexes.push(index);
|
|
148
|
+
});
|
|
149
|
+
return indexes;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#index(): number {
|
|
153
|
+
if (this.#cursor === undefined) return -1;
|
|
154
|
+
return this.lines().findIndex(
|
|
155
|
+
(line) => line.kind === "row" && keyOf(line.row) === this.#cursor,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#select(index: number | undefined): void {
|
|
160
|
+
if (index === undefined) return;
|
|
161
|
+
const line = this.lines()[index];
|
|
162
|
+
if (line?.kind === "row") this.#cursor = keyOf(line.row);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#firstKey(): string | undefined {
|
|
166
|
+
const line = this.lines().find((entry) => entry.kind === "row");
|
|
167
|
+
return line?.kind === "row" ? keyOf(line.row) : undefined;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function keyOf(row: RowView): string {
|
|
172
|
+
return `${row.feature}\u0000${row.id}`;
|
|
173
|
+
}
|