@larose/pi-web 0.3.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 +235 -0
- package/README.md +50 -0
- package/THIRD_PARTY_LICENSES.md +40 -0
- package/dist/client/home.js +1619 -0
- package/dist/client/session.js +3703 -0
- package/dist/server/api.js +485 -0
- package/dist/server/cli.js +51 -0
- package/dist/server/directory-browser.js +104 -0
- package/dist/server/errors.js +10 -0
- package/dist/server/event-buffer.js +40 -0
- package/dist/server/extension-ui.js +245 -0
- package/dist/server/git-workspaces.js +559 -0
- package/dist/server/runtime-registry.js +703 -0
- package/dist/server/server.js +190 -0
- package/dist/server/session-repository.js +374 -0
- package/package.json +46 -0
- package/public/home.html +139 -0
- package/public/session.html +144 -0
- package/public/styles.css +2463 -0
- package/screenshots/home.png +0 -0
- package/screenshots/session.png +0 -0
- package/src/client/display-title.ts +36 -0
- package/src/client/event-stream.ts +194 -0
- package/src/client/home.ts +1575 -0
- package/src/client/markdown.ts +98 -0
- package/src/client/message-queue.ts +67 -0
- package/src/client/path-combobox.ts +271 -0
- package/src/client/session.ts +2174 -0
- package/src/client/shared.ts +99 -0
- package/src/client/slash-completion.ts +184 -0
- package/src/client/transcript-activity.ts +188 -0
- package/src/client/usage-format.ts +156 -0
- package/src/client/workspace-browser.ts +36 -0
- package/src/server/api.ts +652 -0
- package/src/server/cli.ts +63 -0
- package/src/server/directory-browser.ts +137 -0
- package/src/server/errors.ts +11 -0
- package/src/server/event-buffer.ts +59 -0
- package/src/server/extension-ui.ts +359 -0
- package/src/server/git-workspaces.ts +750 -0
- package/src/server/runtime-registry.ts +943 -0
- package/src/server/server.ts +248 -0
- package/src/server/session-repository.ts +488 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { opendir, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { AppError } from "./errors.js";
|
|
5
|
+
|
|
6
|
+
const MAX_DIRECTORY_RESULTS = 50;
|
|
7
|
+
const MAX_SCANNED_ENTRIES = 2_000;
|
|
8
|
+
|
|
9
|
+
export interface DirectorySuggestion {
|
|
10
|
+
name: string;
|
|
11
|
+
path: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DirectorySuggestionListing {
|
|
15
|
+
input: string;
|
|
16
|
+
basePath: string;
|
|
17
|
+
prefix: string;
|
|
18
|
+
directories: DirectorySuggestion[];
|
|
19
|
+
truncated: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface BrowseTarget {
|
|
23
|
+
basePath: string;
|
|
24
|
+
prefix: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function unavailableDirectory(error: unknown): AppError {
|
|
28
|
+
return new AppError("directory_unavailable", "The directory cannot be read by the server", 400, { cause: error });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function canonicalDirectory(path: string): Promise<string | null> {
|
|
32
|
+
try {
|
|
33
|
+
const canonical = await realpath(path);
|
|
34
|
+
return (await stat(canonical)).isDirectory() ? canonical : null;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
37
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
throw unavailableDirectory(error);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function browseTarget(input: string): Promise<BrowseTarget> {
|
|
46
|
+
let candidate = resolve(input);
|
|
47
|
+
let prefix = "";
|
|
48
|
+
|
|
49
|
+
while (true) {
|
|
50
|
+
const canonical = await canonicalDirectory(candidate);
|
|
51
|
+
if (canonical) {
|
|
52
|
+
return { basePath: canonical, prefix };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const parent = dirname(candidate);
|
|
56
|
+
if (parent === candidate) {
|
|
57
|
+
throw new AppError("directory_unavailable", "No readable directory exists for this path", 400);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
prefix = basename(candidate);
|
|
61
|
+
candidate = parent;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function compareSuggestions(left: DirectorySuggestion, right: DirectorySuggestion): number {
|
|
66
|
+
const folded = left.name.toLocaleLowerCase().localeCompare(right.name.toLocaleLowerCase());
|
|
67
|
+
return folded || left.name.localeCompare(right.name) || left.path.localeCompare(right.path);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class DirectoryBrowser {
|
|
71
|
+
async suggest(input: unknown): Promise<DirectorySuggestionListing> {
|
|
72
|
+
if (typeof input !== "string" || input.trim() === "") {
|
|
73
|
+
throw new AppError("invalid_directory_path", "An absolute directory path is required");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const requested = input.trim();
|
|
77
|
+
if (!isAbsolute(requested)) {
|
|
78
|
+
throw new AppError("invalid_directory_path", "The directory path must be absolute");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const target = await browseTarget(requested);
|
|
82
|
+
const foldedPrefix = target.prefix.toLocaleLowerCase();
|
|
83
|
+
const directories: DirectorySuggestion[] = [];
|
|
84
|
+
const canonicalPaths = new Set<string>();
|
|
85
|
+
let scannedEntries = 0;
|
|
86
|
+
let truncated = false;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const handle = await opendir(target.basePath);
|
|
90
|
+
for await (const entry of handle) {
|
|
91
|
+
scannedEntries += 1;
|
|
92
|
+
if (scannedEntries > MAX_SCANNED_ENTRIES) {
|
|
93
|
+
truncated = true;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
if (!entry.name.toLocaleLowerCase().startsWith(foldedPrefix)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const entryPath = resolve(target.basePath, entry.name);
|
|
101
|
+
let canonical: string;
|
|
102
|
+
try {
|
|
103
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
canonical = await realpath(entryPath);
|
|
107
|
+
if (!(await stat(canonical)).isDirectory()) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!canonicalPaths.has(canonical)) {
|
|
115
|
+
canonicalPaths.add(canonical);
|
|
116
|
+
directories.push({ name: entry.name, path: canonical });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
throw unavailableDirectory(error);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
directories.sort(compareSuggestions);
|
|
124
|
+
if (directories.length > MAX_DIRECTORY_RESULTS) {
|
|
125
|
+
directories.length = MAX_DIRECTORY_RESULTS;
|
|
126
|
+
truncated = true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
input: requested,
|
|
131
|
+
basePath: target.basePath,
|
|
132
|
+
prefix: target.prefix,
|
|
133
|
+
directories,
|
|
134
|
+
truncated,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export class AppError extends Error {
|
|
2
|
+
readonly code: string;
|
|
3
|
+
readonly status: number;
|
|
4
|
+
|
|
5
|
+
constructor(code: string, message: string, status = 400, options?: ErrorOptions) {
|
|
6
|
+
super(message, options);
|
|
7
|
+
this.name = "AppError";
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.status = status;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface BufferedEvent<T> {
|
|
2
|
+
id: number;
|
|
3
|
+
data: T;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface EventReplay<T> {
|
|
7
|
+
events: BufferedEvent<T>[];
|
|
8
|
+
gap: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type EventListener<T> = (event: BufferedEvent<T>) => void;
|
|
12
|
+
|
|
13
|
+
export class EventBuffer<T> {
|
|
14
|
+
private readonly capacity: number;
|
|
15
|
+
private readonly events: BufferedEvent<T>[] = [];
|
|
16
|
+
private readonly listeners = new Set<EventListener<T>>();
|
|
17
|
+
private nextId = 1;
|
|
18
|
+
|
|
19
|
+
constructor(capacity = 512) {
|
|
20
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
21
|
+
throw new Error("Event capacity must be a positive integer");
|
|
22
|
+
}
|
|
23
|
+
this.capacity = capacity;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
publish(data: T): BufferedEvent<T> {
|
|
27
|
+
const event = { id: this.nextId++, data };
|
|
28
|
+
this.events.push(event);
|
|
29
|
+
if (this.events.length > this.capacity) {
|
|
30
|
+
this.events.splice(0, this.events.length - this.capacity);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const listener of this.listeners) {
|
|
34
|
+
listener(event);
|
|
35
|
+
}
|
|
36
|
+
return event;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
replay(afterId?: number): EventReplay<T> {
|
|
40
|
+
if (afterId === undefined) {
|
|
41
|
+
return { events: [], gap: false };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const oldestId = this.events[0]?.id ?? this.nextId;
|
|
45
|
+
return {
|
|
46
|
+
events: this.events.filter((event) => event.id > afterId),
|
|
47
|
+
gap: afterId < oldestId - 1 || afterId >= this.nextId,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
subscribe(listener: EventListener<T>): () => void {
|
|
52
|
+
this.listeners.add(listener);
|
|
53
|
+
return () => this.listeners.delete(listener);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get latestId(): number {
|
|
57
|
+
return this.nextId - 1;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { Theme, type ExtensionUIContext, type ExtensionUIDialogOptions } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
export type ExtensionUIDialogRequest =
|
|
6
|
+
| { id: string; method: "select"; title: string; options: string[]; timeout?: number }
|
|
7
|
+
| { id: string; method: "confirm"; title: string; message: string; timeout?: number }
|
|
8
|
+
| { id: string; method: "input"; title: string; placeholder?: string; timeout?: number }
|
|
9
|
+
| { id: string; method: "editor"; title: string; prefill?: string };
|
|
10
|
+
|
|
11
|
+
type NewExtensionUIDialogRequest =
|
|
12
|
+
| { method: "select"; title: string; options: string[] }
|
|
13
|
+
| { method: "confirm"; title: string; message: string }
|
|
14
|
+
| { method: "input"; title: string; placeholder?: string }
|
|
15
|
+
| { method: "editor"; title: string; prefill?: string };
|
|
16
|
+
|
|
17
|
+
export type ExtensionUIRequest =
|
|
18
|
+
| ExtensionUIDialogRequest
|
|
19
|
+
| { id: string; method: "notify"; message: string; notifyType?: "info" | "warning" | "error" }
|
|
20
|
+
| { id: string; method: "setStatus"; statusKey: string; statusText?: string }
|
|
21
|
+
| {
|
|
22
|
+
id: string;
|
|
23
|
+
method: "setWidget";
|
|
24
|
+
widgetKey: string;
|
|
25
|
+
widgetLines?: string[];
|
|
26
|
+
widgetPlacement?: "aboveEditor" | "belowEditor";
|
|
27
|
+
}
|
|
28
|
+
| { id: string; method: "setTitle"; title: string }
|
|
29
|
+
| { id: string; method: "set_editor_text"; text: string };
|
|
30
|
+
|
|
31
|
+
export type ExtensionUIResponse =
|
|
32
|
+
| { id: string; value: string }
|
|
33
|
+
| { id: string; confirmed: boolean }
|
|
34
|
+
| { id: string; cancelled: true };
|
|
35
|
+
|
|
36
|
+
export interface ExtensionUIState {
|
|
37
|
+
pending: ExtensionUIDialogRequest[];
|
|
38
|
+
statuses: Array<{ key: string; text: string }>;
|
|
39
|
+
widgets: Array<{
|
|
40
|
+
key: string;
|
|
41
|
+
lines: string[];
|
|
42
|
+
placement: "aboveEditor" | "belowEditor";
|
|
43
|
+
}>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ExtensionUIEvent =
|
|
47
|
+
| ({ type: "extension_ui_request" } & ExtensionUIRequest)
|
|
48
|
+
| { type: "extension_ui_closed"; id: string }
|
|
49
|
+
| { type: "extension_ui_reset" };
|
|
50
|
+
|
|
51
|
+
interface PendingDialog<T> {
|
|
52
|
+
request: ExtensionUIDialogRequest;
|
|
53
|
+
defaultValue: T;
|
|
54
|
+
parse(response: ExtensionUIResponse): T;
|
|
55
|
+
resolve(value: T): void;
|
|
56
|
+
timeout?: NodeJS.Timeout;
|
|
57
|
+
signal?: AbortSignal;
|
|
58
|
+
abort?: () => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The browser supplies its own colors. Extensions still need a complete Theme
|
|
62
|
+
// object for formatting status and widget text, so keep those operations plain.
|
|
63
|
+
class PlainTextTheme extends Theme {
|
|
64
|
+
constructor() {
|
|
65
|
+
super(
|
|
66
|
+
{ text: "", muted: "", thinkingXhigh: "", searchMatchText: "" } as ConstructorParameters<typeof Theme>[0],
|
|
67
|
+
{ selectedBg: "" } as ConstructorParameters<typeof Theme>[1],
|
|
68
|
+
"truecolor",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
override fg(...[, text]: Parameters<Theme["fg"]>): string {
|
|
73
|
+
return text;
|
|
74
|
+
}
|
|
75
|
+
override bg(...[, text]: Parameters<Theme["bg"]>): string {
|
|
76
|
+
return text;
|
|
77
|
+
}
|
|
78
|
+
override bold(text: string): string {
|
|
79
|
+
return text;
|
|
80
|
+
}
|
|
81
|
+
override italic(text: string): string {
|
|
82
|
+
return text;
|
|
83
|
+
}
|
|
84
|
+
override underline(text: string): string {
|
|
85
|
+
return text;
|
|
86
|
+
}
|
|
87
|
+
override inverse(text: string): string {
|
|
88
|
+
return text;
|
|
89
|
+
}
|
|
90
|
+
override strikethrough(text: string): string {
|
|
91
|
+
return text;
|
|
92
|
+
}
|
|
93
|
+
override getFgAnsi(): string {
|
|
94
|
+
return "";
|
|
95
|
+
}
|
|
96
|
+
override getBgAnsi(): string {
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
99
|
+
override getThinkingBorderColor(): (text: string) => string {
|
|
100
|
+
return (text) => text;
|
|
101
|
+
}
|
|
102
|
+
override getBashModeBorderColor(): (text: string) => string {
|
|
103
|
+
return (text) => text;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const plainTextTheme = new PlainTextTheme();
|
|
108
|
+
|
|
109
|
+
export class WebExtensionUI {
|
|
110
|
+
private readonly pending = new Map<string, PendingDialog<unknown>>();
|
|
111
|
+
private readonly statuses = new Map<string, string>();
|
|
112
|
+
private readonly widgets = new Map<string, { lines: string[]; placement: "aboveEditor" | "belowEditor" }>();
|
|
113
|
+
private publisher: ((event: ExtensionUIEvent) => void) | undefined;
|
|
114
|
+
private queuedEvents: ExtensionUIEvent[] = [];
|
|
115
|
+
private available = true;
|
|
116
|
+
|
|
117
|
+
readonly context: ExtensionUIContext = {
|
|
118
|
+
select: (title, options, opts) =>
|
|
119
|
+
this.request(
|
|
120
|
+
{ method: "select", title, options: [...options] },
|
|
121
|
+
undefined,
|
|
122
|
+
(response) => ("value" in response ? response.value : undefined),
|
|
123
|
+
opts,
|
|
124
|
+
),
|
|
125
|
+
confirm: (title, message, opts) =>
|
|
126
|
+
this.request(
|
|
127
|
+
{ method: "confirm", title, message },
|
|
128
|
+
false,
|
|
129
|
+
(response) => ("confirmed" in response ? response.confirmed : false),
|
|
130
|
+
opts,
|
|
131
|
+
),
|
|
132
|
+
input: (title, placeholder, opts) =>
|
|
133
|
+
this.request(
|
|
134
|
+
{ method: "input", title, ...(placeholder === undefined ? {} : { placeholder }) },
|
|
135
|
+
undefined,
|
|
136
|
+
(response) => ("value" in response ? response.value : undefined),
|
|
137
|
+
opts,
|
|
138
|
+
),
|
|
139
|
+
editor: (title, prefill) =>
|
|
140
|
+
this.request({ method: "editor", title, ...(prefill === undefined ? {} : { prefill }) }, undefined, (response) =>
|
|
141
|
+
"value" in response ? response.value : undefined,
|
|
142
|
+
),
|
|
143
|
+
notify: (message, type) =>
|
|
144
|
+
this.emit({
|
|
145
|
+
type: "extension_ui_request",
|
|
146
|
+
id: randomUUID(),
|
|
147
|
+
method: "notify",
|
|
148
|
+
message,
|
|
149
|
+
...(type === undefined ? {} : { notifyType: type }),
|
|
150
|
+
}),
|
|
151
|
+
onTerminalInput: () => () => undefined,
|
|
152
|
+
setStatus: (key, text) => {
|
|
153
|
+
if (text === undefined) {
|
|
154
|
+
this.statuses.delete(key);
|
|
155
|
+
} else {
|
|
156
|
+
this.statuses.set(key, text);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
this.emit({
|
|
160
|
+
type: "extension_ui_request",
|
|
161
|
+
id: randomUUID(),
|
|
162
|
+
method: "setStatus",
|
|
163
|
+
statusKey: key,
|
|
164
|
+
...(text === undefined ? {} : { statusText: text }),
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
setWorkingMessage: () => undefined,
|
|
168
|
+
setWorkingVisible: () => undefined,
|
|
169
|
+
setWorkingIndicator: () => undefined,
|
|
170
|
+
setHiddenThinkingLabel: () => undefined,
|
|
171
|
+
setWidget: (key, content, options) => {
|
|
172
|
+
if (content !== undefined && !Array.isArray(content)) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const placement = options?.placement ?? "aboveEditor";
|
|
177
|
+
if (content === undefined) {
|
|
178
|
+
this.widgets.delete(key);
|
|
179
|
+
} else {
|
|
180
|
+
this.widgets.set(key, { lines: [...content], placement });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this.emit({
|
|
184
|
+
type: "extension_ui_request",
|
|
185
|
+
id: randomUUID(),
|
|
186
|
+
method: "setWidget",
|
|
187
|
+
widgetKey: key,
|
|
188
|
+
...(content === undefined ? {} : { widgetLines: [...content] }),
|
|
189
|
+
...(options?.placement === undefined ? {} : { widgetPlacement: options.placement }),
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
setFooter: () => undefined,
|
|
193
|
+
setHeader: () => undefined,
|
|
194
|
+
setTitle: (title) =>
|
|
195
|
+
this.emit({
|
|
196
|
+
type: "extension_ui_request",
|
|
197
|
+
id: randomUUID(),
|
|
198
|
+
method: "setTitle",
|
|
199
|
+
title,
|
|
200
|
+
}),
|
|
201
|
+
custom: async <T>() => undefined as T,
|
|
202
|
+
pasteToEditor: (text) =>
|
|
203
|
+
this.emit({
|
|
204
|
+
type: "extension_ui_request",
|
|
205
|
+
id: randomUUID(),
|
|
206
|
+
method: "set_editor_text",
|
|
207
|
+
text,
|
|
208
|
+
}),
|
|
209
|
+
setEditorText: (text) =>
|
|
210
|
+
this.emit({
|
|
211
|
+
type: "extension_ui_request",
|
|
212
|
+
id: randomUUID(),
|
|
213
|
+
method: "set_editor_text",
|
|
214
|
+
text,
|
|
215
|
+
}),
|
|
216
|
+
getEditorText: () => "",
|
|
217
|
+
addAutocompleteProvider: () => undefined,
|
|
218
|
+
setEditorComponent: () => undefined,
|
|
219
|
+
getEditorComponent: () => undefined,
|
|
220
|
+
get theme() {
|
|
221
|
+
return plainTextTheme;
|
|
222
|
+
},
|
|
223
|
+
getAllThemes: () => [],
|
|
224
|
+
getTheme: () => undefined,
|
|
225
|
+
setTheme: () => ({ success: false, error: "Theme switching is not supported in Pi Web" }),
|
|
226
|
+
getToolsExpanded: () => false,
|
|
227
|
+
setToolsExpanded: () => undefined,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
setPublisher(publisher: (event: ExtensionUIEvent) => void): () => void {
|
|
231
|
+
this.publisher = publisher;
|
|
232
|
+
|
|
233
|
+
const queued = this.queuedEvents;
|
|
234
|
+
this.queuedEvents = [];
|
|
235
|
+
for (const event of queued) {
|
|
236
|
+
publisher(event);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return () => {
|
|
240
|
+
if (this.publisher === publisher) {
|
|
241
|
+
this.publisher = undefined;
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
snapshot(): ExtensionUIState {
|
|
247
|
+
return {
|
|
248
|
+
pending: [...this.pending.values()].map(({ request }) => ({ ...request })),
|
|
249
|
+
statuses: [...this.statuses].map(([key, text]) => ({ key, text })),
|
|
250
|
+
widgets: [...this.widgets].map(([key, widget]) => ({
|
|
251
|
+
key,
|
|
252
|
+
lines: [...widget.lines],
|
|
253
|
+
placement: widget.placement,
|
|
254
|
+
})),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
respond(response: ExtensionUIResponse): boolean {
|
|
259
|
+
const dialog = this.pending.get(response.id);
|
|
260
|
+
if (!dialog) {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
this.settle(response.id, dialog.parse(response));
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
cancelPending(): void {
|
|
269
|
+
for (const [id, dialog] of [...this.pending]) {
|
|
270
|
+
this.settle(id, dialog.defaultValue);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
reset(): void {
|
|
275
|
+
this.cancelPending();
|
|
276
|
+
this.statuses.clear();
|
|
277
|
+
this.widgets.clear();
|
|
278
|
+
this.emit({ type: "extension_ui_reset" });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
dispose(): void {
|
|
282
|
+
if (!this.available) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
this.reset();
|
|
287
|
+
this.available = false;
|
|
288
|
+
this.publisher = undefined;
|
|
289
|
+
this.queuedEvents = [];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private request<T>(
|
|
293
|
+
request: NewExtensionUIDialogRequest,
|
|
294
|
+
defaultValue: T,
|
|
295
|
+
parse: (response: ExtensionUIResponse) => T,
|
|
296
|
+
options?: ExtensionUIDialogOptions,
|
|
297
|
+
): Promise<T> {
|
|
298
|
+
if (!this.available || options?.signal?.aborted) {
|
|
299
|
+
return Promise.resolve(defaultValue);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const id = randomUUID();
|
|
303
|
+
const fullRequest = {
|
|
304
|
+
id,
|
|
305
|
+
...request,
|
|
306
|
+
...(options?.timeout === undefined ? {} : { timeout: options.timeout }),
|
|
307
|
+
} as ExtensionUIDialogRequest;
|
|
308
|
+
|
|
309
|
+
return new Promise<T>((resolve) => {
|
|
310
|
+
const pending: PendingDialog<T> = {
|
|
311
|
+
request: fullRequest,
|
|
312
|
+
defaultValue,
|
|
313
|
+
parse,
|
|
314
|
+
resolve,
|
|
315
|
+
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
|
316
|
+
};
|
|
317
|
+
if (options?.timeout !== undefined) {
|
|
318
|
+
pending.timeout = setTimeout(() => this.settle(id, defaultValue), options.timeout);
|
|
319
|
+
pending.timeout.unref();
|
|
320
|
+
}
|
|
321
|
+
if (options?.signal) {
|
|
322
|
+
pending.abort = () => this.settle(id, defaultValue);
|
|
323
|
+
options.signal.addEventListener("abort", pending.abort, { once: true });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
this.pending.set(id, pending as PendingDialog<unknown>);
|
|
327
|
+
this.emit({ type: "extension_ui_request", ...fullRequest });
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private settle<T>(id: string, value: T): void {
|
|
332
|
+
const dialog = this.pending.get(id) as PendingDialog<T> | undefined;
|
|
333
|
+
if (!dialog) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
this.pending.delete(id);
|
|
338
|
+
if (dialog.timeout) {
|
|
339
|
+
clearTimeout(dialog.timeout);
|
|
340
|
+
}
|
|
341
|
+
if (dialog.signal && dialog.abort) {
|
|
342
|
+
dialog.signal.removeEventListener("abort", dialog.abort);
|
|
343
|
+
}
|
|
344
|
+
dialog.resolve(value);
|
|
345
|
+
this.emit({ type: "extension_ui_closed", id });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private emit(event: ExtensionUIEvent): void {
|
|
349
|
+
if (!this.available) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (this.publisher) {
|
|
354
|
+
this.publisher(event);
|
|
355
|
+
} else {
|
|
356
|
+
this.queuedEvents.push(event);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|