toolcraft-openapi 0.0.17 → 0.0.18
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/dist/bin/generate.js +7 -0
- package/dist/define-client.js +2 -2
- package/dist/generate.js +2 -2
- package/dist/http.d.ts +21 -2
- package/dist/http.js +147 -22
- package/dist/index.d.ts +1 -1
- package/dist/lock.d.ts +1 -1
- package/dist/lock.js +109 -5
- package/dist/mock/fetch.js +1 -1
- package/dist/network-error.d.ts +2 -0
- package/dist/network-error.js +83 -0
- package/dist/spec-source.js +103 -3
- package/node_modules/@poe-code/design-system/dist/components/help-formatter-plain.d.ts +1 -0
- package/node_modules/@poe-code/design-system/dist/components/help-formatter-plain.js +1 -1
- package/node_modules/@poe-code/design-system/dist/components/text.d.ts +1 -0
- package/node_modules/@poe-code/design-system/dist/components/text.js +8 -0
- package/node_modules/@poe-code/design-system/dist/dashboard/buffer.js +8 -1
- package/node_modules/@poe-code/design-system/dist/dashboard/keymap.d.ts +5 -0
- package/node_modules/@poe-code/design-system/dist/dashboard/keymap.js +146 -12
- package/node_modules/@poe-code/design-system/dist/dashboard/terminal.js +31 -0
- package/node_modules/@poe-code/design-system/dist/dashboard/types.d.ts +1 -0
- package/node_modules/@poe-code/design-system/dist/explorer/actions.d.ts +16 -0
- package/node_modules/@poe-code/design-system/dist/explorer/actions.js +39 -0
- package/node_modules/@poe-code/design-system/dist/explorer/demo.d.ts +13 -0
- package/node_modules/@poe-code/design-system/dist/explorer/demo.js +297 -0
- package/node_modules/@poe-code/design-system/dist/explorer/events.d.ts +61 -0
- package/node_modules/@poe-code/design-system/dist/explorer/events.js +1 -0
- package/node_modules/@poe-code/design-system/dist/explorer/filter.d.ts +10 -0
- package/node_modules/@poe-code/design-system/dist/explorer/filter.js +95 -0
- package/node_modules/@poe-code/design-system/dist/explorer/index.d.ts +8 -0
- package/node_modules/@poe-code/design-system/dist/explorer/index.js +8 -0
- package/node_modules/@poe-code/design-system/dist/explorer/jobs.d.ts +7 -0
- package/node_modules/@poe-code/design-system/dist/explorer/jobs.js +59 -0
- package/node_modules/@poe-code/design-system/dist/explorer/keymap.d.ts +21 -0
- package/node_modules/@poe-code/design-system/dist/explorer/keymap.js +363 -0
- package/node_modules/@poe-code/design-system/dist/explorer/layout.d.ts +20 -0
- package/node_modules/@poe-code/design-system/dist/explorer/layout.js +73 -0
- package/node_modules/@poe-code/design-system/dist/explorer/reducer.d.ts +9 -0
- package/node_modules/@poe-code/design-system/dist/explorer/reducer.js +704 -0
- package/node_modules/@poe-code/design-system/dist/explorer/render/detail.d.ts +4 -0
- package/node_modules/@poe-code/design-system/dist/explorer/render/detail.js +96 -0
- package/node_modules/@poe-code/design-system/dist/explorer/render/footer.d.ts +4 -0
- package/node_modules/@poe-code/design-system/dist/explorer/render/footer.js +49 -0
- package/node_modules/@poe-code/design-system/dist/explorer/render/header.d.ts +4 -0
- package/node_modules/@poe-code/design-system/dist/explorer/render/header.js +56 -0
- package/node_modules/@poe-code/design-system/dist/explorer/render/index.d.ts +8 -0
- package/node_modules/@poe-code/design-system/dist/explorer/render/index.js +61 -0
- package/node_modules/@poe-code/design-system/dist/explorer/render/list.d.ts +4 -0
- package/node_modules/@poe-code/design-system/dist/explorer/render/list.js +106 -0
- package/node_modules/@poe-code/design-system/dist/explorer/render/modal.d.ts +3 -0
- package/node_modules/@poe-code/design-system/dist/explorer/render/modal.js +91 -0
- package/node_modules/@poe-code/design-system/dist/explorer/render/test-fixtures.d.ts +8 -0
- package/node_modules/@poe-code/design-system/dist/explorer/render/test-fixtures.js +156 -0
- package/node_modules/@poe-code/design-system/dist/explorer/runtime.d.ts +2 -0
- package/node_modules/@poe-code/design-system/dist/explorer/runtime.js +282 -0
- package/node_modules/@poe-code/design-system/dist/explorer/runtime.test-helpers.d.ts +50 -0
- package/node_modules/@poe-code/design-system/dist/explorer/runtime.test-helpers.js +101 -0
- package/node_modules/@poe-code/design-system/dist/explorer/state.d.ts +130 -0
- package/node_modules/@poe-code/design-system/dist/explorer/state.js +87 -0
- package/node_modules/@poe-code/design-system/dist/explorer/theme.d.ts +27 -0
- package/node_modules/@poe-code/design-system/dist/explorer/theme.js +97 -0
- package/node_modules/@poe-code/design-system/dist/index.d.ts +3 -0
- package/node_modules/@poe-code/design-system/dist/index.js +3 -0
- package/node_modules/@poe-code/design-system/package.json +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { ScreenBuffer, cellToAnsi, diff } from "../dashboard/buffer.js";
|
|
2
|
+
import { createTerminalDriver } from "../dashboard/terminal.js";
|
|
3
|
+
import { createDetailJobs } from "./jobs.js";
|
|
4
|
+
import { computeExplorerLayout } from "./layout.js";
|
|
5
|
+
import { renderExplorer } from "./render/index.js";
|
|
6
|
+
import { step } from "./reducer.js";
|
|
7
|
+
import { createInitialState, REGION_ALL, REGION_MODAL, REGION_TOAST } from "./state.js";
|
|
8
|
+
const TOAST_MS = 2500;
|
|
9
|
+
export async function runExplorer(config) {
|
|
10
|
+
if (process.stdout.isTTY !== true) {
|
|
11
|
+
throw new Error("explorer requires a TTY");
|
|
12
|
+
}
|
|
13
|
+
const driver = createTerminalDriver();
|
|
14
|
+
const runtime = new ExplorerRuntime(config, driver);
|
|
15
|
+
return runtime.run();
|
|
16
|
+
}
|
|
17
|
+
class ExplorerRuntime {
|
|
18
|
+
config;
|
|
19
|
+
driver;
|
|
20
|
+
state;
|
|
21
|
+
previousBuffer = new ScreenBuffer(0, 0);
|
|
22
|
+
detailJobs;
|
|
23
|
+
runtimeHandles;
|
|
24
|
+
pendingEffects = new Set();
|
|
25
|
+
unsubscribeKeypress;
|
|
26
|
+
unsubscribeResize;
|
|
27
|
+
toastTimer;
|
|
28
|
+
stopped = false;
|
|
29
|
+
settle;
|
|
30
|
+
constructor(config, driver) {
|
|
31
|
+
this.config = config;
|
|
32
|
+
this.driver = driver;
|
|
33
|
+
this.state = createInitialState(config, driver.getSize());
|
|
34
|
+
this.detailJobs = createDetailJobs((event) => {
|
|
35
|
+
this.dispatch(event);
|
|
36
|
+
});
|
|
37
|
+
this.runtimeHandles = {
|
|
38
|
+
refresh: async () => {
|
|
39
|
+
await this.refreshRows();
|
|
40
|
+
},
|
|
41
|
+
suspendAnd: async (fn) => this.suspendAnd(fn),
|
|
42
|
+
toast: (msg, tone) => {
|
|
43
|
+
this.showToast(msg, tone);
|
|
44
|
+
},
|
|
45
|
+
confirm: async (prompt) => this.confirm(prompt),
|
|
46
|
+
exit: (after) => {
|
|
47
|
+
this.exit(null, after);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
run() {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
this.settle = { resolve, reject };
|
|
54
|
+
try {
|
|
55
|
+
this.startTerminal();
|
|
56
|
+
this.render();
|
|
57
|
+
this.refreshRows().catch((error) => {
|
|
58
|
+
this.fail(error);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
this.fail(error);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
startTerminal() {
|
|
67
|
+
this.driver.enterRawMode();
|
|
68
|
+
this.driver.enterAltScreen();
|
|
69
|
+
this.driver.disableLineWrap();
|
|
70
|
+
this.driver.hideCursor();
|
|
71
|
+
this.unsubscribeKeypress = this.driver.onKeypress((key) => {
|
|
72
|
+
this.dispatch({ type: "key", key });
|
|
73
|
+
});
|
|
74
|
+
this.unsubscribeResize = this.driver.onResize(() => {
|
|
75
|
+
const size = this.driver.getSize();
|
|
76
|
+
this.dispatch({ type: "resize", cols: size.cols, rows: size.rows });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async refreshRows() {
|
|
80
|
+
const rows = await this.config.rows();
|
|
81
|
+
this.dispatch({ type: "rowsLoaded", rows });
|
|
82
|
+
}
|
|
83
|
+
dispatch(event) {
|
|
84
|
+
if (this.stopped) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const previousState = this.state;
|
|
88
|
+
const next = step(this.state, event, this.runtimeHandles);
|
|
89
|
+
this.state = next.state;
|
|
90
|
+
this.render();
|
|
91
|
+
this.applyEffects(next.effects, previousState);
|
|
92
|
+
}
|
|
93
|
+
applyEffects(effects, previousState) {
|
|
94
|
+
for (const effect of effects) {
|
|
95
|
+
if (effect.type === "renderDetail") {
|
|
96
|
+
this.renderDetail(effect.rowId);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (effect.type === "persistOrder") {
|
|
100
|
+
this.track(this.persistOrder(effect.orderedIds, previousState.rows));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (effect.type === "suspend") {
|
|
104
|
+
this.track(this.runActionEffect(effect));
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (effect.type === "exit") {
|
|
108
|
+
this.exit(effect.result, effect.after);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
renderDetail(rowId) {
|
|
113
|
+
const row = this.state.rows.find((candidate) => candidate.id === rowId);
|
|
114
|
+
if (row === undefined) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const layout = computeExplorerLayout({
|
|
118
|
+
cols: this.state.size.cols,
|
|
119
|
+
rows: this.state.size.rows,
|
|
120
|
+
detailHidden: this.state.layout === "narrow-list-only" || this.state.layout === "too-narrow"
|
|
121
|
+
});
|
|
122
|
+
void this.detailJobs.schedule(rowId, (ctx) => this.config.detail.items(row, ctx), {
|
|
123
|
+
width: layout.detail.width,
|
|
124
|
+
height: layout.detail.height,
|
|
125
|
+
row,
|
|
126
|
+
signal: new AbortController().signal
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
async persistOrder(orderedIds, previousRows) {
|
|
130
|
+
try {
|
|
131
|
+
await this.config.reorder?.onReorder(orderedIds);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
this.showToast(error instanceof Error ? error.message : "Could not persist order", "error");
|
|
135
|
+
this.dispatch({ type: "rowsLoaded", rows: previousRows });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async runActionEffect(effect) {
|
|
139
|
+
try {
|
|
140
|
+
const value = await effect.fn();
|
|
141
|
+
this.dispatch(effect.resumeWith(value));
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
this.showToast(error instanceof Error ? error.message : "Action failed", "error");
|
|
145
|
+
this.dispatch(effect.resumeWith(error));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async suspendAnd(fn) {
|
|
149
|
+
this.driver.exitAltScreen();
|
|
150
|
+
this.driver.enableLineWrap();
|
|
151
|
+
this.driver.showCursor();
|
|
152
|
+
this.driver.exitRawMode();
|
|
153
|
+
try {
|
|
154
|
+
return await fn();
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
if (!this.stopped) {
|
|
158
|
+
this.driver.enterRawMode();
|
|
159
|
+
this.driver.enterAltScreen();
|
|
160
|
+
this.driver.disableLineWrap();
|
|
161
|
+
this.driver.hideCursor();
|
|
162
|
+
const size = this.driver.getSize();
|
|
163
|
+
this.dispatch({
|
|
164
|
+
type: "suspendResumed",
|
|
165
|
+
value: null,
|
|
166
|
+
emit: { type: "resize", cols: size.cols, rows: size.rows }
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
confirm(prompt) {
|
|
172
|
+
return new Promise((resolve) => {
|
|
173
|
+
const action = {
|
|
174
|
+
id: "__confirm__",
|
|
175
|
+
label: prompt,
|
|
176
|
+
handler: () => undefined
|
|
177
|
+
};
|
|
178
|
+
const row = this.currentRow();
|
|
179
|
+
this.state = {
|
|
180
|
+
...this.state,
|
|
181
|
+
modal: {
|
|
182
|
+
kind: "confirm",
|
|
183
|
+
action,
|
|
184
|
+
rows: row === undefined ? [] : [row],
|
|
185
|
+
resolver: resolve
|
|
186
|
+
},
|
|
187
|
+
dirty: REGION_MODAL
|
|
188
|
+
};
|
|
189
|
+
this.render();
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
showToast(message, tone = "info") {
|
|
193
|
+
if (this.stopped) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (this.toastTimer !== undefined) {
|
|
197
|
+
clearTimeout(this.toastTimer);
|
|
198
|
+
}
|
|
199
|
+
this.state = {
|
|
200
|
+
...this.state,
|
|
201
|
+
toast: { message, tone, expiresAt: Date.now() + TOAST_MS },
|
|
202
|
+
dirty: REGION_TOAST
|
|
203
|
+
};
|
|
204
|
+
this.render();
|
|
205
|
+
this.toastTimer = setTimeout(() => {
|
|
206
|
+
this.dispatch({ type: "toastExpired" });
|
|
207
|
+
}, TOAST_MS);
|
|
208
|
+
}
|
|
209
|
+
currentRow() {
|
|
210
|
+
return this.state.rows[this.state.filtered[this.state.cursor] ?? -1];
|
|
211
|
+
}
|
|
212
|
+
render() {
|
|
213
|
+
if (this.stopped) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const size = this.driver.getSize();
|
|
217
|
+
if (size.cols !== this.state.size.cols || size.rows !== this.state.size.rows) {
|
|
218
|
+
this.state = step(this.state, { type: "resize", cols: size.cols, rows: size.rows }, this.runtimeHandles).state;
|
|
219
|
+
}
|
|
220
|
+
const nextBuffer = this.state.dirty === REGION_ALL
|
|
221
|
+
? new ScreenBuffer(this.state.size.cols, this.state.size.rows)
|
|
222
|
+
: cloneBuffer(this.previousBuffer);
|
|
223
|
+
renderExplorer(this.state, nextBuffer);
|
|
224
|
+
this.driver.write(changesToAnsi(diff(this.previousBuffer, nextBuffer)));
|
|
225
|
+
this.previousBuffer = nextBuffer;
|
|
226
|
+
this.state = { ...this.state, dirty: 0 };
|
|
227
|
+
}
|
|
228
|
+
track(promise) {
|
|
229
|
+
this.pendingEffects.add(promise);
|
|
230
|
+
promise.finally(() => {
|
|
231
|
+
this.pendingEffects.delete(promise);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
exit(result, after) {
|
|
235
|
+
if (this.stopped) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
this.stopped = true;
|
|
239
|
+
this.unsubscribeKeypress?.();
|
|
240
|
+
this.unsubscribeResize?.();
|
|
241
|
+
this.detailJobs.abort();
|
|
242
|
+
if (this.toastTimer !== undefined) {
|
|
243
|
+
clearTimeout(this.toastTimer);
|
|
244
|
+
}
|
|
245
|
+
this.driver.destroy();
|
|
246
|
+
Promise.resolve()
|
|
247
|
+
.then(() => after?.())
|
|
248
|
+
.then(() => {
|
|
249
|
+
this.settle?.resolve(result);
|
|
250
|
+
})
|
|
251
|
+
.catch((error) => {
|
|
252
|
+
this.settle?.reject(error);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
fail(error) {
|
|
256
|
+
if (!this.stopped) {
|
|
257
|
+
this.stopped = true;
|
|
258
|
+
this.driver.destroy();
|
|
259
|
+
}
|
|
260
|
+
this.settle?.reject(error);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function cloneBuffer(buffer) {
|
|
264
|
+
const next = new ScreenBuffer(buffer.width, buffer.height);
|
|
265
|
+
for (let y = 0; y < buffer.height; y += 1) {
|
|
266
|
+
for (let x = 0; x < buffer.width; x += 1) {
|
|
267
|
+
const cell = buffer.get(x, y);
|
|
268
|
+
next.put(x, y, cell.ch, cell.style);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return next;
|
|
272
|
+
}
|
|
273
|
+
function changesToAnsi(changes) {
|
|
274
|
+
let output = "";
|
|
275
|
+
for (const change of changes) {
|
|
276
|
+
output += `${cursorPositionAnsi(change.x, change.y)}${cellToAnsi(change.cell)}`;
|
|
277
|
+
}
|
|
278
|
+
return output;
|
|
279
|
+
}
|
|
280
|
+
function cursorPositionAnsi(x, y) {
|
|
281
|
+
return `\u001b[${Math.max(1, y + 1)};${Math.max(1, x + 1)}H`;
|
|
282
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Cell } from "../dashboard/types.js";
|
|
2
|
+
import type { KeypressEvent, TerminalDriver } from "../dashboard/terminal.js";
|
|
3
|
+
export declare class FakeTerminalDriver implements TerminalDriver {
|
|
4
|
+
private cols;
|
|
5
|
+
private rows;
|
|
6
|
+
readonly keyQueue: KeypressEvent[];
|
|
7
|
+
readonly writes: string[];
|
|
8
|
+
readonly flushes: Array<Array<{
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
cell: Cell;
|
|
12
|
+
}>>;
|
|
13
|
+
rawMode: boolean;
|
|
14
|
+
altScreen: boolean;
|
|
15
|
+
lineWrap: boolean;
|
|
16
|
+
cursorVisible: boolean;
|
|
17
|
+
destroyed: boolean;
|
|
18
|
+
enterAltScreenCount: number;
|
|
19
|
+
exitAltScreenCount: number;
|
|
20
|
+
enterRawModeCount: number;
|
|
21
|
+
exitRawModeCount: number;
|
|
22
|
+
private readonly keypressHandlers;
|
|
23
|
+
private readonly resizeHandlers;
|
|
24
|
+
constructor(cols?: number, rows?: number);
|
|
25
|
+
get output(): string;
|
|
26
|
+
enterRawMode(): void;
|
|
27
|
+
exitRawMode(): void;
|
|
28
|
+
enterAltScreen(): void;
|
|
29
|
+
exitAltScreen(): void;
|
|
30
|
+
disableLineWrap(): void;
|
|
31
|
+
enableLineWrap(): void;
|
|
32
|
+
hideCursor(): void;
|
|
33
|
+
showCursor(): void;
|
|
34
|
+
moveTo(x: number, y: number): void;
|
|
35
|
+
write(text: string): void;
|
|
36
|
+
flush(changes: Array<{
|
|
37
|
+
x: number;
|
|
38
|
+
y: number;
|
|
39
|
+
cell: Cell;
|
|
40
|
+
}>): void;
|
|
41
|
+
getSize(): {
|
|
42
|
+
cols: number;
|
|
43
|
+
rows: number;
|
|
44
|
+
};
|
|
45
|
+
resize(cols: number, rows: number): void;
|
|
46
|
+
onResize(handler: () => void): () => void;
|
|
47
|
+
onKeypress(handler: (key: KeypressEvent) => void): () => void;
|
|
48
|
+
press(key: KeypressEvent): void;
|
|
49
|
+
destroy(): void;
|
|
50
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
export class FakeTerminalDriver {
|
|
2
|
+
cols;
|
|
3
|
+
rows;
|
|
4
|
+
keyQueue = [];
|
|
5
|
+
writes = [];
|
|
6
|
+
flushes = [];
|
|
7
|
+
rawMode = false;
|
|
8
|
+
altScreen = false;
|
|
9
|
+
lineWrap = true;
|
|
10
|
+
cursorVisible = true;
|
|
11
|
+
destroyed = false;
|
|
12
|
+
enterAltScreenCount = 0;
|
|
13
|
+
exitAltScreenCount = 0;
|
|
14
|
+
enterRawModeCount = 0;
|
|
15
|
+
exitRawModeCount = 0;
|
|
16
|
+
keypressHandlers = new Set();
|
|
17
|
+
resizeHandlers = new Set();
|
|
18
|
+
constructor(cols = 120, rows = 24) {
|
|
19
|
+
this.cols = cols;
|
|
20
|
+
this.rows = rows;
|
|
21
|
+
}
|
|
22
|
+
get output() {
|
|
23
|
+
return this.writes.join("");
|
|
24
|
+
}
|
|
25
|
+
enterRawMode() {
|
|
26
|
+
this.rawMode = true;
|
|
27
|
+
this.enterRawModeCount += 1;
|
|
28
|
+
}
|
|
29
|
+
exitRawMode() {
|
|
30
|
+
this.rawMode = false;
|
|
31
|
+
this.exitRawModeCount += 1;
|
|
32
|
+
}
|
|
33
|
+
enterAltScreen() {
|
|
34
|
+
this.altScreen = true;
|
|
35
|
+
this.enterAltScreenCount += 1;
|
|
36
|
+
}
|
|
37
|
+
exitAltScreen() {
|
|
38
|
+
this.altScreen = false;
|
|
39
|
+
this.exitAltScreenCount += 1;
|
|
40
|
+
}
|
|
41
|
+
disableLineWrap() {
|
|
42
|
+
this.lineWrap = false;
|
|
43
|
+
}
|
|
44
|
+
enableLineWrap() {
|
|
45
|
+
this.lineWrap = true;
|
|
46
|
+
}
|
|
47
|
+
hideCursor() {
|
|
48
|
+
this.cursorVisible = false;
|
|
49
|
+
}
|
|
50
|
+
showCursor() {
|
|
51
|
+
this.cursorVisible = true;
|
|
52
|
+
}
|
|
53
|
+
moveTo(x, y) {
|
|
54
|
+
this.write(`\u001b[${Math.max(1, y + 1)};${Math.max(1, x + 1)}H`);
|
|
55
|
+
}
|
|
56
|
+
write(text) {
|
|
57
|
+
if (!this.destroyed) {
|
|
58
|
+
this.writes.push(text);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
flush(changes) {
|
|
62
|
+
this.flushes.push(changes);
|
|
63
|
+
}
|
|
64
|
+
getSize() {
|
|
65
|
+
return { cols: this.cols, rows: this.rows };
|
|
66
|
+
}
|
|
67
|
+
resize(cols, rows) {
|
|
68
|
+
this.cols = cols;
|
|
69
|
+
this.rows = rows;
|
|
70
|
+
for (const handler of this.resizeHandlers) {
|
|
71
|
+
handler();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
onResize(handler) {
|
|
75
|
+
this.resizeHandlers.add(handler);
|
|
76
|
+
return () => {
|
|
77
|
+
this.resizeHandlers.delete(handler);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
onKeypress(handler) {
|
|
81
|
+
this.keypressHandlers.add(handler);
|
|
82
|
+
return () => {
|
|
83
|
+
this.keypressHandlers.delete(handler);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
press(key) {
|
|
87
|
+
this.keyQueue.push(key);
|
|
88
|
+
for (const handler of this.keypressHandlers) {
|
|
89
|
+
handler(key);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
destroy() {
|
|
93
|
+
this.destroyed = true;
|
|
94
|
+
this.rawMode = false;
|
|
95
|
+
this.lineWrap = true;
|
|
96
|
+
this.altScreen = false;
|
|
97
|
+
this.cursorVisible = true;
|
|
98
|
+
this.keypressHandlers.clear();
|
|
99
|
+
this.resizeHandlers.clear();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { type ResolvedBindings } from "./keymap.js";
|
|
2
|
+
export type Tone = "success" | "warning" | "error" | "info" | "muted";
|
|
3
|
+
export interface Row {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
subtitle?: string;
|
|
7
|
+
badge?: {
|
|
8
|
+
text: string;
|
|
9
|
+
tone?: Tone;
|
|
10
|
+
};
|
|
11
|
+
group?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface DetailItem {
|
|
14
|
+
id: string;
|
|
15
|
+
title?: string;
|
|
16
|
+
subtitle?: string;
|
|
17
|
+
badge?: {
|
|
18
|
+
text: string;
|
|
19
|
+
tone?: Tone;
|
|
20
|
+
};
|
|
21
|
+
render: (ctx: DetailCtx) => string | Promise<string>;
|
|
22
|
+
}
|
|
23
|
+
export interface Detail<R> {
|
|
24
|
+
items: (row: Row, ctx: DetailCtx) => Promise<DetailItem[]>;
|
|
25
|
+
actions?: Action<R>[];
|
|
26
|
+
}
|
|
27
|
+
export interface DetailCtx {
|
|
28
|
+
width: number;
|
|
29
|
+
height: number;
|
|
30
|
+
signal: AbortSignal;
|
|
31
|
+
row: Row;
|
|
32
|
+
}
|
|
33
|
+
export interface Action<R> {
|
|
34
|
+
id: string;
|
|
35
|
+
label: string | (() => string);
|
|
36
|
+
key?: string | string[];
|
|
37
|
+
predicate?: (ctx: ActionContext<R>) => boolean;
|
|
38
|
+
handler: (ctx: ActionContext<R>) => void | Promise<void>;
|
|
39
|
+
destructive?: boolean;
|
|
40
|
+
primary?: boolean;
|
|
41
|
+
showInFooter?: boolean;
|
|
42
|
+
}
|
|
43
|
+
export interface ActionContext<R> {
|
|
44
|
+
row: Row;
|
|
45
|
+
rows: Row[];
|
|
46
|
+
item?: DetailItem;
|
|
47
|
+
filter: string;
|
|
48
|
+
refresh: () => Promise<void>;
|
|
49
|
+
suspendAnd: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
50
|
+
toast: (msg: string, tone?: Tone) => void;
|
|
51
|
+
confirm: (prompt: string) => Promise<boolean>;
|
|
52
|
+
exit: (after?: () => void | Promise<void>) => void;
|
|
53
|
+
}
|
|
54
|
+
export interface ExplorerConfig<R> {
|
|
55
|
+
title: string;
|
|
56
|
+
rows: () => Promise<Row[]>;
|
|
57
|
+
detail: Detail<R>;
|
|
58
|
+
actions: Action<R>[];
|
|
59
|
+
reorder?: {
|
|
60
|
+
onReorder: (orderedIds: string[]) => void | Promise<void>;
|
|
61
|
+
};
|
|
62
|
+
multiSelect?: boolean;
|
|
63
|
+
keybindOverrides?: Record<string, string | string[]>;
|
|
64
|
+
emptyHint?: string;
|
|
65
|
+
initialFilter?: string;
|
|
66
|
+
}
|
|
67
|
+
export declare const REGION_HEADER: number;
|
|
68
|
+
export declare const REGION_LIST: number;
|
|
69
|
+
export declare const REGION_DETAIL: number;
|
|
70
|
+
export declare const REGION_FOOTER: number;
|
|
71
|
+
export declare const REGION_MODAL: number;
|
|
72
|
+
export declare const REGION_TOAST: number;
|
|
73
|
+
export declare const REGION_ALL: number;
|
|
74
|
+
export type Dirty = number;
|
|
75
|
+
export type ExplorerLayoutMode = "wide" | "medium" | "narrow-vertical" | "narrow-list-only" | "too-narrow";
|
|
76
|
+
export interface ExplorerSize {
|
|
77
|
+
cols: number;
|
|
78
|
+
rows: number;
|
|
79
|
+
}
|
|
80
|
+
export interface ExplorerState {
|
|
81
|
+
title: string;
|
|
82
|
+
emptyHint: string;
|
|
83
|
+
rows: Row[];
|
|
84
|
+
filtered: number[];
|
|
85
|
+
matchPositions: Map<number, number[]>;
|
|
86
|
+
cursor: number;
|
|
87
|
+
filter: string;
|
|
88
|
+
filterFocused: boolean;
|
|
89
|
+
focused: "list" | "detail";
|
|
90
|
+
detail: {
|
|
91
|
+
rowId: string | null;
|
|
92
|
+
items: DetailItem[] | null;
|
|
93
|
+
cursor: number;
|
|
94
|
+
scroll: number;
|
|
95
|
+
token: number;
|
|
96
|
+
loading: boolean;
|
|
97
|
+
};
|
|
98
|
+
selected: Set<string>;
|
|
99
|
+
modal: null | {
|
|
100
|
+
kind: "help";
|
|
101
|
+
} | {
|
|
102
|
+
kind: "confirm";
|
|
103
|
+
action: Action<unknown>;
|
|
104
|
+
rows: Row[];
|
|
105
|
+
resolver: (ok: boolean) => void;
|
|
106
|
+
} | {
|
|
107
|
+
kind: "palette";
|
|
108
|
+
query: string;
|
|
109
|
+
cursor: number;
|
|
110
|
+
};
|
|
111
|
+
toast: {
|
|
112
|
+
message: string;
|
|
113
|
+
tone: Tone;
|
|
114
|
+
expiresAt: number;
|
|
115
|
+
} | null;
|
|
116
|
+
dirty: Dirty;
|
|
117
|
+
size: ExplorerSize;
|
|
118
|
+
layout: ExplorerLayoutMode;
|
|
119
|
+
bindings: ResolvedBindings;
|
|
120
|
+
actionState: Map<string, ActionStateEntry>;
|
|
121
|
+
}
|
|
122
|
+
export interface ActionStateEntry {
|
|
123
|
+
available: boolean;
|
|
124
|
+
label: string;
|
|
125
|
+
running?: boolean;
|
|
126
|
+
action?: Action<unknown>;
|
|
127
|
+
source?: "row" | "detail";
|
|
128
|
+
}
|
|
129
|
+
export declare function createInitialState<R>(config: ExplorerConfig<R>, size: ExplorerSize): ExplorerState;
|
|
130
|
+
export declare function resolveExplorerLayoutMode(cols: number): ExplorerLayoutMode;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { resolveBindings } from "./keymap.js";
|
|
2
|
+
export const REGION_HEADER = 1 << 0;
|
|
3
|
+
export const REGION_LIST = 1 << 1;
|
|
4
|
+
export const REGION_DETAIL = 1 << 2;
|
|
5
|
+
export const REGION_FOOTER = 1 << 3;
|
|
6
|
+
export const REGION_MODAL = 1 << 4;
|
|
7
|
+
export const REGION_TOAST = 1 << 5;
|
|
8
|
+
export const REGION_ALL = REGION_HEADER |
|
|
9
|
+
REGION_LIST |
|
|
10
|
+
REGION_DETAIL |
|
|
11
|
+
REGION_FOOTER |
|
|
12
|
+
REGION_MODAL |
|
|
13
|
+
REGION_TOAST;
|
|
14
|
+
export function createInitialState(config, size) {
|
|
15
|
+
const normalizedSize = {
|
|
16
|
+
cols: normalizeSize(size.cols),
|
|
17
|
+
rows: normalizeSize(size.rows)
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
title: config.title,
|
|
21
|
+
emptyHint: config.emptyHint ?? "No detail",
|
|
22
|
+
rows: [],
|
|
23
|
+
filtered: [],
|
|
24
|
+
matchPositions: new Map(),
|
|
25
|
+
cursor: 0,
|
|
26
|
+
filter: config.initialFilter ?? "",
|
|
27
|
+
filterFocused: false,
|
|
28
|
+
focused: "list",
|
|
29
|
+
detail: {
|
|
30
|
+
rowId: null,
|
|
31
|
+
items: null,
|
|
32
|
+
cursor: 0,
|
|
33
|
+
scroll: 0,
|
|
34
|
+
token: 0,
|
|
35
|
+
loading: false
|
|
36
|
+
},
|
|
37
|
+
selected: new Set(),
|
|
38
|
+
modal: null,
|
|
39
|
+
toast: null,
|
|
40
|
+
dirty: REGION_ALL,
|
|
41
|
+
size: normalizedSize,
|
|
42
|
+
layout: resolveExplorerLayoutMode(normalizedSize.cols),
|
|
43
|
+
bindings: resolveBindings(config),
|
|
44
|
+
actionState: createInitialActionState(config)
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function createInitialActionState(config) {
|
|
48
|
+
const state = new Map();
|
|
49
|
+
for (const action of config.actions) {
|
|
50
|
+
state.set(action.id, {
|
|
51
|
+
available: true,
|
|
52
|
+
label: typeof action.label === "function" ? action.id : action.label,
|
|
53
|
+
action: action,
|
|
54
|
+
source: "row"
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
for (const action of config.detail.actions ?? []) {
|
|
58
|
+
state.set(action.id, {
|
|
59
|
+
available: true,
|
|
60
|
+
label: typeof action.label === "function" ? action.id : action.label,
|
|
61
|
+
action: action,
|
|
62
|
+
source: "detail"
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return state;
|
|
66
|
+
}
|
|
67
|
+
export function resolveExplorerLayoutMode(cols) {
|
|
68
|
+
if (cols < 40) {
|
|
69
|
+
return "too-narrow";
|
|
70
|
+
}
|
|
71
|
+
if (cols < 80) {
|
|
72
|
+
return "narrow-list-only";
|
|
73
|
+
}
|
|
74
|
+
if (cols < 100) {
|
|
75
|
+
return "narrow-vertical";
|
|
76
|
+
}
|
|
77
|
+
if (cols < 120) {
|
|
78
|
+
return "medium";
|
|
79
|
+
}
|
|
80
|
+
return "wide";
|
|
81
|
+
}
|
|
82
|
+
function normalizeSize(value) {
|
|
83
|
+
if (!Number.isFinite(value)) {
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
return Math.max(0, Math.floor(value));
|
|
87
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Tone } from "./state.js";
|
|
2
|
+
type CellStyle = {
|
|
3
|
+
fg?: string;
|
|
4
|
+
bg?: string;
|
|
5
|
+
bold?: boolean;
|
|
6
|
+
dim?: boolean;
|
|
7
|
+
underline?: boolean;
|
|
8
|
+
};
|
|
9
|
+
export interface ExplorerTheme {
|
|
10
|
+
accent: (text: string) => string;
|
|
11
|
+
muted: (text: string) => string;
|
|
12
|
+
border: (text: string) => string;
|
|
13
|
+
borderFocused: (text: string) => string;
|
|
14
|
+
badge: (text: string, tone: Tone) => string;
|
|
15
|
+
matchHighlight: (text: string) => string;
|
|
16
|
+
}
|
|
17
|
+
export interface ExplorerStyles {
|
|
18
|
+
accent: CellStyle;
|
|
19
|
+
muted: CellStyle;
|
|
20
|
+
border: CellStyle;
|
|
21
|
+
borderFocused: CellStyle;
|
|
22
|
+
matchHighlight: CellStyle;
|
|
23
|
+
tones: Record<Tone, CellStyle>;
|
|
24
|
+
}
|
|
25
|
+
export declare function getExplorerTheme(): ExplorerTheme;
|
|
26
|
+
export declare function getExplorerStyles(): ExplorerStyles;
|
|
27
|
+
export {};
|