@achasoft/dsh-advanced-sidebar 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/cordis.patch.yml +158 -0
- package/lib/client.js +24890 -0
- package/lib/client.js.map +1 -0
- package/lib/host.js +3156 -0
- package/lib/index.js +20 -0
- package/lib/remote.js +1994 -0
- package/lib/typert.host.js +2000 -0
- package/package.json +136 -0
- package/types/client/ActionMenu.d.ts +36 -0
- package/types/client/Glyphs.d.ts +49 -0
- package/types/client/PanelHost.d.ts +24 -0
- package/types/client/Seats.d.ts +16 -0
- package/types/client/SettingsCard.d.ts +22 -0
- package/types/client/contract.d.ts +358 -0
- package/types/client/controller.d.ts +224 -0
- package/types/client/cx.d.ts +16 -0
- package/types/client/index.d.ts +39 -0
- package/types/client/locales.d.ts +474 -0
- package/types/client/panels/ChangesPanel.d.ts +22 -0
- package/types/client/panels/FilesPanel.d.ts +16 -0
- package/types/client/panels/PreviewPanel.d.ts +27 -0
- package/types/client/panels/TasksPanel.d.ts +28 -0
- package/types/client/panels/TerminalPanel.d.ts +40 -0
- package/types/client/panels/shared.d.ts +65 -0
- package/types/client/target.d.ts +23 -0
- package/types/client/terminal-screen.d.ts +95 -0
- package/types/client/ui/Alert.d.ts +30 -0
- package/types/client/ui/Badge.d.ts +24 -0
- package/types/client/ui/Button.d.ts +28 -0
- package/types/client/ui/Calendar.d.ts +65 -0
- package/types/client/ui/DatePicker.d.ts +41 -0
- package/types/client/ui/Dialog.d.ts +61 -0
- package/types/client/ui/DropdownMenu.d.ts +98 -0
- package/types/client/ui/Input.d.ts +25 -0
- package/types/client/ui/Layer.d.ts +56 -0
- package/types/client/ui/Select.d.ts +49 -0
- package/types/client/ui/Separator.d.ts +15 -0
- package/types/client/ui/Tabs.d.ts +49 -0
- package/types/client/ui/Toggle.d.ts +57 -0
- package/types/client/ui/Tooltip.d.ts +22 -0
- package/types/client/ui/anchor.d.ts +92 -0
- package/types/client/ui/index.d.ts +42 -0
- package/types/client/use-capability.d.ts +27 -0
- package/types/host/deletion.d.ts +57 -0
- package/types/host/files.d.ts +43 -0
- package/types/host/git.d.ts +198 -0
- package/types/host/index.d.ts +210 -0
- package/types/host/open-in.d.ts +93 -0
- package/types/host/paths.d.ts +55 -0
- package/types/host/porcelain.d.ts +51 -0
- package/types/host/preview.d.ts +185 -0
- package/types/host/run.d.ts +72 -0
- package/types/host/tasks.d.ts +80 -0
- package/types/host/terminals.d.ts +86 -0
- package/types/host/types.d.ts +877 -0
- package/types/index.d.ts +17 -0
package/lib/host.js
ADDED
|
@@ -0,0 +1,3156 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
3
|
+
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
4
|
+
import { rm } from "node:fs/promises";
|
|
5
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { connect } from "node:net";
|
|
9
|
+
import { JobId } from "@deepseek-ai/dsh-jobs";
|
|
10
|
+
|
|
11
|
+
//#region tsbuild/host/deletion.js
|
|
12
|
+
/**
|
|
13
|
+
* Commits Delete for the sidebar menu. Stateless apart from the context and settings it reads.
|
|
14
|
+
*/
|
|
15
|
+
var SessionDeleter = class {
|
|
16
|
+
ctx;
|
|
17
|
+
source;
|
|
18
|
+
/**
|
|
19
|
+
* @param ctx - Host context carrying the workspace registry and session persistence.
|
|
20
|
+
* @param source - reads the current settings section; called per request.
|
|
21
|
+
*/
|
|
22
|
+
constructor(ctx, source) {
|
|
23
|
+
this.ctx = ctx;
|
|
24
|
+
this.source = source;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Report whether the durable log can be removed at all.
|
|
28
|
+
* @returns the capability, with a reason when purging is impossible.
|
|
29
|
+
*/
|
|
30
|
+
describe() {
|
|
31
|
+
const persistence = this.ctx.get("sessionPersistence");
|
|
32
|
+
if (persistence === void 0) return {
|
|
33
|
+
canPurge: false,
|
|
34
|
+
reason: "no session-persistence backend is mounted; nothing is written to remove"
|
|
35
|
+
};
|
|
36
|
+
if (!persistence.supportsRawArtifacts) return {
|
|
37
|
+
canPurge: false,
|
|
38
|
+
reason: "this session-persistence backend keeps no per-session artifact, so Delete can only archive"
|
|
39
|
+
};
|
|
40
|
+
return { canPurge: true };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Hide one session, and remove its durable artifact when the mode and the Host allow it.
|
|
44
|
+
* @param request - the session to delete.
|
|
45
|
+
* @param signal - cancellation for the persistence listing.
|
|
46
|
+
* @returns what was actually done, or a classified failure.
|
|
47
|
+
*/
|
|
48
|
+
async delete(request, signal) {
|
|
49
|
+
const settings = this.source();
|
|
50
|
+
if (!settings.showDelete) return {
|
|
51
|
+
ok: false,
|
|
52
|
+
code: "disabled",
|
|
53
|
+
message: "Delete is switched off in the advanced-sidebar settings"
|
|
54
|
+
};
|
|
55
|
+
const registry = this.ctx.get("workspaceRegistry");
|
|
56
|
+
if (registry === void 0) return {
|
|
57
|
+
ok: false,
|
|
58
|
+
code: "no-registry",
|
|
59
|
+
message: "no workspace registry is mounted"
|
|
60
|
+
};
|
|
61
|
+
const sessionId = request.sessionId;
|
|
62
|
+
const artifact = settings.deleteMode === "purge" ? await this.locateArtifact(sessionId, signal) : void 0;
|
|
63
|
+
try {
|
|
64
|
+
await registry.archiveSession(sessionId);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
code: "archive-failed",
|
|
69
|
+
message: error instanceof Error ? error.message : String(error)
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (settings.deleteMode === "archive") return {
|
|
73
|
+
ok: true,
|
|
74
|
+
archived: true,
|
|
75
|
+
purged: false
|
|
76
|
+
};
|
|
77
|
+
if (artifact === void 0 || "reason" in artifact) return {
|
|
78
|
+
ok: true,
|
|
79
|
+
archived: true,
|
|
80
|
+
purged: false,
|
|
81
|
+
purgeSkippedReason: artifact?.reason ?? this.describe().reason ?? "the session has no materialized artifact to remove"
|
|
82
|
+
};
|
|
83
|
+
try {
|
|
84
|
+
await rm(artifact.path, { force: true });
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
code: "remove-failed",
|
|
89
|
+
message: `archived, but ${artifact.path} could not be removed: ${error instanceof Error ? error.message : String(error)}`
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
archived: true,
|
|
95
|
+
purged: true,
|
|
96
|
+
artifactPath: artifact.path
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Find the backend artifact for one session, or say why it will not be removed.
|
|
101
|
+
* @param sessionId - the session to look up.
|
|
102
|
+
* @param signal - cancellation for the persistence listing.
|
|
103
|
+
* @returns the artifact path, or the reason purging is skipped.
|
|
104
|
+
*/
|
|
105
|
+
async locateArtifact(sessionId, signal) {
|
|
106
|
+
const capability = this.describe();
|
|
107
|
+
if (!capability.canPurge) return { reason: capability.reason ?? "purging is unavailable on this Host" };
|
|
108
|
+
if (this.ctx.agents.get(sessionId) !== void 0) return { reason: "the session is live, so its log was kept; delete it again after it stops" };
|
|
109
|
+
const persistence = this.ctx.sessionPersistence;
|
|
110
|
+
let headers;
|
|
111
|
+
try {
|
|
112
|
+
headers = await persistence.list(signal);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return { reason: `the durable session list could not be read: ${error instanceof Error ? error.message : String(error)}` };
|
|
115
|
+
}
|
|
116
|
+
const header = headers.find((entry) => entry.id === sessionId);
|
|
117
|
+
if (header === void 0) return { reason: "the session has no durable log on this Host" };
|
|
118
|
+
const location = persistence.locate(header);
|
|
119
|
+
if (location === void 0) return { reason: "this backend keeps no per-session artifact for that session" };
|
|
120
|
+
return { path: location.path };
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
//#endregion
|
|
125
|
+
//#region tsbuild/host/paths.js
|
|
126
|
+
/**
|
|
127
|
+
* Path resolution and containment for every endpoint that takes a path from the browser.
|
|
128
|
+
*
|
|
129
|
+
* A browser-supplied path is untrusted input at a process boundary, so each one is resolved through
|
|
130
|
+
* `ctx.fs` and proved to sit inside the workspace it claims to belong to before any command, read,
|
|
131
|
+
* or launch sees it. `..` and symlinks are handled by the filesystem's own canonicalization rather
|
|
132
|
+
* than by string arithmetic here.
|
|
133
|
+
* @module @achasoft/dsh-advanced-sidebar/host/paths
|
|
134
|
+
*/
|
|
135
|
+
/**
|
|
136
|
+
* Resolve one absolute directory as a workspace root.
|
|
137
|
+
* @param ctx - Host context carrying the optional filesystem capability.
|
|
138
|
+
* @param path - absolute directory path supplied by the browser.
|
|
139
|
+
* @param signal - cancellation for the backend round-trip.
|
|
140
|
+
* @returns the canonical directory, or the reason it was refused.
|
|
141
|
+
*/
|
|
142
|
+
async function resolveWorkspace(ctx, path, signal) {
|
|
143
|
+
const fs = ctx.get("fs");
|
|
144
|
+
if (fs === void 0) return {
|
|
145
|
+
ok: false,
|
|
146
|
+
rejection: {
|
|
147
|
+
code: "no-filesystem",
|
|
148
|
+
message: "no filesystem capability is mounted: this deployment composes no @deepseek-ai/dsh-fs provider"
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
let target;
|
|
152
|
+
try {
|
|
153
|
+
target = await fs.resolve(path, signal === void 0 ? {} : { signal });
|
|
154
|
+
} catch (error) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
rejection: {
|
|
158
|
+
code: "path-denied",
|
|
159
|
+
message: describe(error, path)
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const info = await fs.stat(target, signal);
|
|
164
|
+
if (info === void 0 || info.type !== "directory") return {
|
|
165
|
+
ok: false,
|
|
166
|
+
rejection: {
|
|
167
|
+
code: "path-denied",
|
|
168
|
+
message: `${path} is not a directory`
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
return {
|
|
172
|
+
ok: true,
|
|
173
|
+
value: {
|
|
174
|
+
target,
|
|
175
|
+
processPath: fs.processPath(target)
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Resolve one path and prove it sits inside an already-resolved workspace.
|
|
181
|
+
* @param ctx - Host context carrying the optional filesystem capability.
|
|
182
|
+
* @param workspace - the canonical workspace directory the path must stay within.
|
|
183
|
+
* @param path - absolute path supplied by the browser.
|
|
184
|
+
* @param signal - cancellation for the backend round-trip.
|
|
185
|
+
* @returns the canonical path, or the reason it was refused.
|
|
186
|
+
*/
|
|
187
|
+
async function resolveInside(ctx, workspace, path, signal) {
|
|
188
|
+
const fs = ctx.get("fs");
|
|
189
|
+
/* v8 ignore next 4 -- the caller resolved `workspace` through the same service moments earlier. */
|
|
190
|
+
if (fs === void 0) return {
|
|
191
|
+
ok: false,
|
|
192
|
+
rejection: {
|
|
193
|
+
code: "no-filesystem",
|
|
194
|
+
message: "filesystem capability withdrawn mid-request"
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
let target;
|
|
198
|
+
try {
|
|
199
|
+
target = await fs.resolve(path, signal === void 0 ? {} : { signal });
|
|
200
|
+
} catch (error) {
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
rejection: {
|
|
204
|
+
code: "path-denied",
|
|
205
|
+
message: describe(error, path)
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (!fs.contains(workspace.target, target)) return {
|
|
210
|
+
ok: false,
|
|
211
|
+
rejection: {
|
|
212
|
+
code: "path-denied",
|
|
213
|
+
message: `${path} is outside ${workspace.target.displayPath}`
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
return {
|
|
217
|
+
ok: true,
|
|
218
|
+
value: {
|
|
219
|
+
target,
|
|
220
|
+
processPath: fs.processPath(target)
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Phrase one resolution failure without leaking a stack.
|
|
226
|
+
* @param error - whatever the backend threw.
|
|
227
|
+
* @param path - the path that was being resolved.
|
|
228
|
+
* @returns a single-line operator diagnostic.
|
|
229
|
+
*/
|
|
230
|
+
function describe(error, path) {
|
|
231
|
+
return `cannot resolve ${path}: ${error instanceof Error ? error.message : String(error)}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
//#endregion
|
|
235
|
+
//#region tsbuild/host/files.js
|
|
236
|
+
/**
|
|
237
|
+
* How much of a file's head decides whether it is text. A NUL byte anywhere in this window is the
|
|
238
|
+
* same test `git` uses, and it is cheap enough to apply to every preview.
|
|
239
|
+
*/
|
|
240
|
+
const BINARY_PROBE_BYTES = 8e3;
|
|
241
|
+
/** Reads file previews for the Files panel. */
|
|
242
|
+
var FileReader = class {
|
|
243
|
+
ctx;
|
|
244
|
+
source;
|
|
245
|
+
/**
|
|
246
|
+
* @param ctx - Host context carrying the optional filesystem capability.
|
|
247
|
+
* @param source - reads the current settings section; called per request.
|
|
248
|
+
*/
|
|
249
|
+
constructor(ctx, source) {
|
|
250
|
+
this.ctx = ctx;
|
|
251
|
+
this.source = source;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Report whether a preview can be read on this Host.
|
|
255
|
+
* @returns availability, with the reason when there is no filesystem.
|
|
256
|
+
*/
|
|
257
|
+
describe() {
|
|
258
|
+
if (this.ctx.get("fs") === void 0) return {
|
|
259
|
+
available: false,
|
|
260
|
+
reason: "no filesystem capability is mounted: this deployment composes no @deepseek-ai/dsh-fs provider"
|
|
261
|
+
};
|
|
262
|
+
return { available: true };
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* List one directory level inside a workspace.
|
|
266
|
+
*
|
|
267
|
+
* The Web Client's own `listDirectory` cannot serve this panel: the Host's browse capability
|
|
268
|
+
* returns directories only, because its one caller is a workspace picker. A file browser needs
|
|
269
|
+
* the files.
|
|
270
|
+
* @param request - the directory and the workspace it must stay inside.
|
|
271
|
+
* @param signal - cancellation for the listing.
|
|
272
|
+
* @returns the level, or a classified failure.
|
|
273
|
+
*/
|
|
274
|
+
async list(request, signal) {
|
|
275
|
+
const fs = this.ctx.get("fs");
|
|
276
|
+
if (fs === void 0) return {
|
|
277
|
+
ok: false,
|
|
278
|
+
code: "no-filesystem",
|
|
279
|
+
message: "no filesystem capability is mounted"
|
|
280
|
+
};
|
|
281
|
+
const workspace = await resolveWorkspace(this.ctx, request.workspacePath, signal);
|
|
282
|
+
if (!workspace.ok) return {
|
|
283
|
+
ok: false,
|
|
284
|
+
code: workspace.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied",
|
|
285
|
+
message: workspace.rejection.message
|
|
286
|
+
};
|
|
287
|
+
const directory = await resolveInside(this.ctx, workspace.value, request.path, signal);
|
|
288
|
+
if (!directory.ok) return {
|
|
289
|
+
ok: false,
|
|
290
|
+
code: directory.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied",
|
|
291
|
+
message: directory.rejection.message
|
|
292
|
+
};
|
|
293
|
+
const info = await fs.stat(directory.value.target, signal);
|
|
294
|
+
if (info === void 0 || info.type !== "directory") return {
|
|
295
|
+
ok: false,
|
|
296
|
+
code: "not-a-file",
|
|
297
|
+
message: `${request.path} is not a directory`
|
|
298
|
+
};
|
|
299
|
+
let children;
|
|
300
|
+
try {
|
|
301
|
+
children = await fs.listDir(directory.value.target, signal);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
return {
|
|
304
|
+
ok: false,
|
|
305
|
+
code: "read-failed",
|
|
306
|
+
message: error instanceof Error ? error.message : String(error)
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const settings = this.source();
|
|
310
|
+
const sorted = children.filter((child) => settings.filesShowHidden || !child.name.startsWith(".")).map((child) => ({
|
|
311
|
+
name: child.name,
|
|
312
|
+
path: fs.processPath(child.target),
|
|
313
|
+
kind: child.type,
|
|
314
|
+
...child.size === void 0 ? {} : { size: child.size }
|
|
315
|
+
})).sort((left, right) => {
|
|
316
|
+
const leftDirectory = left.kind === "directory";
|
|
317
|
+
if (leftDirectory !== (right.kind === "directory")) return leftDirectory ? -1 : 1;
|
|
318
|
+
return left.name.localeCompare(right.name);
|
|
319
|
+
});
|
|
320
|
+
const truncated = sorted.length > settings.filesMaxEntries;
|
|
321
|
+
const parent = fs.contains(directory.value.target, workspace.value.target) ? void 0 : dirname(directory.value.processPath);
|
|
322
|
+
return {
|
|
323
|
+
ok: true,
|
|
324
|
+
path: directory.value.processPath,
|
|
325
|
+
...parent === void 0 ? {} : { parent },
|
|
326
|
+
entries: truncated ? sorted.slice(0, settings.filesMaxEntries) : sorted,
|
|
327
|
+
truncated
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Read one file, bounded by `filesMaxPreviewBytes`.
|
|
332
|
+
* @param request - the file and the workspace it must stay inside.
|
|
333
|
+
* @param signal - cancellation for the read.
|
|
334
|
+
* @returns the preview, or a classified failure.
|
|
335
|
+
*/
|
|
336
|
+
async read(request, signal) {
|
|
337
|
+
const fs = this.ctx.get("fs");
|
|
338
|
+
if (fs === void 0) return {
|
|
339
|
+
ok: false,
|
|
340
|
+
code: "no-filesystem",
|
|
341
|
+
message: "no filesystem capability is mounted"
|
|
342
|
+
};
|
|
343
|
+
const workspace = await resolveWorkspace(this.ctx, request.workspacePath, signal);
|
|
344
|
+
if (!workspace.ok) return {
|
|
345
|
+
ok: false,
|
|
346
|
+
code: workspace.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied",
|
|
347
|
+
message: workspace.rejection.message
|
|
348
|
+
};
|
|
349
|
+
const file = await resolveInside(this.ctx, workspace.value, request.path, signal);
|
|
350
|
+
if (!file.ok) return {
|
|
351
|
+
ok: false,
|
|
352
|
+
code: file.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied",
|
|
353
|
+
message: file.rejection.message
|
|
354
|
+
};
|
|
355
|
+
const info = await fs.stat(file.value.target, signal);
|
|
356
|
+
if (info === void 0 || info.type !== "file") return {
|
|
357
|
+
ok: false,
|
|
358
|
+
code: "not-a-file",
|
|
359
|
+
message: `${request.path} is not a regular file`
|
|
360
|
+
};
|
|
361
|
+
const max = this.source().filesMaxPreviewBytes;
|
|
362
|
+
let bytes;
|
|
363
|
+
try {
|
|
364
|
+
bytes = await fs.readBytes(file.value.target, signal, max);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
return {
|
|
367
|
+
ok: false,
|
|
368
|
+
code: "read-failed",
|
|
369
|
+
message: error instanceof Error ? error.message : String(error)
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
const size = info.size ?? bytes.byteLength;
|
|
373
|
+
if (isBinary(bytes)) return {
|
|
374
|
+
ok: true,
|
|
375
|
+
path: request.path,
|
|
376
|
+
text: "",
|
|
377
|
+
binary: true,
|
|
378
|
+
truncated: false,
|
|
379
|
+
bytes: size
|
|
380
|
+
};
|
|
381
|
+
return {
|
|
382
|
+
ok: true,
|
|
383
|
+
path: request.path,
|
|
384
|
+
text: new TextDecoder("utf-8", { fatal: false }).decode(bytes),
|
|
385
|
+
binary: false,
|
|
386
|
+
truncated: size > bytes.byteLength,
|
|
387
|
+
bytes: size
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
/**
|
|
392
|
+
* Whether a file's leading bytes look like something other than text.
|
|
393
|
+
* @param bytes - the read window.
|
|
394
|
+
* @returns true when a NUL appears in the probe window.
|
|
395
|
+
*/
|
|
396
|
+
function isBinary(bytes) {
|
|
397
|
+
const limit = Math.min(bytes.byteLength, BINARY_PROBE_BYTES);
|
|
398
|
+
for (let index = 0; index < limit; index += 1) if (bytes[index] === 0) return true;
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
//#endregion
|
|
403
|
+
//#region tsbuild/host/porcelain.js
|
|
404
|
+
/**
|
|
405
|
+
* Pure parser for `git status --porcelain=v2 --branch -z`.
|
|
406
|
+
*
|
|
407
|
+
* Kept apart from the command runner so the format — which is the only part of the git integration
|
|
408
|
+
* with edge cases worth testing (renames carry two paths, `-z` terminates every line including the
|
|
409
|
+
* headers, and a path may contain any byte but NUL) — is exercised without a subprocess.
|
|
410
|
+
* @module @achasoft/dsh-advanced-sidebar/host/porcelain
|
|
411
|
+
*/
|
|
412
|
+
/**
|
|
413
|
+
* `--porcelain=v2` status letters. `.` is git's spelling of "nothing to report in this index",
|
|
414
|
+
* which is why {@link GitFileState} carries `unmodified` rather than leaving the field absent.
|
|
415
|
+
*/
|
|
416
|
+
const STATE_BY_LETTER = {
|
|
417
|
+
".": "unmodified",
|
|
418
|
+
"M": "modified",
|
|
419
|
+
"T": "typechange",
|
|
420
|
+
"A": "added",
|
|
421
|
+
"D": "deleted",
|
|
422
|
+
"R": "renamed",
|
|
423
|
+
"C": "copied",
|
|
424
|
+
"U": "conflicted"
|
|
425
|
+
};
|
|
426
|
+
/**
|
|
427
|
+
* Map one status letter.
|
|
428
|
+
* @param letter - a single `XY` character.
|
|
429
|
+
* @returns the state, or `modified` for a letter a newer git introduced after this parser.
|
|
430
|
+
*/
|
|
431
|
+
function state(letter) {
|
|
432
|
+
if (letter === void 0) return "unmodified";
|
|
433
|
+
return STATE_BY_LETTER[letter] ?? "modified";
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Split one `1`/`2`/`u` record into its space-separated fields plus the trailing path.
|
|
437
|
+
*
|
|
438
|
+
* The path is the remainder after a fixed field count, never a split result: a path may contain
|
|
439
|
+
* spaces, and splitting it would silently truncate every such file to its first word.
|
|
440
|
+
* @param record - the record text without its NUL terminator.
|
|
441
|
+
* @param fields - how many space-separated fields precede the path (including the leading type).
|
|
442
|
+
* @returns the fields and the untouched path remainder, or undefined for a malformed record.
|
|
443
|
+
*/
|
|
444
|
+
function splitRecord(record, fields) {
|
|
445
|
+
const head = [];
|
|
446
|
+
let at = 0;
|
|
447
|
+
for (let index = 0; index < fields; index += 1) {
|
|
448
|
+
const space = record.indexOf(" ", at);
|
|
449
|
+
if (space < 0) return void 0;
|
|
450
|
+
head.push(record.slice(at, space));
|
|
451
|
+
at = space + 1;
|
|
452
|
+
}
|
|
453
|
+
const path = record.slice(at);
|
|
454
|
+
return path === "" ? void 0 : {
|
|
455
|
+
head,
|
|
456
|
+
path
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Parse the `# branch.ab +A -B` header.
|
|
461
|
+
* @param value - the header's value text.
|
|
462
|
+
* @returns ahead and behind counts; zeros when the header is malformed.
|
|
463
|
+
*/
|
|
464
|
+
function parseAheadBehind(value) {
|
|
465
|
+
const match = /^\+(\d+)\s+-(\d+)$/u.exec(value.trim());
|
|
466
|
+
if (match === null) return {
|
|
467
|
+
ahead: 0,
|
|
468
|
+
behind: 0
|
|
469
|
+
};
|
|
470
|
+
return {
|
|
471
|
+
ahead: Number(match[1]),
|
|
472
|
+
behind: Number(match[2])
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Parse one complete `--porcelain=v2 --branch -z` payload.
|
|
477
|
+
*
|
|
478
|
+
* Unknown record types are skipped rather than rejected: git adds record kinds over time, and a
|
|
479
|
+
* reading that lists every change it understood is more useful than one that refuses the whole
|
|
480
|
+
* repository because a future git emitted a line this parser has not met.
|
|
481
|
+
* @param payload - git's stdout, verbatim.
|
|
482
|
+
* @returns the branch facts and every parsed change.
|
|
483
|
+
*/
|
|
484
|
+
function parsePorcelainV2(payload) {
|
|
485
|
+
const branch = {
|
|
486
|
+
ahead: 0,
|
|
487
|
+
behind: 0,
|
|
488
|
+
detached: false
|
|
489
|
+
};
|
|
490
|
+
const changes = [];
|
|
491
|
+
const chunks = payload.split("\0");
|
|
492
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
493
|
+
const chunk = chunks[index];
|
|
494
|
+
if (chunk === void 0 || chunk === "") continue;
|
|
495
|
+
if (chunk.startsWith("# ")) {
|
|
496
|
+
const space = chunk.indexOf(" ", 2);
|
|
497
|
+
const key = space < 0 ? chunk.slice(2) : chunk.slice(2, space);
|
|
498
|
+
const value = space < 0 ? "" : chunk.slice(space + 1);
|
|
499
|
+
if (key === "branch.head") if (value === "(detached)") branch.detached = true;
|
|
500
|
+
else branch.branch = value;
|
|
501
|
+
else if (key === "branch.upstream") branch.upstream = value;
|
|
502
|
+
else if (key === "branch.ab") {
|
|
503
|
+
const { ahead, behind } = parseAheadBehind(value);
|
|
504
|
+
branch.ahead = ahead;
|
|
505
|
+
branch.behind = behind;
|
|
506
|
+
}
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (chunk.startsWith("? ")) {
|
|
510
|
+
const path = chunk.slice(2);
|
|
511
|
+
if (path !== "") changes.push({
|
|
512
|
+
path,
|
|
513
|
+
index: "unmodified",
|
|
514
|
+
worktree: "untracked",
|
|
515
|
+
untracked: true,
|
|
516
|
+
conflicted: false
|
|
517
|
+
});
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (chunk.startsWith("! ")) continue;
|
|
521
|
+
if (chunk.startsWith("1 ")) {
|
|
522
|
+
const parsed = splitRecord(chunk, 8);
|
|
523
|
+
if (parsed === void 0) continue;
|
|
524
|
+
const xy = parsed.head[1] ?? "..";
|
|
525
|
+
changes.push({
|
|
526
|
+
path: parsed.path,
|
|
527
|
+
index: state(xy[0]),
|
|
528
|
+
worktree: state(xy[1]),
|
|
529
|
+
untracked: false,
|
|
530
|
+
conflicted: false
|
|
531
|
+
});
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (chunk.startsWith("2 ")) {
|
|
535
|
+
const parsed = splitRecord(chunk, 9);
|
|
536
|
+
const original = chunks[index + 1];
|
|
537
|
+
index += 1;
|
|
538
|
+
if (parsed === void 0) continue;
|
|
539
|
+
const xy = parsed.head[1] ?? "..";
|
|
540
|
+
changes.push({
|
|
541
|
+
path: parsed.path,
|
|
542
|
+
...original === void 0 || original === "" ? {} : { oldPath: original },
|
|
543
|
+
index: state(xy[0]),
|
|
544
|
+
worktree: state(xy[1]),
|
|
545
|
+
untracked: false,
|
|
546
|
+
conflicted: false
|
|
547
|
+
});
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (chunk.startsWith("u ")) {
|
|
551
|
+
const parsed = splitRecord(chunk, 10);
|
|
552
|
+
if (parsed === void 0) continue;
|
|
553
|
+
changes.push({
|
|
554
|
+
path: parsed.path,
|
|
555
|
+
index: "conflicted",
|
|
556
|
+
worktree: "conflicted",
|
|
557
|
+
untracked: false,
|
|
558
|
+
conflicted: true
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return {
|
|
563
|
+
branch,
|
|
564
|
+
changes
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Whether a change belongs in the staged group — its index state differs from HEAD.
|
|
569
|
+
* @param change - one parsed change.
|
|
570
|
+
* @returns true when a commit right now would record something for this path.
|
|
571
|
+
*/
|
|
572
|
+
function isStaged(change) {
|
|
573
|
+
return !change.untracked && !change.conflicted && change.index !== "unmodified";
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Whether a change belongs in the unstaged group — its working tree differs from the index.
|
|
577
|
+
* @param change - one parsed change.
|
|
578
|
+
* @returns true when the path has edits no commit would record yet.
|
|
579
|
+
*/
|
|
580
|
+
function isUnstaged(change) {
|
|
581
|
+
return !change.untracked && !change.conflicted && change.worktree !== "unmodified";
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
//#endregion
|
|
585
|
+
//#region tsbuild/host/run.js
|
|
586
|
+
/**
|
|
587
|
+
* One-shot command execution over `ctx.subprocess`, shared by the git reader and the Open in
|
|
588
|
+
* launcher.
|
|
589
|
+
*
|
|
590
|
+
* The subprocess seam applies no defaults, so every disposition, bound, and grace period is stated
|
|
591
|
+
* here from this plugin's own settings — which is also what keeps the two callers' behavior
|
|
592
|
+
* configurable from cordis.yml rather than from constants buried in a runner.
|
|
593
|
+
* @module @achasoft/dsh-advanced-sidebar/host/run
|
|
594
|
+
*/
|
|
595
|
+
/** No filesystem/subprocess capability, or a spawn that never produced a process. */
|
|
596
|
+
var CommandUnavailableError = class extends Error {
|
|
597
|
+
/**
|
|
598
|
+
* @param message - operator diagnostic naming what was missing.
|
|
599
|
+
*/
|
|
600
|
+
constructor(message) {
|
|
601
|
+
super(message);
|
|
602
|
+
this.name = "CommandUnavailableError";
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
/**
|
|
606
|
+
* Read one collect-mode stream in full.
|
|
607
|
+
* @param reader - the stream's offset reader, present by the spawn's own disposition.
|
|
608
|
+
* @returns the retained text and whether the head was dropped to fit the cap.
|
|
609
|
+
*/
|
|
610
|
+
function drain(reader) {
|
|
611
|
+
/* v8 ignore next -- both streams are spawned in collect mode, so both readers exist. */
|
|
612
|
+
if (reader === void 0) return {
|
|
613
|
+
text: "",
|
|
614
|
+
lossy: false
|
|
615
|
+
};
|
|
616
|
+
const read = reader.readFrom(0);
|
|
617
|
+
return {
|
|
618
|
+
text: read.text,
|
|
619
|
+
lossy: read.lossy
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Resolve one executable, returning undefined rather than throwing when it is absent.
|
|
624
|
+
* @param ctx - Host context carrying the optional subprocess capability.
|
|
625
|
+
* @param command - executable name or absolute path.
|
|
626
|
+
* @param signal - cancellation for the lookup.
|
|
627
|
+
* @returns the canonical executable path, or undefined when it does not resolve.
|
|
628
|
+
* @throws {CommandUnavailableError} when no subprocess capability is mounted.
|
|
629
|
+
*/
|
|
630
|
+
async function resolveCommand(ctx, command, signal) {
|
|
631
|
+
const subprocess = ctx.get("subprocess");
|
|
632
|
+
if (subprocess === void 0) throw new CommandUnavailableError("no subprocess capability is mounted: this deployment composes no @deepseek-ai/dsh-subprocess provider");
|
|
633
|
+
try {
|
|
634
|
+
return await subprocess.resolveExecutable(command, void 0, signal);
|
|
635
|
+
} catch {
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Run one command to completion under a timeout.
|
|
641
|
+
* @param ctx - Host context carrying the optional subprocess capability.
|
|
642
|
+
* @param spec - the fully specified invocation.
|
|
643
|
+
* @param signal - the caller's cancellation, distinguished from the timeout in the outcome.
|
|
644
|
+
* @returns exit facts and collected output.
|
|
645
|
+
* @throws {CommandUnavailableError} when no subprocess capability is mounted.
|
|
646
|
+
*/
|
|
647
|
+
async function runCommand(ctx, spec, signal) {
|
|
648
|
+
const subprocess = ctx.get("subprocess");
|
|
649
|
+
if (subprocess === void 0) throw new CommandUnavailableError("no subprocess capability is mounted: this deployment composes no @deepseek-ai/dsh-subprocess provider");
|
|
650
|
+
const timeout = AbortSignal.timeout(spec.timeoutMs);
|
|
651
|
+
const combined = signal === void 0 ? timeout : AbortSignal.any([signal, timeout]);
|
|
652
|
+
const handle = subprocess.spawn({
|
|
653
|
+
argv: spec.argv,
|
|
654
|
+
cwd: spec.cwd,
|
|
655
|
+
stdio: {
|
|
656
|
+
stdin: "ignore",
|
|
657
|
+
stdout: { maxBytes: spec.maxBytes },
|
|
658
|
+
stderr: { maxBytes: spec.maxBytes }
|
|
659
|
+
},
|
|
660
|
+
graceMs: spec.graceMs,
|
|
661
|
+
signal: combined,
|
|
662
|
+
...spec.env === void 0 ? {} : { env: spec.env }
|
|
663
|
+
});
|
|
664
|
+
const outcome = await handle.done;
|
|
665
|
+
const stdout = drain(handle.collected.stdout);
|
|
666
|
+
return {
|
|
667
|
+
exitCode: outcome.exitCode,
|
|
668
|
+
signal: outcome.signal,
|
|
669
|
+
stdout: stdout.text,
|
|
670
|
+
stderr: drain(handle.collected.stderr).text,
|
|
671
|
+
timedOut: timeout.aborted,
|
|
672
|
+
aborted: signal?.aborted === true && !timeout.aborted,
|
|
673
|
+
stdoutLossy: stdout.lossy
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
//#endregion
|
|
678
|
+
//#region tsbuild/host/git.js
|
|
679
|
+
/**
|
|
680
|
+
* `git`'s own separator between the two paths of a rename record and between status lines.
|
|
681
|
+
* Requested explicitly (`-z`) so a path containing a newline or a quote cannot be misread — git's
|
|
682
|
+
* default output would C-quote such a path and this parser would then compare a quoted spelling
|
|
683
|
+
* against the unquoted one a later diff request repeats.
|
|
684
|
+
*/
|
|
685
|
+
const NUL_ARGS = ["-z"];
|
|
686
|
+
/** Exit code of the synthetic outcome produced when the `git` binary does not resolve. */
|
|
687
|
+
const MISSING_BINARY_EXIT = 127;
|
|
688
|
+
/** The remote a branch with no upstream is published to when nothing else names one. */
|
|
689
|
+
const DEFAULT_REMOTE = "origin";
|
|
690
|
+
/**
|
|
691
|
+
* What the model is told a commit message is, when the settings section supplies no prompt of its
|
|
692
|
+
* own.
|
|
693
|
+
*
|
|
694
|
+
* It states the format because a model asked for "a commit message" writes a paragraph as readily
|
|
695
|
+
* as a subject line, and the panel puts the answer straight into the box a person then edits.
|
|
696
|
+
*/
|
|
697
|
+
const DEFAULT_COMMIT_PROMPT = [
|
|
698
|
+
"Write a git commit message for the supplied diff.",
|
|
699
|
+
"Answer with the message alone: no preamble, no explanation, no code fences, no quotes.",
|
|
700
|
+
"First line: imperative mood, under 72 characters, no trailing period.",
|
|
701
|
+
"Then, only if the change needs it, a blank line and a short body explaining WHY rather than restating the diff. Wrap the body at 72 columns.",
|
|
702
|
+
"Describe what the diff actually does. Do not invent a motive it does not show."
|
|
703
|
+
].join("\n");
|
|
704
|
+
/** Compose one classified failure. */
|
|
705
|
+
function fail$4(code, message) {
|
|
706
|
+
return {
|
|
707
|
+
ok: false,
|
|
708
|
+
code,
|
|
709
|
+
message
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
/** First non-empty line of git's stderr, which is the part worth showing. */
|
|
713
|
+
function stderrLine(outcome) {
|
|
714
|
+
return outcome.stderr.split("\n").map((part) => part.trim()).find((part) => part !== "") ?? `git exited with code ${String(outcome.exitCode)}`;
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Turn a non-zero exit into the right classified failure.
|
|
718
|
+
* @param outcome - the finished command.
|
|
719
|
+
* @returns the failure a caller should return.
|
|
720
|
+
*/
|
|
721
|
+
function classify(outcome) {
|
|
722
|
+
if (outcome.timedOut) return fail$4("timeout", "git did not finish within gitTimeoutMs");
|
|
723
|
+
if (outcome.aborted) return fail$4("cancelled", "the request was abandoned before git finished");
|
|
724
|
+
if (outcome.exitCode === MISSING_BINARY_EXIT) return fail$4("no-git", "git is not installed, or not on this Host process PATH");
|
|
725
|
+
const message = stderrLine(outcome);
|
|
726
|
+
if (/not a git repository/iu.test(message)) return fail$4("not-a-repository", message);
|
|
727
|
+
return fail$4("git-failed", message);
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Take a model's answer down to the message itself.
|
|
731
|
+
*
|
|
732
|
+
* A model asked for plain text still fences it often enough that the panel would otherwise put
|
|
733
|
+
* ```` ``` ```` into a commit; the wrapper is removed only when it wraps the WHOLE answer, so a
|
|
734
|
+
* message that legitimately quotes a fenced block keeps it.
|
|
735
|
+
* @param text - what the model streamed.
|
|
736
|
+
* @returns the message, trimmed.
|
|
737
|
+
*/
|
|
738
|
+
function stripFence(text) {
|
|
739
|
+
const trimmed = text.trim();
|
|
740
|
+
return (/^```[^\n]*\n([\s\S]*)\n```$/u.exec(trimmed)?.[1] ?? trimmed).trim();
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Reads one workspace's git state. One instance serves every request; the resolved `git` path is
|
|
744
|
+
* cached across calls and dropped whenever a lookup fails, so installing git later needs no restart.
|
|
745
|
+
*/
|
|
746
|
+
var GitReader = class {
|
|
747
|
+
ctx;
|
|
748
|
+
source;
|
|
749
|
+
executable;
|
|
750
|
+
version;
|
|
751
|
+
/**
|
|
752
|
+
* @param ctx - Host context carrying the subprocess and filesystem capabilities.
|
|
753
|
+
* @param source - reads the current settings section; called per request so a committed change
|
|
754
|
+
* reaches the next reading with no registration to rebuild.
|
|
755
|
+
*/
|
|
756
|
+
constructor(ctx, source) {
|
|
757
|
+
this.ctx = ctx;
|
|
758
|
+
this.source = source;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Report whether git can answer on this Host.
|
|
762
|
+
* @param signal - cancellation for the lookup.
|
|
763
|
+
* @returns availability plus the version string when one was read.
|
|
764
|
+
*/
|
|
765
|
+
async describe(signal) {
|
|
766
|
+
let executable;
|
|
767
|
+
try {
|
|
768
|
+
executable = await this.locate(signal);
|
|
769
|
+
} catch (error) {
|
|
770
|
+
/* v8 ignore next -- only a missing subprocess capability reaches here. */
|
|
771
|
+
return {
|
|
772
|
+
available: false,
|
|
773
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
if (executable === void 0) return {
|
|
777
|
+
available: false,
|
|
778
|
+
reason: "git is not installed, or not on this Host process PATH"
|
|
779
|
+
};
|
|
780
|
+
return {
|
|
781
|
+
available: true,
|
|
782
|
+
...this.version === void 0 ? {} : { detail: this.version }
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Read one workspace's changed paths.
|
|
787
|
+
* @param request - the workspace directory to read.
|
|
788
|
+
* @param signal - cancellation for the reading.
|
|
789
|
+
* @returns the reading, or a classified failure.
|
|
790
|
+
*/
|
|
791
|
+
async status(request, signal) {
|
|
792
|
+
const prepared = await this.prepare(request.workspacePath, signal);
|
|
793
|
+
if ("failure" in prepared) return prepared.failure;
|
|
794
|
+
const { repository, workspace } = prepared;
|
|
795
|
+
const outcome = await this.git(workspace.value.processPath, [
|
|
796
|
+
"status",
|
|
797
|
+
"--porcelain=v2",
|
|
798
|
+
"--branch",
|
|
799
|
+
"--untracked-files=all",
|
|
800
|
+
...NUL_ARGS
|
|
801
|
+
], signal);
|
|
802
|
+
if (outcome.exitCode !== 0) return classify(outcome);
|
|
803
|
+
const parsed = parsePorcelainV2(outcome.stdout);
|
|
804
|
+
const limit = this.source().gitMaxFiles;
|
|
805
|
+
const truncated = outcome.stdoutLossy || parsed.changes.length > limit;
|
|
806
|
+
const changes = truncated ? parsed.changes.slice(0, limit) : parsed.changes;
|
|
807
|
+
const conflicted = changes.filter((change) => change.conflicted);
|
|
808
|
+
return {
|
|
809
|
+
ok: true,
|
|
810
|
+
write: await this.writeCapability(repository.root, signal),
|
|
811
|
+
repositoryRoot: repository.root,
|
|
812
|
+
prefix: repository.prefix,
|
|
813
|
+
...parsed.branch.branch === void 0 ? {} : { branch: parsed.branch.branch },
|
|
814
|
+
...parsed.branch.upstream === void 0 ? {} : { upstream: parsed.branch.upstream },
|
|
815
|
+
ahead: parsed.branch.ahead,
|
|
816
|
+
behind: parsed.branch.behind,
|
|
817
|
+
detached: parsed.branch.detached,
|
|
818
|
+
staged: changes.filter(isStaged),
|
|
819
|
+
unstaged: changes.filter(isUnstaged),
|
|
820
|
+
untracked: changes.filter((change) => change.untracked),
|
|
821
|
+
conflicted,
|
|
822
|
+
truncated,
|
|
823
|
+
readAt: Date.now()
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Read one path's patch.
|
|
828
|
+
* @param request - which path, and which of the two indexes to compare.
|
|
829
|
+
* @param signal - cancellation for the reading.
|
|
830
|
+
* @returns the patch, or a classified failure.
|
|
831
|
+
*/
|
|
832
|
+
async diff(request, signal) {
|
|
833
|
+
const prepared = await this.prepare(request.workspacePath, signal);
|
|
834
|
+
if ("failure" in prepared) return prepared.failure;
|
|
835
|
+
const { repository } = prepared;
|
|
836
|
+
const contained = await this.contain(repository.root, [request.path], signal);
|
|
837
|
+
if (contained !== void 0) return contained;
|
|
838
|
+
const common = [
|
|
839
|
+
"--no-pager",
|
|
840
|
+
"diff",
|
|
841
|
+
"--no-color",
|
|
842
|
+
"--no-ext-diff"
|
|
843
|
+
];
|
|
844
|
+
const argv = request.untracked ? [
|
|
845
|
+
...common,
|
|
846
|
+
"--no-index",
|
|
847
|
+
"--",
|
|
848
|
+
devNull(),
|
|
849
|
+
request.path
|
|
850
|
+
] : [
|
|
851
|
+
...common,
|
|
852
|
+
...request.staged ? ["--cached"] : [],
|
|
853
|
+
"--",
|
|
854
|
+
request.path
|
|
855
|
+
];
|
|
856
|
+
const max = this.source().gitDiffMaxBytes;
|
|
857
|
+
const outcome = await this.git(repository.root, argv, signal, max);
|
|
858
|
+
const differed = request.untracked && outcome.exitCode === 1 && outcome.stderr.trim() === "";
|
|
859
|
+
if (outcome.exitCode !== 0 && !differed) return classify(outcome);
|
|
860
|
+
const patch = outcome.stdout;
|
|
861
|
+
const truncated = outcome.stdoutLossy || patch.length > max;
|
|
862
|
+
return {
|
|
863
|
+
ok: true,
|
|
864
|
+
path: request.path,
|
|
865
|
+
patch: patch.length > max ? patch.slice(0, max) : patch,
|
|
866
|
+
binary: /^Binary files .* differ$/mu.test(patch),
|
|
867
|
+
truncated
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Stage paths.
|
|
872
|
+
* @param request - the workspace and the repository-relative paths to add.
|
|
873
|
+
* @param signal - cancellation for the write and the reading that follows it.
|
|
874
|
+
* @returns the reading after the write, or a classified failure.
|
|
875
|
+
*/
|
|
876
|
+
stage(request, signal) {
|
|
877
|
+
if (!this.source().allowGitStaging) return Promise.resolve(fail$4("disabled", "staging is switched off in the advanced-sidebar settings"));
|
|
878
|
+
return this.write(request, signal, (paths) => [
|
|
879
|
+
"add",
|
|
880
|
+
"--",
|
|
881
|
+
...paths
|
|
882
|
+
]);
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Unstage paths, leaving the working tree untouched.
|
|
886
|
+
* @param request - the workspace and the repository-relative paths to restore.
|
|
887
|
+
* @param signal - cancellation for the write and the reading that follows it.
|
|
888
|
+
* @returns the reading after the write, or a classified failure.
|
|
889
|
+
*/
|
|
890
|
+
unstage(request, signal) {
|
|
891
|
+
if (!this.source().allowGitStaging) return Promise.resolve(fail$4("disabled", "staging is switched off in the advanced-sidebar settings"));
|
|
892
|
+
return this.write(request, signal, (paths) => [
|
|
893
|
+
"restore",
|
|
894
|
+
"--staged",
|
|
895
|
+
"--",
|
|
896
|
+
...paths
|
|
897
|
+
]);
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Record the staged changes.
|
|
901
|
+
*
|
|
902
|
+
* The message crosses as one argv element, so no shell ever sees it and nothing in it can become
|
|
903
|
+
* an option. Hooks run: a `pre-commit` that refuses is a real answer, and its stderr is returned
|
|
904
|
+
* verbatim rather than summarized.
|
|
905
|
+
* @param request - the workspace, the message, and whether to replace the previous commit.
|
|
906
|
+
* @param signal - cancellation for the commit and the reading that follows it.
|
|
907
|
+
* @returns the new commit and the reading after it, or a classified failure.
|
|
908
|
+
*/
|
|
909
|
+
async commit(request, signal) {
|
|
910
|
+
const settings = this.source();
|
|
911
|
+
if (!settings.allowGitCommit) return fail$4("disabled", "committing is switched off in the advanced-sidebar settings");
|
|
912
|
+
const message = request.message.trim();
|
|
913
|
+
if (message === "") return fail$4("empty-message", "a commit needs a message");
|
|
914
|
+
const prepared = await this.prepare(request.workspacePath, signal);
|
|
915
|
+
if ("failure" in prepared) return prepared.failure;
|
|
916
|
+
const { repository } = prepared;
|
|
917
|
+
if (await this.author(repository.root, signal) === void 0) return fail$4("no-identity", "git has no user.name and user.email, so it has no author to record; set them with `git config --global user.name` and `git config --global user.email`");
|
|
918
|
+
if (!request.amend && !await this.hasStaged(repository.root, signal)) return fail$4("nothing-staged", "nothing is staged, so there is nothing to commit");
|
|
919
|
+
const outcome = await this.git(repository.root, [
|
|
920
|
+
"commit",
|
|
921
|
+
...request.amend ? ["--amend"] : [],
|
|
922
|
+
"-m",
|
|
923
|
+
message
|
|
924
|
+
], signal, void 0, settings.gitCommitTimeoutMs);
|
|
925
|
+
if (outcome.exitCode !== 0) return classify(outcome);
|
|
926
|
+
const [commit, subject] = (await this.git(repository.root, [
|
|
927
|
+
"log",
|
|
928
|
+
"-1",
|
|
929
|
+
"--format=%h%n%s"
|
|
930
|
+
], signal)).stdout.split("\n");
|
|
931
|
+
const status = await this.status({ workspacePath: request.workspacePath }, signal);
|
|
932
|
+
if (!status.ok) return status;
|
|
933
|
+
return {
|
|
934
|
+
ok: true,
|
|
935
|
+
commit: (commit ?? "").trim(),
|
|
936
|
+
subject: (subject ?? "").trim(),
|
|
937
|
+
status,
|
|
938
|
+
notes: outcome.stderr.trim()
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Send the current branch's commits to its remote.
|
|
943
|
+
*
|
|
944
|
+
* Only the current branch, and only to its own upstream: a refspec assembled from browser text
|
|
945
|
+
* would let one button push anything anywhere, and `git push` with no arguments already means
|
|
946
|
+
* exactly what the panel offers. A branch with no upstream is published only when the caller asks
|
|
947
|
+
* for it, because choosing a remote is a decision rather than a default.
|
|
948
|
+
* @param request - the workspace, and whether an unpublished branch may be published.
|
|
949
|
+
* @param signal - cancellation; the network wait runs under `gitPushTimeoutMs`.
|
|
950
|
+
* @returns the push, the reading after it, or a classified failure.
|
|
951
|
+
*/
|
|
952
|
+
async push(request, signal) {
|
|
953
|
+
const settings = this.source();
|
|
954
|
+
if (!settings.allowGitPush) return fail$4("disabled", "pushing is switched off in the advanced-sidebar settings");
|
|
955
|
+
const prepared = await this.prepare(request.workspacePath, signal);
|
|
956
|
+
if ("failure" in prepared) return prepared.failure;
|
|
957
|
+
const { repository } = prepared;
|
|
958
|
+
const before = await this.status({ workspacePath: request.workspacePath }, signal);
|
|
959
|
+
if (!before.ok) return before;
|
|
960
|
+
if (before.detached || before.branch === void 0) return fail$4("detached-head", "HEAD names a commit rather than a branch, so there is nothing to push");
|
|
961
|
+
const branch = before.branch;
|
|
962
|
+
const upstream = before.upstream;
|
|
963
|
+
if (upstream === void 0 && !request.setUpstream) return fail$4("no-upstream", `${branch} has no upstream; publish it to a remote first, or use Publish to record one`);
|
|
964
|
+
const remote = upstream === void 0 ? await this.defaultRemote(repository.root, signal) : upstream.split("/")[0] ?? DEFAULT_REMOTE;
|
|
965
|
+
if (remote === void 0) return fail$4("no-upstream", "this repository has no remote to push to");
|
|
966
|
+
const outcome = await this.git(repository.root, upstream === void 0 ? [
|
|
967
|
+
"push",
|
|
968
|
+
"--set-upstream",
|
|
969
|
+
remote,
|
|
970
|
+
branch
|
|
971
|
+
] : ["push"], signal, void 0, settings.gitPushTimeoutMs);
|
|
972
|
+
if (outcome.exitCode !== 0) return classify(outcome);
|
|
973
|
+
const status = await this.status({ workspacePath: request.workspacePath }, signal);
|
|
974
|
+
if (!status.ok) return status;
|
|
975
|
+
return {
|
|
976
|
+
ok: true,
|
|
977
|
+
branch,
|
|
978
|
+
remote,
|
|
979
|
+
published: upstream === void 0,
|
|
980
|
+
status,
|
|
981
|
+
notes: `${outcome.stdout}\n${outcome.stderr}`.trim()
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Ask the deployment's own model to write a commit message for what is staged.
|
|
986
|
+
*
|
|
987
|
+
* The model sees the staged patch and nothing else — not the working tree, not the repository's
|
|
988
|
+
* history, not the session. It is the same model the composer is set to, so this needs no second
|
|
989
|
+
* credential and no second provider; a Host with no model reports the verb unavailable instead.
|
|
990
|
+
* @param request - the workspace, and whether the message is for an amend.
|
|
991
|
+
* @param signal - cancellation for the readings and the model call.
|
|
992
|
+
* @returns the drafted message, or a classified failure.
|
|
993
|
+
*/
|
|
994
|
+
async draftCommitMessage(request, signal) {
|
|
995
|
+
const settings = this.source();
|
|
996
|
+
if (!settings.allowGitCommit || !settings.allowCommitMessageDraft) return fail$4("disabled", "the drafted commit message is switched off in the advanced-sidebar settings");
|
|
997
|
+
const llm = this.ctx.get("llm");
|
|
998
|
+
const models = this.ctx.get("agentDefaultModel");
|
|
999
|
+
if (llm === void 0 || models === void 0) return fail$4("no-model", "no model is configured for this deployment");
|
|
1000
|
+
const prepared = await this.prepare(request.workspacePath, signal);
|
|
1001
|
+
if ("failure" in prepared) return prepared.failure;
|
|
1002
|
+
const { repository } = prepared;
|
|
1003
|
+
const patch = await this.stagedPatch(repository.root, request.amend, signal);
|
|
1004
|
+
if ("failure" in patch) return patch.failure;
|
|
1005
|
+
if (patch.text.trim() === "") return fail$4("nothing-staged", "nothing is staged, so there is nothing to describe");
|
|
1006
|
+
const selection = models.currentSelection();
|
|
1007
|
+
try {
|
|
1008
|
+
let drafted = "";
|
|
1009
|
+
for await (const chunk of llm.stream({
|
|
1010
|
+
provider: selection.provider,
|
|
1011
|
+
model: selection.model,
|
|
1012
|
+
...selection.reasoningEffort === void 0 ? {} : { reasoningEffort: selection.reasoningEffort },
|
|
1013
|
+
system: settings.commitMessagePrompt.trim() === "" ? DEFAULT_COMMIT_PROMPT : settings.commitMessagePrompt,
|
|
1014
|
+
messages: [createUserMessage({
|
|
1015
|
+
content: [{
|
|
1016
|
+
type: "text",
|
|
1017
|
+
text: patch.text
|
|
1018
|
+
}],
|
|
1019
|
+
source: {
|
|
1020
|
+
kind: "plugin",
|
|
1021
|
+
plugin: "dsh-advanced-sidebar"
|
|
1022
|
+
}
|
|
1023
|
+
})],
|
|
1024
|
+
...signal === void 0 ? {} : { signal }
|
|
1025
|
+
})) if (chunk.type === "text-delta") drafted += chunk.text;
|
|
1026
|
+
const message = stripFence(drafted);
|
|
1027
|
+
if (message === "") return fail$4("llm-failed", "the model returned no message");
|
|
1028
|
+
return {
|
|
1029
|
+
ok: true,
|
|
1030
|
+
message,
|
|
1031
|
+
model: `${selection.provider}/${selection.model}`,
|
|
1032
|
+
truncated: patch.truncated
|
|
1033
|
+
};
|
|
1034
|
+
} catch (error) {
|
|
1035
|
+
if (signal?.aborted === true) return fail$4("cancelled", "the request was abandoned");
|
|
1036
|
+
return fail$4("llm-failed", error instanceof Error ? error.message : "the model request failed");
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* The patch a drafted message describes, bounded so a large change cannot become a large request.
|
|
1041
|
+
* @param root - absolute repository root.
|
|
1042
|
+
* @param amend - describe the previous commit's content as well as the index.
|
|
1043
|
+
* @param signal - cancellation for the invocations.
|
|
1044
|
+
* @returns the patch and whether it was cut, or the failure to return.
|
|
1045
|
+
*/
|
|
1046
|
+
async stagedPatch(root, amend, signal) {
|
|
1047
|
+
const settings = this.source();
|
|
1048
|
+
const base = amend && await this.hasParent(root, signal) ? ["HEAD~1"] : [];
|
|
1049
|
+
const stat = await this.git(root, [
|
|
1050
|
+
"diff",
|
|
1051
|
+
"--cached",
|
|
1052
|
+
"--stat",
|
|
1053
|
+
...base
|
|
1054
|
+
], signal);
|
|
1055
|
+
if (stat.exitCode !== 0) return { failure: classify(stat) };
|
|
1056
|
+
const cap = settings.commitMessageMaxBytes;
|
|
1057
|
+
const patch = await this.git(root, [
|
|
1058
|
+
"diff",
|
|
1059
|
+
"--cached",
|
|
1060
|
+
"--no-color",
|
|
1061
|
+
...base
|
|
1062
|
+
], signal, cap);
|
|
1063
|
+
if (patch.exitCode !== 0) return { failure: classify(patch) };
|
|
1064
|
+
const truncated = patch.stdoutLossy || patch.stdout.length >= cap;
|
|
1065
|
+
return {
|
|
1066
|
+
text: [
|
|
1067
|
+
stat.stdout.trim(),
|
|
1068
|
+
"",
|
|
1069
|
+
truncated ? patch.stdout.slice(0, cap) : patch.stdout,
|
|
1070
|
+
truncated ? "\n[the patch was truncated here]" : ""
|
|
1071
|
+
].join("\n").trim(),
|
|
1072
|
+
truncated
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Whether HEAD has a parent commit.
|
|
1077
|
+
* @param cwd - absolute repository root.
|
|
1078
|
+
* @param signal - cancellation for the invocation.
|
|
1079
|
+
* @returns true when `HEAD~1` resolves.
|
|
1080
|
+
*/
|
|
1081
|
+
async hasParent(cwd, signal) {
|
|
1082
|
+
return (await this.git(cwd, [
|
|
1083
|
+
"rev-parse",
|
|
1084
|
+
"--verify",
|
|
1085
|
+
"--quiet",
|
|
1086
|
+
"HEAD~1"
|
|
1087
|
+
], signal)).exitCode === 0;
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* The remote an unpublished branch would be published to.
|
|
1091
|
+
* @param cwd - absolute repository root.
|
|
1092
|
+
* @param signal - cancellation for the invocation.
|
|
1093
|
+
* @returns `origin` when it exists, else the first remote, else undefined.
|
|
1094
|
+
*/
|
|
1095
|
+
async defaultRemote(cwd, signal) {
|
|
1096
|
+
const outcome = await this.git(cwd, ["remote"], signal);
|
|
1097
|
+
if (outcome.exitCode !== 0) return void 0;
|
|
1098
|
+
const remotes = outcome.stdout.split("\n").map((line) => line.trim()).filter((line) => line !== "");
|
|
1099
|
+
return remotes.includes(DEFAULT_REMOTE) ? DEFAULT_REMOTE : remotes[0];
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Run one index write over contained paths, then re-read the repository.
|
|
1103
|
+
* @param request - the workspace and the paths.
|
|
1104
|
+
* @param signal - cancellation for both invocations.
|
|
1105
|
+
* @param argv - builds the git arguments from the accepted paths.
|
|
1106
|
+
* @returns the reading after the write, or a classified failure.
|
|
1107
|
+
*/
|
|
1108
|
+
async write(request, signal, argv) {
|
|
1109
|
+
if (request.paths.length === 0) return fail$4("path-denied", "no paths were given");
|
|
1110
|
+
const prepared = await this.prepare(request.workspacePath, signal);
|
|
1111
|
+
if ("failure" in prepared) return prepared.failure;
|
|
1112
|
+
const { repository } = prepared;
|
|
1113
|
+
const contained = await this.contain(repository.root, request.paths, signal);
|
|
1114
|
+
if (contained !== void 0) return contained;
|
|
1115
|
+
const outcome = await this.git(repository.root, argv(request.paths), signal);
|
|
1116
|
+
if (outcome.exitCode !== 0) return classify(outcome);
|
|
1117
|
+
const status = await this.status({ workspacePath: request.workspacePath }, signal);
|
|
1118
|
+
return status.ok ? {
|
|
1119
|
+
ok: true,
|
|
1120
|
+
status
|
|
1121
|
+
} : status;
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Prove every path sits inside the repository before git is handed any of them.
|
|
1125
|
+
*
|
|
1126
|
+
* A path from the browser is untrusted input at a process boundary. `git add` and
|
|
1127
|
+
* `git restore` both accept absolute paths and `..`, and `git diff --no-index` will read any file
|
|
1128
|
+
* at all — so an unchecked path is a write outside the repository in one direction and an
|
|
1129
|
+
* exfiltration route in the other.
|
|
1130
|
+
* @param root - absolute repository root.
|
|
1131
|
+
* @param paths - repository-relative paths, as the browser sent them.
|
|
1132
|
+
* @param signal - cancellation for the resolutions.
|
|
1133
|
+
* @returns the failure to return, or undefined when every path is inside.
|
|
1134
|
+
*/
|
|
1135
|
+
async contain(root, paths, signal) {
|
|
1136
|
+
const resolved = await resolveWorkspace(this.ctx, root, signal);
|
|
1137
|
+
if (!resolved.ok) return fail$4(resolved.rejection.code, resolved.rejection.message);
|
|
1138
|
+
for (const path of paths) {
|
|
1139
|
+
const inside = await resolveInside(this.ctx, resolved.value, resolve(root, path), signal);
|
|
1140
|
+
if (!inside.ok) return fail$4(inside.rejection.code, inside.rejection.message);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* The author `git commit` would record.
|
|
1145
|
+
* @param cwd - the repository root.
|
|
1146
|
+
* @param signal - cancellation for the invocation.
|
|
1147
|
+
* @returns `Name <email>`, or undefined when git has no identity configured.
|
|
1148
|
+
*/
|
|
1149
|
+
async author(cwd, signal) {
|
|
1150
|
+
const outcome = await this.git(cwd, ["var", "GIT_AUTHOR_IDENT"], signal);
|
|
1151
|
+
if (outcome.exitCode !== 0) return void 0;
|
|
1152
|
+
const ident = outcome.stdout.trim();
|
|
1153
|
+
const at = ident.lastIndexOf(">");
|
|
1154
|
+
return at < 0 ? void 0 : ident.slice(0, at + 1);
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Whether the index differs from HEAD.
|
|
1158
|
+
* @param cwd - the repository root.
|
|
1159
|
+
* @param signal - cancellation for the invocation.
|
|
1160
|
+
* @returns true when a commit would record something.
|
|
1161
|
+
*/
|
|
1162
|
+
async hasStaged(cwd, signal) {
|
|
1163
|
+
return (await this.git(cwd, [
|
|
1164
|
+
"diff",
|
|
1165
|
+
"--cached",
|
|
1166
|
+
"--quiet"
|
|
1167
|
+
], signal)).exitCode !== 0;
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Report what the panel may do to this repository.
|
|
1171
|
+
* @param cwd - the repository root.
|
|
1172
|
+
* @param signal - cancellation for the identity lookup.
|
|
1173
|
+
* @returns the write capability.
|
|
1174
|
+
*/
|
|
1175
|
+
async writeCapability(cwd, signal) {
|
|
1176
|
+
const settings = this.source();
|
|
1177
|
+
const canStage = settings.allowGitStaging;
|
|
1178
|
+
const canCommit = canStage && settings.allowGitCommit;
|
|
1179
|
+
const canDraftMessage = canCommit && settings.allowCommitMessageDraft && this.ctx.get("llm") !== void 0 && this.ctx.get("agentDefaultModel") !== void 0;
|
|
1180
|
+
const author = canCommit ? await this.author(cwd, signal) : void 0;
|
|
1181
|
+
return {
|
|
1182
|
+
canStage,
|
|
1183
|
+
canCommit,
|
|
1184
|
+
canPush: settings.allowGitPush,
|
|
1185
|
+
canDraftMessage,
|
|
1186
|
+
...author === void 0 ? {} : { author }
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Resolve the workspace and its repository once for both endpoints.
|
|
1191
|
+
* @param workspacePath - the browser-supplied directory.
|
|
1192
|
+
* @param signal - cancellation for the resolution and the `rev-parse`.
|
|
1193
|
+
* @returns the repository and the resolved workspace, or the failure to return.
|
|
1194
|
+
*/
|
|
1195
|
+
async prepare(workspacePath, signal) {
|
|
1196
|
+
const workspace = await resolveWorkspace(this.ctx, workspacePath, signal);
|
|
1197
|
+
if (!workspace.ok) return { failure: fail$4(workspace.rejection.code, workspace.rejection.message) };
|
|
1198
|
+
const repository = await this.locateRepository(workspace.value.processPath, signal);
|
|
1199
|
+
if ("failure" in repository) return { failure: repository.failure };
|
|
1200
|
+
return {
|
|
1201
|
+
repository: repository.repository,
|
|
1202
|
+
workspace
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* Ask git where the repository containing a directory begins.
|
|
1207
|
+
* @param cwd - the canonical workspace directory.
|
|
1208
|
+
* @param signal - cancellation for the invocation.
|
|
1209
|
+
* @returns the repository, or the failure to return.
|
|
1210
|
+
*/
|
|
1211
|
+
async locateRepository(cwd, signal) {
|
|
1212
|
+
const outcome = await this.git(cwd, [
|
|
1213
|
+
"rev-parse",
|
|
1214
|
+
"--show-toplevel",
|
|
1215
|
+
"--show-prefix"
|
|
1216
|
+
], signal);
|
|
1217
|
+
if (outcome.exitCode !== 0) return { failure: classify(outcome) };
|
|
1218
|
+
const [root, prefix] = outcome.stdout.split("\n");
|
|
1219
|
+
if (root === void 0 || root.trim() === "") return { failure: fail$4("not-a-repository", `${cwd} is not inside a git repository`) };
|
|
1220
|
+
return { repository: {
|
|
1221
|
+
root: root.trim(),
|
|
1222
|
+
prefix: (prefix ?? "").trim().replace(/\/$/u, "")
|
|
1223
|
+
} };
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Run one git invocation with this plugin's own bounds.
|
|
1227
|
+
* @param cwd - directory to run in.
|
|
1228
|
+
* @param args - arguments after the executable.
|
|
1229
|
+
* @param signal - the caller's cancellation.
|
|
1230
|
+
* @returns the finished command.
|
|
1231
|
+
*/
|
|
1232
|
+
async git(cwd, args, signal, maxBytes, timeoutMs) {
|
|
1233
|
+
const executable = await this.locate(signal);
|
|
1234
|
+
if (executable === void 0) return {
|
|
1235
|
+
exitCode: MISSING_BINARY_EXIT,
|
|
1236
|
+
signal: null,
|
|
1237
|
+
stdout: "",
|
|
1238
|
+
stderr: "git is not installed, or not on this Host process PATH",
|
|
1239
|
+
timedOut: false,
|
|
1240
|
+
aborted: false,
|
|
1241
|
+
stdoutLossy: false
|
|
1242
|
+
};
|
|
1243
|
+
const settings = this.source();
|
|
1244
|
+
return runCommand(this.ctx, {
|
|
1245
|
+
argv: [executable, ...args],
|
|
1246
|
+
cwd,
|
|
1247
|
+
timeoutMs: timeoutMs ?? settings.gitTimeoutMs,
|
|
1248
|
+
maxBytes: maxBytes ?? Math.max(settings.gitDiffMaxBytes, 1 << 20),
|
|
1249
|
+
graceMs: 1e3,
|
|
1250
|
+
env: {
|
|
1251
|
+
GIT_PAGER: "cat",
|
|
1252
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
1253
|
+
LC_ALL: "C",
|
|
1254
|
+
GIT_EDITOR: "true",
|
|
1255
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
1256
|
+
}
|
|
1257
|
+
}, signal);
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Resolve `git` once and remember it.
|
|
1261
|
+
* @param signal - cancellation for the lookup.
|
|
1262
|
+
* @returns the executable path, or undefined when git is absent.
|
|
1263
|
+
* @throws {CommandUnavailableError} when no subprocess capability is mounted.
|
|
1264
|
+
*/
|
|
1265
|
+
async locate(signal) {
|
|
1266
|
+
if (this.executable !== void 0) return this.executable;
|
|
1267
|
+
const found = await resolveCommand(this.ctx, "git", signal);
|
|
1268
|
+
if (found === void 0) return void 0;
|
|
1269
|
+
this.executable = found;
|
|
1270
|
+
try {
|
|
1271
|
+
const outcome = await runCommand(this.ctx, {
|
|
1272
|
+
argv: [found, "--version"],
|
|
1273
|
+
cwd: process.cwd(),
|
|
1274
|
+
timeoutMs: 5e3,
|
|
1275
|
+
maxBytes: 4096,
|
|
1276
|
+
graceMs: 1e3
|
|
1277
|
+
}, signal);
|
|
1278
|
+
if (outcome.exitCode === 0) this.version = outcome.stdout.trim();
|
|
1279
|
+
} catch (error) {
|
|
1280
|
+
/* v8 ignore next -- runCommand only throws for a withdrawn subprocess capability. */
|
|
1281
|
+
if (!(error instanceof CommandUnavailableError)) throw error;
|
|
1282
|
+
}
|
|
1283
|
+
return found;
|
|
1284
|
+
}
|
|
1285
|
+
};
|
|
1286
|
+
/**
|
|
1287
|
+
* The empty left-hand side of an untracked file's synthesized patch.
|
|
1288
|
+
* @returns the platform's null device path.
|
|
1289
|
+
*/
|
|
1290
|
+
function devNull() {
|
|
1291
|
+
return process.platform === "win32" ? "NUL" : "/dev/null";
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
//#endregion
|
|
1295
|
+
//#region tsbuild/host/open-in.js
|
|
1296
|
+
/** Id of the built-in file-manager target; no configured editor may claim it. */
|
|
1297
|
+
const REVEAL_TARGET_ID = "reveal";
|
|
1298
|
+
/** Wall-clock bound on a launch. A desktop opener returns immediately or is broken. */
|
|
1299
|
+
const LAUNCH_TIMEOUT_MS = 15e3;
|
|
1300
|
+
/** TERM-to-KILL grace for a launcher that ignored its deadline. */
|
|
1301
|
+
const LAUNCH_GRACE_MS = 2e3;
|
|
1302
|
+
/** Compose one classified failure. */
|
|
1303
|
+
function fail$3(code, message) {
|
|
1304
|
+
return {
|
|
1305
|
+
ok: false,
|
|
1306
|
+
code,
|
|
1307
|
+
message
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
/** The file manager's name on this platform, used as the target's menu text. */
|
|
1311
|
+
function revealLabel(platform) {
|
|
1312
|
+
if (platform === "darwin") return "Finder";
|
|
1313
|
+
if (platform === "win32") return "File Explorer";
|
|
1314
|
+
return "File manager";
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Launches external applications for the Open in submenu. One instance serves every request and
|
|
1318
|
+
* caches each target's resolved executable.
|
|
1319
|
+
*/
|
|
1320
|
+
var OpenInLauncher = class {
|
|
1321
|
+
ctx;
|
|
1322
|
+
source;
|
|
1323
|
+
resolved = /* @__PURE__ */ new Map();
|
|
1324
|
+
/**
|
|
1325
|
+
* @param ctx - Host context carrying the subprocess and filesystem capabilities.
|
|
1326
|
+
* @param source - reads the current settings section; called per request so an edited target list
|
|
1327
|
+
* reaches the next menu with no registration to rebuild.
|
|
1328
|
+
*/
|
|
1329
|
+
constructor(ctx, source) {
|
|
1330
|
+
this.ctx = ctx;
|
|
1331
|
+
this.source = source;
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* List every target in menu order with its availability.
|
|
1335
|
+
*
|
|
1336
|
+
* Unavailable targets are listed rather than hidden: a person who configured an editor and does
|
|
1337
|
+
* not see it cannot tell a typo in `command` from a menu that simply has no such feature.
|
|
1338
|
+
* @param signal - cancellation for the executable lookups.
|
|
1339
|
+
* @returns the targets, file manager first.
|
|
1340
|
+
*/
|
|
1341
|
+
async describe(signal) {
|
|
1342
|
+
const platform = process.platform;
|
|
1343
|
+
const views = [{
|
|
1344
|
+
id: REVEAL_TARGET_ID,
|
|
1345
|
+
label: revealLabel(platform),
|
|
1346
|
+
available: await this.available(this.revealCommand(platform).argv[0] ?? "", signal),
|
|
1347
|
+
kind: "reveal"
|
|
1348
|
+
}];
|
|
1349
|
+
for (const editor of this.source().editors) views.push({
|
|
1350
|
+
id: editor.id,
|
|
1351
|
+
label: editor.label,
|
|
1352
|
+
available: await this.available(editor.command, signal),
|
|
1353
|
+
kind: "command"
|
|
1354
|
+
});
|
|
1355
|
+
return views;
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Hand one path to a target.
|
|
1359
|
+
* @param request - which target, and which absolute path.
|
|
1360
|
+
* @param signal - cancellation for the launch.
|
|
1361
|
+
* @returns settlement, or a classified failure.
|
|
1362
|
+
*/
|
|
1363
|
+
async open(request, signal) {
|
|
1364
|
+
const resolvedPath = await resolveWorkspace(this.ctx, request.path, signal);
|
|
1365
|
+
const target = resolvedPath.ok ? resolvedPath.value.processPath : await this.resolveFile(request.path, signal);
|
|
1366
|
+
if (target === void 0) return fail$3("path-denied", `${request.path} does not exist on this Host`);
|
|
1367
|
+
const directory = resolvedPath.ok;
|
|
1368
|
+
if (request.targetId === REVEAL_TARGET_ID) return this.reveal(target, directory, signal);
|
|
1369
|
+
const editor = this.source().editors.find((entry) => entry.id === request.targetId);
|
|
1370
|
+
if (editor === void 0) return fail$3("unknown-target", `no Open in target "${request.targetId}"`);
|
|
1371
|
+
const executable = await this.locate(editor.command, signal);
|
|
1372
|
+
if (executable === void 0) return fail$3("unavailable", `${editor.label}: "${editor.command}" does not resolve on this Host`);
|
|
1373
|
+
return this.launch([
|
|
1374
|
+
executable,
|
|
1375
|
+
...editor.args,
|
|
1376
|
+
target
|
|
1377
|
+
], signal);
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Show a path in the operating system's file manager.
|
|
1381
|
+
* @param target - the canonical path.
|
|
1382
|
+
* @param directory - whether the path is a directory (opened) or a file (selected).
|
|
1383
|
+
* @param signal - cancellation for the launch.
|
|
1384
|
+
* @returns settlement, or a classified failure.
|
|
1385
|
+
*/
|
|
1386
|
+
async reveal(target, directory, signal) {
|
|
1387
|
+
const platform = process.platform;
|
|
1388
|
+
const { argv, tolerateExit } = this.revealCommand(platform, target, directory);
|
|
1389
|
+
const command = argv[0];
|
|
1390
|
+
if (command === void 0) return fail$3("unavailable", `no file manager is known for ${platform}`);
|
|
1391
|
+
const executable = await this.locate(command, signal);
|
|
1392
|
+
if (executable === void 0) return fail$3("unavailable", `${command} does not resolve on this Host`);
|
|
1393
|
+
return this.launch([executable, ...argv.slice(1)], signal, tolerateExit);
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* The platform's file-manager invocation.
|
|
1397
|
+
* @param platform - the Host platform.
|
|
1398
|
+
* @param target - the canonical path; omitted while only the executable name is needed.
|
|
1399
|
+
* @param directory - whether the path is a directory.
|
|
1400
|
+
* @returns the argv and whether a non-zero exit is normal for it.
|
|
1401
|
+
*/
|
|
1402
|
+
revealCommand(platform, target = "", directory = true) {
|
|
1403
|
+
if (platform === "darwin") return {
|
|
1404
|
+
argv: directory ? ["open", target] : [
|
|
1405
|
+
"open",
|
|
1406
|
+
"-R",
|
|
1407
|
+
target
|
|
1408
|
+
],
|
|
1409
|
+
tolerateExit: false
|
|
1410
|
+
};
|
|
1411
|
+
if (platform === "win32") return {
|
|
1412
|
+
argv: directory ? ["explorer.exe", target] : ["explorer.exe", `/select,${target}`],
|
|
1413
|
+
tolerateExit: true
|
|
1414
|
+
};
|
|
1415
|
+
return {
|
|
1416
|
+
argv: ["xdg-open", directory ? target : dirname(target)],
|
|
1417
|
+
tolerateExit: false
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Run one launcher and classify its outcome.
|
|
1422
|
+
* @param argv - resolved executable and arguments.
|
|
1423
|
+
* @param signal - cancellation for the launch.
|
|
1424
|
+
* @param tolerateExit - accept a non-zero exit as success (Windows Explorer).
|
|
1425
|
+
* @returns settlement, or a classified failure.
|
|
1426
|
+
*/
|
|
1427
|
+
async launch(argv, signal, tolerateExit = false) {
|
|
1428
|
+
let outcome;
|
|
1429
|
+
try {
|
|
1430
|
+
outcome = await runCommand(this.ctx, {
|
|
1431
|
+
argv,
|
|
1432
|
+
cwd: process.cwd(),
|
|
1433
|
+
timeoutMs: LAUNCH_TIMEOUT_MS,
|
|
1434
|
+
maxBytes: 8192,
|
|
1435
|
+
graceMs: LAUNCH_GRACE_MS
|
|
1436
|
+
}, signal);
|
|
1437
|
+
} catch (error) {
|
|
1438
|
+
if (error instanceof CommandUnavailableError) return fail$3("unavailable", error.message);
|
|
1439
|
+
throw error;
|
|
1440
|
+
}
|
|
1441
|
+
if (outcome.timedOut) return fail$3("timeout", `${argv[0] ?? "launcher"} did not return within ${String(LAUNCH_TIMEOUT_MS)}ms`);
|
|
1442
|
+
if (outcome.exitCode === 0 || tolerateExit) return { ok: true };
|
|
1443
|
+
const detail = outcome.stderr.trim();
|
|
1444
|
+
return fail$3("launch-failed", detail === "" ? `${argv[0] ?? "launcher"} exited with code ${String(outcome.exitCode)}` : detail);
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Resolve a file path that is not a directory.
|
|
1448
|
+
* @param path - the browser-supplied path.
|
|
1449
|
+
* @param signal - cancellation for the resolution.
|
|
1450
|
+
* @returns the canonical path, or undefined when nothing is there.
|
|
1451
|
+
*/
|
|
1452
|
+
async resolveFile(path, signal) {
|
|
1453
|
+
const fs = this.ctx.get("fs");
|
|
1454
|
+
if (fs === void 0) return void 0;
|
|
1455
|
+
try {
|
|
1456
|
+
const resolved = await fs.resolve(path, signal === void 0 ? {} : { signal });
|
|
1457
|
+
return await fs.stat(resolved, signal) === void 0 ? void 0 : fs.processPath(resolved);
|
|
1458
|
+
} catch {
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Whether one command resolves, without reporting why it does not.
|
|
1464
|
+
* @param command - executable name or path.
|
|
1465
|
+
* @param signal - cancellation for the lookup.
|
|
1466
|
+
* @returns true when the command resolves.
|
|
1467
|
+
*/
|
|
1468
|
+
async available(command, signal) {
|
|
1469
|
+
if (command === "") return false;
|
|
1470
|
+
try {
|
|
1471
|
+
return await this.locate(command, signal) !== void 0;
|
|
1472
|
+
} catch (error) {
|
|
1473
|
+
/* v8 ignore next -- only a missing subprocess capability reaches here. */
|
|
1474
|
+
if (error instanceof CommandUnavailableError) return false;
|
|
1475
|
+
/* v8 ignore next */
|
|
1476
|
+
throw error;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Resolve one command once and remember the answer, negative answers included.
|
|
1481
|
+
* @param command - executable name or path.
|
|
1482
|
+
* @param signal - cancellation for the lookup.
|
|
1483
|
+
* @returns the executable path, or undefined when the command is absent.
|
|
1484
|
+
* @throws {CommandUnavailableError} when no subprocess capability is mounted.
|
|
1485
|
+
*/
|
|
1486
|
+
async locate(command, signal) {
|
|
1487
|
+
if (this.resolved.has(command)) return this.resolved.get(command);
|
|
1488
|
+
const found = await resolveCommand(this.ctx, command, signal);
|
|
1489
|
+
this.resolved.set(command, found);
|
|
1490
|
+
return found;
|
|
1491
|
+
}
|
|
1492
|
+
/** Drop every cached lookup, so an edited target list or a newly installed editor is re-probed. */
|
|
1493
|
+
forget() {
|
|
1494
|
+
this.resolved.clear();
|
|
1495
|
+
}
|
|
1496
|
+
};
|
|
1497
|
+
|
|
1498
|
+
//#endregion
|
|
1499
|
+
//#region tsbuild/host/preview.js
|
|
1500
|
+
/** Claude Code's launch file, read relative to the workspace root. */
|
|
1501
|
+
const LAUNCH_FILE = ".claude/launch.json";
|
|
1502
|
+
/** How long one readiness probe waits for the socket before retrying. */
|
|
1503
|
+
const PROBE_TIMEOUT_MS = 500;
|
|
1504
|
+
/** How long to wait between readiness probes. */
|
|
1505
|
+
const PROBE_INTERVAL_MS = 250;
|
|
1506
|
+
/** In-memory cap per collected stream, in bytes. Generous: a dev server's startup banner is small
|
|
1507
|
+
* and its request log is what a person scrolls back through. */
|
|
1508
|
+
const STREAM_MAX_BYTES = 4 * 1024 * 1024;
|
|
1509
|
+
/** Compose one classified failure. */
|
|
1510
|
+
function fail$2(code, message) {
|
|
1511
|
+
return {
|
|
1512
|
+
ok: false,
|
|
1513
|
+
code,
|
|
1514
|
+
message
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Where a configuration's frame should point.
|
|
1519
|
+
* @param launch - the configuration.
|
|
1520
|
+
* @returns the URL, or undefined when neither a url nor a port was given.
|
|
1521
|
+
*/
|
|
1522
|
+
function urlOf(launch) {
|
|
1523
|
+
if (launch.url !== void 0 && launch.url !== "") return launch.url;
|
|
1524
|
+
if (launch.port === void 0 || launch.port <= 0) return void 0;
|
|
1525
|
+
return `http://127.0.0.1:${String(launch.port)}`;
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* Whether a socket accepts a connection on one port.
|
|
1529
|
+
* @param port - the port to probe.
|
|
1530
|
+
* @param signal - cancellation of the whole readiness wait.
|
|
1531
|
+
* @returns true when the connection was accepted.
|
|
1532
|
+
*/
|
|
1533
|
+
function accepts(port, signal) {
|
|
1534
|
+
return new Promise((resolve$1) => {
|
|
1535
|
+
const socket = connect({
|
|
1536
|
+
port,
|
|
1537
|
+
host: "127.0.0.1"
|
|
1538
|
+
});
|
|
1539
|
+
const settle = (value) => {
|
|
1540
|
+
socket.destroy();
|
|
1541
|
+
signal.removeEventListener("abort", onAbort);
|
|
1542
|
+
resolve$1(value);
|
|
1543
|
+
};
|
|
1544
|
+
const onAbort = () => {
|
|
1545
|
+
settle(false);
|
|
1546
|
+
};
|
|
1547
|
+
socket.setTimeout(PROBE_TIMEOUT_MS);
|
|
1548
|
+
socket.once("connect", () => {
|
|
1549
|
+
settle(true);
|
|
1550
|
+
});
|
|
1551
|
+
socket.once("timeout", () => {
|
|
1552
|
+
settle(false);
|
|
1553
|
+
});
|
|
1554
|
+
socket.once("error", () => {
|
|
1555
|
+
settle(false);
|
|
1556
|
+
});
|
|
1557
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
/** Sleep, resolving early when the wait is cancelled. */
|
|
1561
|
+
function pause(ms, signal) {
|
|
1562
|
+
return new Promise((resolve$1) => {
|
|
1563
|
+
const timer = setTimeout(() => {
|
|
1564
|
+
signal.removeEventListener("abort", onAbort);
|
|
1565
|
+
resolve$1();
|
|
1566
|
+
}, ms);
|
|
1567
|
+
const onAbort = () => {
|
|
1568
|
+
clearTimeout(timer);
|
|
1569
|
+
resolve$1();
|
|
1570
|
+
};
|
|
1571
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
/**
|
|
1575
|
+
* Read one launch file's configurations.
|
|
1576
|
+
*
|
|
1577
|
+
* A malformed file is reported rather than thrown: the panel still lists the settings rows and says
|
|
1578
|
+
* why the file was ignored, which is more useful than a Preview entry that refuses to open.
|
|
1579
|
+
* @param text - the file's contents.
|
|
1580
|
+
* @returns the configurations, or the reason the file was ignored.
|
|
1581
|
+
*/
|
|
1582
|
+
function parseLaunchFile(text) {
|
|
1583
|
+
let document;
|
|
1584
|
+
try {
|
|
1585
|
+
document = JSON.parse(text);
|
|
1586
|
+
} catch (error) {
|
|
1587
|
+
return { error: `not valid JSON: ${error instanceof Error ? error.message : String(error)}` };
|
|
1588
|
+
}
|
|
1589
|
+
if (typeof document !== "object" || document === null) return { error: "the top level is not an object" };
|
|
1590
|
+
const configurations = document.configurations;
|
|
1591
|
+
if (!Array.isArray(configurations)) return { error: "it carries no `configurations` array" };
|
|
1592
|
+
const launches = [];
|
|
1593
|
+
for (const entry of configurations) {
|
|
1594
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1595
|
+
const row = entry;
|
|
1596
|
+
const name = row.name;
|
|
1597
|
+
if (typeof name !== "string" || name === "") continue;
|
|
1598
|
+
const args = Array.isArray(row.runtimeArgs) ? row.runtimeArgs.filter((value) => typeof value === "string") : [];
|
|
1599
|
+
launches.push({
|
|
1600
|
+
name,
|
|
1601
|
+
...typeof row.runtimeExecutable === "string" && row.runtimeExecutable !== "" ? { runtimeExecutable: row.runtimeExecutable } : {},
|
|
1602
|
+
runtimeArgs: args,
|
|
1603
|
+
...typeof row.port === "number" && Number.isInteger(row.port) && row.port > 0 ? { port: row.port } : {},
|
|
1604
|
+
...typeof row.url === "string" && row.url !== "" ? { url: row.url } : {},
|
|
1605
|
+
...typeof row.cwd === "string" && row.cwd !== "" ? { cwd: row.cwd } : {},
|
|
1606
|
+
...typeof row.env === "object" && row.env !== null ? { env: Object.fromEntries(Object.entries(row.env).filter((pair) => typeof pair[1] === "string")) } : {}
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
return { launches };
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* Merge a workspace's launch file with the settings rows.
|
|
1613
|
+
*
|
|
1614
|
+
* The file wins a name collision: a repository stating how to run itself outranks a deployment-wide
|
|
1615
|
+
* default that happens to use the same name.
|
|
1616
|
+
* @param fromFile - configurations read from `.claude/launch.json`.
|
|
1617
|
+
* @param fromSettings - configurations from the `previews` settings rows.
|
|
1618
|
+
* @returns the merged list, file rows first.
|
|
1619
|
+
*/
|
|
1620
|
+
function mergeLaunches(fromFile, fromSettings) {
|
|
1621
|
+
const merged = fromFile.map((launch) => ({
|
|
1622
|
+
launch,
|
|
1623
|
+
origin: "launch-json"
|
|
1624
|
+
}));
|
|
1625
|
+
const claimed = new Set(fromFile.map((launch) => launch.name));
|
|
1626
|
+
for (const launch of fromSettings) {
|
|
1627
|
+
if (claimed.has(launch.name)) continue;
|
|
1628
|
+
claimed.add(launch.name);
|
|
1629
|
+
merged.push({
|
|
1630
|
+
launch,
|
|
1631
|
+
origin: "settings"
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
return merged;
|
|
1635
|
+
}
|
|
1636
|
+
/**
|
|
1637
|
+
* Owns every preview server in the process. One instance is created by the service and disposed
|
|
1638
|
+
* with it, which is what guarantees no dev server outlives the plugin.
|
|
1639
|
+
*/
|
|
1640
|
+
var PreviewServers = class {
|
|
1641
|
+
ctx;
|
|
1642
|
+
source;
|
|
1643
|
+
records = /* @__PURE__ */ new Map();
|
|
1644
|
+
/**
|
|
1645
|
+
* Configurations with a start already in flight, keyed `<workspace>\u0000<name>`.
|
|
1646
|
+
*
|
|
1647
|
+
* `start()` awaits a file read, a path resolution, and an executable lookup before it registers
|
|
1648
|
+
* anything, so two starts of one row would both find no predecessor and both spawn — leaving a
|
|
1649
|
+
* dev server on the port that no `serverId` any panel holds can ever stop.
|
|
1650
|
+
*/
|
|
1651
|
+
starting = /* @__PURE__ */ new Set();
|
|
1652
|
+
closing = false;
|
|
1653
|
+
/**
|
|
1654
|
+
* @param ctx - Host context carrying the subprocess and filesystem capabilities.
|
|
1655
|
+
* @param source - reads the current settings section; called per request.
|
|
1656
|
+
*/
|
|
1657
|
+
constructor(ctx, source) {
|
|
1658
|
+
this.ctx = ctx;
|
|
1659
|
+
this.source = source;
|
|
1660
|
+
}
|
|
1661
|
+
/**
|
|
1662
|
+
* Report whether a preview server can be started on this Host.
|
|
1663
|
+
* @returns availability plus how many servers are already running.
|
|
1664
|
+
*/
|
|
1665
|
+
describe() {
|
|
1666
|
+
const running = [...this.records.values()].filter((record) => record.state !== "exited" && record.state !== "failed").length;
|
|
1667
|
+
if (this.ctx.get("subprocess") === void 0) return {
|
|
1668
|
+
available: false,
|
|
1669
|
+
reason: "no subprocess capability is mounted: this deployment composes no @deepseek-ai/dsh-subprocess provider",
|
|
1670
|
+
running
|
|
1671
|
+
};
|
|
1672
|
+
if (this.ctx.get("fs") === void 0) return {
|
|
1673
|
+
available: false,
|
|
1674
|
+
reason: "no filesystem capability is mounted: a launch configuration has no directory to run in",
|
|
1675
|
+
running
|
|
1676
|
+
};
|
|
1677
|
+
return {
|
|
1678
|
+
available: true,
|
|
1679
|
+
running
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
/**
|
|
1683
|
+
* List one workspace's configurations, each with its current state.
|
|
1684
|
+
* @param request - the workspace to read.
|
|
1685
|
+
* @param signal - cancellation for the file read.
|
|
1686
|
+
* @returns the list, or a classified failure.
|
|
1687
|
+
*/
|
|
1688
|
+
async list(request, signal) {
|
|
1689
|
+
const workspace = await resolveWorkspace(this.ctx, request.workspacePath, signal);
|
|
1690
|
+
if (!workspace.ok) return fail$2(workspace.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied", workspace.rejection.message);
|
|
1691
|
+
const file = await this.readLaunchFile(workspace, signal);
|
|
1692
|
+
return {
|
|
1693
|
+
ok: true,
|
|
1694
|
+
servers: mergeLaunches(file.launches, this.settingsLaunches()).map((resolved) => this.view(resolved, workspace.value.processPath)),
|
|
1695
|
+
...file.path === void 0 ? {} : { launchFile: file.path },
|
|
1696
|
+
...file.error === void 0 ? {} : { launchFileError: file.error }
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Start one configuration.
|
|
1701
|
+
* @param request - the workspace and the configuration name.
|
|
1702
|
+
* @param signal - cancellation of the start itself; a started server owns its later lifetime.
|
|
1703
|
+
* @returns the started row, or a classified failure.
|
|
1704
|
+
*/
|
|
1705
|
+
async start(request, signal) {
|
|
1706
|
+
if (this.closing) return fail$2("closed", "the plugin is unloading");
|
|
1707
|
+
if (this.ctx.get("subprocess") === void 0) return fail$2("no-subprocess", "no subprocess capability is mounted");
|
|
1708
|
+
const workspace = await resolveWorkspace(this.ctx, request.workspacePath, signal);
|
|
1709
|
+
if (!workspace.ok) return fail$2(workspace.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied", workspace.rejection.message);
|
|
1710
|
+
const resolved = mergeLaunches((await this.readLaunchFile(workspace, signal)).launches, this.settingsLaunches()).find((entry) => entry.launch.name === request.name);
|
|
1711
|
+
if (resolved === void 0) return fail$2("unknown-server", `no launch configuration named "${request.name}"`);
|
|
1712
|
+
const launch = resolved.launch;
|
|
1713
|
+
if (launch.runtimeExecutable === void 0 || launch.runtimeExecutable === "") return fail$2("not-startable", `"${launch.name}" names no command; it opens its url and starts nothing`);
|
|
1714
|
+
const key = `${workspace.value.processPath}\u0000${launch.name}`;
|
|
1715
|
+
if (this.starting.has(key)) return fail$2("limit-reached", `"${launch.name}" is already starting`);
|
|
1716
|
+
this.starting.add(key);
|
|
1717
|
+
try {
|
|
1718
|
+
return await this.spawn(workspace, launch, resolved.origin, signal);
|
|
1719
|
+
} finally {
|
|
1720
|
+
this.starting.delete(key);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
/**
|
|
1724
|
+
* Start one validated configuration, holding its in-flight claim.
|
|
1725
|
+
* @param workspace - the resolved workspace.
|
|
1726
|
+
* @param launch - the configuration, already known to name a command.
|
|
1727
|
+
* @param origin - which file it came from.
|
|
1728
|
+
* @param signal - cancellation of the start.
|
|
1729
|
+
* @returns the started row, or a classified failure.
|
|
1730
|
+
*/
|
|
1731
|
+
async spawn(workspace, launch, origin, signal) {
|
|
1732
|
+
const subprocess = this.ctx.get("subprocess");
|
|
1733
|
+
/* v8 ignore next -- the caller resolved the same service moments earlier. */
|
|
1734
|
+
if (subprocess === void 0) return fail$2("no-subprocess", "no subprocess capability is mounted");
|
|
1735
|
+
const settings = this.source();
|
|
1736
|
+
const previous = this.find(workspace.value.processPath, launch.name);
|
|
1737
|
+
if (previous !== void 0) await this.terminate(previous);
|
|
1738
|
+
if ([...this.records.values()].filter((record$1) => record$1.state === "starting" || record$1.state === "ready").length >= settings.maxPreviews) return fail$2("limit-reached", `${String(settings.maxPreviews)} preview servers are already running`);
|
|
1739
|
+
const directory = await this.launchDirectory(workspace, launch, signal);
|
|
1740
|
+
if ("failure" in directory) return directory.failure;
|
|
1741
|
+
let executable;
|
|
1742
|
+
try {
|
|
1743
|
+
executable = await resolveCommand(this.ctx, launch.runtimeExecutable ?? "", signal);
|
|
1744
|
+
} catch (error) {
|
|
1745
|
+
return fail$2("no-subprocess", error instanceof CommandUnavailableError ? error.message : String(error));
|
|
1746
|
+
}
|
|
1747
|
+
if (executable === void 0) return fail$2("unavailable", `"${launch.runtimeExecutable ?? ""}" does not resolve on this Host`);
|
|
1748
|
+
if (this.closing) return fail$2("closed", "the plugin is unloading");
|
|
1749
|
+
let handle;
|
|
1750
|
+
try {
|
|
1751
|
+
handle = subprocess.spawn({
|
|
1752
|
+
argv: [executable, ...launch.runtimeArgs ?? []],
|
|
1753
|
+
cwd: directory.path,
|
|
1754
|
+
stdio: {
|
|
1755
|
+
stdin: "ignore",
|
|
1756
|
+
stdout: { maxBytes: STREAM_MAX_BYTES },
|
|
1757
|
+
stderr: { maxBytes: STREAM_MAX_BYTES }
|
|
1758
|
+
},
|
|
1759
|
+
graceMs: settings.previewGraceMs,
|
|
1760
|
+
env: {
|
|
1761
|
+
NO_COLOR: "1",
|
|
1762
|
+
FORCE_COLOR: "0",
|
|
1763
|
+
...launch.env ?? {},
|
|
1764
|
+
...launch.port === void 0 ? {} : { PORT: String(launch.port) }
|
|
1765
|
+
}
|
|
1766
|
+
});
|
|
1767
|
+
} catch (error) {
|
|
1768
|
+
return fail$2("spawn-failed", error instanceof Error ? error.message : String(error));
|
|
1769
|
+
}
|
|
1770
|
+
const record = {
|
|
1771
|
+
serverId: randomUUID(),
|
|
1772
|
+
name: launch.name,
|
|
1773
|
+
workspace: workspace.value.processPath,
|
|
1774
|
+
origin,
|
|
1775
|
+
handle,
|
|
1776
|
+
url: urlOf(launch),
|
|
1777
|
+
port: launch.port,
|
|
1778
|
+
startedAt: Date.now(),
|
|
1779
|
+
base: 0,
|
|
1780
|
+
buffer: "",
|
|
1781
|
+
read: {
|
|
1782
|
+
stdout: 0,
|
|
1783
|
+
stderr: 0
|
|
1784
|
+
},
|
|
1785
|
+
state: launch.port === void 0 ? "ready" : "starting",
|
|
1786
|
+
exitCode: null,
|
|
1787
|
+
detail: void 0,
|
|
1788
|
+
abort: new AbortController()
|
|
1789
|
+
};
|
|
1790
|
+
this.records.set(record.serverId, record);
|
|
1791
|
+
if (this.closing) {
|
|
1792
|
+
await this.terminate(record);
|
|
1793
|
+
return fail$2("closed", "the plugin is unloading");
|
|
1794
|
+
}
|
|
1795
|
+
this.watch(record);
|
|
1796
|
+
if (record.state === "starting") this.awaitReady(record);
|
|
1797
|
+
return {
|
|
1798
|
+
ok: true,
|
|
1799
|
+
server: this.viewOf(record)
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* Stop one server and forget it.
|
|
1804
|
+
* @param request - the handle.
|
|
1805
|
+
* @returns settlement, or a classified failure.
|
|
1806
|
+
*/
|
|
1807
|
+
async stop(request) {
|
|
1808
|
+
const record = this.records.get(request.serverId);
|
|
1809
|
+
if (record === void 0) return fail$2("unknown-server", `no preview server ${request.serverId}`);
|
|
1810
|
+
this.records.delete(record.serverId);
|
|
1811
|
+
await this.terminate(record);
|
|
1812
|
+
return { ok: true };
|
|
1813
|
+
}
|
|
1814
|
+
/**
|
|
1815
|
+
* Read one server's output from a caller-owned offset, with its state at read time.
|
|
1816
|
+
* @param request - the handle and the offset already rendered.
|
|
1817
|
+
* @returns the delta and the state, or a classified failure.
|
|
1818
|
+
*/
|
|
1819
|
+
logs(request) {
|
|
1820
|
+
const record = this.records.get(request.serverId);
|
|
1821
|
+
if (record === void 0) return fail$2("unknown-server", `no preview server ${request.serverId}`);
|
|
1822
|
+
this.drain(record);
|
|
1823
|
+
const total = record.base + record.buffer.length;
|
|
1824
|
+
const from = Number.isFinite(request.fromOffset) ? Math.max(0, Math.floor(request.fromOffset)) : 0;
|
|
1825
|
+
const lossy = from < record.base;
|
|
1826
|
+
const text = lossy ? record.buffer : record.buffer.slice(Math.min(from - record.base, record.buffer.length));
|
|
1827
|
+
return {
|
|
1828
|
+
ok: true,
|
|
1829
|
+
serverId: record.serverId,
|
|
1830
|
+
text,
|
|
1831
|
+
nextOffset: total,
|
|
1832
|
+
lossy,
|
|
1833
|
+
server: this.viewOf(record)
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
/**
|
|
1837
|
+
* Terminate every server and refuse new ones. Called from the service's teardown effect.
|
|
1838
|
+
* @returns after every process tree has exited.
|
|
1839
|
+
*/
|
|
1840
|
+
async disposeAll() {
|
|
1841
|
+
this.closing = true;
|
|
1842
|
+
const live = [...this.records.values()];
|
|
1843
|
+
this.records.clear();
|
|
1844
|
+
await Promise.all(live.map((record) => this.terminate(record)));
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* The `previews` settings rows, normalized into the shape the merge reads.
|
|
1848
|
+
* @returns the configured launches.
|
|
1849
|
+
*/
|
|
1850
|
+
settingsLaunches() {
|
|
1851
|
+
return this.source().previews.map((row) => ({
|
|
1852
|
+
name: row.name,
|
|
1853
|
+
...row.runtimeExecutable === "" ? {} : { runtimeExecutable: row.runtimeExecutable },
|
|
1854
|
+
runtimeArgs: row.runtimeArgs,
|
|
1855
|
+
...row.port <= 0 ? {} : { port: row.port },
|
|
1856
|
+
...row.url === "" ? {} : { url: row.url },
|
|
1857
|
+
...row.cwd === "" ? {} : { cwd: row.cwd }
|
|
1858
|
+
}));
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Read and parse the workspace's launch file.
|
|
1862
|
+
* @param workspace - the resolved workspace.
|
|
1863
|
+
* @param signal - cancellation for the read.
|
|
1864
|
+
* @returns the configurations, the file's path when it existed, and why it was ignored when it was.
|
|
1865
|
+
*/
|
|
1866
|
+
async readLaunchFile(workspace, signal) {
|
|
1867
|
+
if (!this.source().previewsFromLaunchFile) return { launches: [] };
|
|
1868
|
+
const fs = this.ctx.get("fs");
|
|
1869
|
+
if (fs === void 0) return { launches: [] };
|
|
1870
|
+
const path = join(workspace.value.processPath, LAUNCH_FILE);
|
|
1871
|
+
let text;
|
|
1872
|
+
try {
|
|
1873
|
+
const target = await fs.resolve(path, signal === void 0 ? {} : { signal });
|
|
1874
|
+
const info = await fs.stat(target, signal);
|
|
1875
|
+
if (info === void 0 || info.type !== "file") return { launches: [] };
|
|
1876
|
+
text = await fs.readText(target, signal);
|
|
1877
|
+
} catch {
|
|
1878
|
+
return { launches: [] };
|
|
1879
|
+
}
|
|
1880
|
+
const parsed = parseLaunchFile(text);
|
|
1881
|
+
if ("error" in parsed) return {
|
|
1882
|
+
launches: [],
|
|
1883
|
+
path,
|
|
1884
|
+
error: `${LAUNCH_FILE} was ignored: ${parsed.error}`
|
|
1885
|
+
};
|
|
1886
|
+
return {
|
|
1887
|
+
launches: parsed.launches,
|
|
1888
|
+
path
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
/**
|
|
1892
|
+
* Resolve the directory one configuration runs in, proving it stays inside the workspace.
|
|
1893
|
+
* @param workspace - the resolved workspace.
|
|
1894
|
+
* @param launch - the configuration.
|
|
1895
|
+
* @param signal - cancellation for the resolution.
|
|
1896
|
+
* @returns the directory, or the failure to return.
|
|
1897
|
+
*/
|
|
1898
|
+
async launchDirectory(workspace, launch, signal) {
|
|
1899
|
+
if (launch.cwd === void 0 || launch.cwd === "") return { path: workspace.value.processPath };
|
|
1900
|
+
const candidate = isAbsolute(launch.cwd) ? launch.cwd : join(workspace.value.processPath, launch.cwd);
|
|
1901
|
+
const resolved = await resolveInside(this.ctx, workspace.value, candidate, signal);
|
|
1902
|
+
if (!resolved.ok) return { failure: fail$2(resolved.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied", resolved.rejection.message) };
|
|
1903
|
+
return { path: resolved.value.processPath };
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Watch one process for its exit.
|
|
1907
|
+
* @param record - the started server.
|
|
1908
|
+
*/
|
|
1909
|
+
watch(record) {
|
|
1910
|
+
record.handle.done.then((outcome) => {
|
|
1911
|
+
record.abort.abort();
|
|
1912
|
+
record.exitCode = outcome.exitCode;
|
|
1913
|
+
record.state = record.state === "ready" ? "exited" : "failed";
|
|
1914
|
+
if (record.state === "failed" && record.detail === void 0) record.detail = outcome.signal === null ? `the process exited with code ${String(outcome.exitCode)} before its port accepted` : `the process ended on ${outcome.signal} before its port accepted`;
|
|
1915
|
+
}, (error) => {
|
|
1916
|
+
record.abort.abort();
|
|
1917
|
+
record.state = "failed";
|
|
1918
|
+
record.detail = error instanceof Error ? error.message : String(error);
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
/**
|
|
1922
|
+
* Probe one server's port until it accepts or the deadline passes.
|
|
1923
|
+
* @param record - the started server.
|
|
1924
|
+
*/
|
|
1925
|
+
async awaitReady(record) {
|
|
1926
|
+
const port = record.port;
|
|
1927
|
+
/* v8 ignore next -- only a row WITH a port enters the starting state. */
|
|
1928
|
+
if (port === void 0) return;
|
|
1929
|
+
const deadline = Date.now() + this.source().previewReadyTimeoutMs;
|
|
1930
|
+
while (!record.abort.signal.aborted && Date.now() < deadline) {
|
|
1931
|
+
if (await accepts(port, record.abort.signal)) {
|
|
1932
|
+
if (record.state === "starting") record.state = "ready";
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
await pause(PROBE_INTERVAL_MS, record.abort.signal);
|
|
1936
|
+
}
|
|
1937
|
+
if (record.state === "starting") {
|
|
1938
|
+
record.state = "failed";
|
|
1939
|
+
record.detail = `port ${String(port)} did not accept a connection within previewReadyTimeoutMs`;
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
/**
|
|
1943
|
+
* Move whatever the collected streams hold into the retained buffer.
|
|
1944
|
+
*
|
|
1945
|
+
* Both streams share one buffer and one offset, because the panel shows one log: a dev server
|
|
1946
|
+
* prints its banner on one and its errors on the other, and two independently scrolling views of
|
|
1947
|
+
* the same startup would be harder to read, not easier.
|
|
1948
|
+
* @param record - the server to drain.
|
|
1949
|
+
*/
|
|
1950
|
+
drain(record) {
|
|
1951
|
+
const take = (reader, from) => {
|
|
1952
|
+
/* v8 ignore next -- both streams are spawned in collect mode, so both readers exist. */
|
|
1953
|
+
if (reader === void 0) return {
|
|
1954
|
+
text: "",
|
|
1955
|
+
next: from
|
|
1956
|
+
};
|
|
1957
|
+
const read = reader.readFrom(from);
|
|
1958
|
+
return {
|
|
1959
|
+
text: read.lossy ? "" : read.text,
|
|
1960
|
+
next: read.nextOffset
|
|
1961
|
+
};
|
|
1962
|
+
};
|
|
1963
|
+
const out = take(record.handle.collected.stdout, record.read.stdout);
|
|
1964
|
+
const err = take(record.handle.collected.stderr, record.read.stderr);
|
|
1965
|
+
record.read = {
|
|
1966
|
+
stdout: out.next,
|
|
1967
|
+
stderr: err.next
|
|
1968
|
+
};
|
|
1969
|
+
const added = out.text + err.text;
|
|
1970
|
+
if (added === "") return;
|
|
1971
|
+
record.buffer += added;
|
|
1972
|
+
const max = Math.max(this.source().previewScrollback, 1024);
|
|
1973
|
+
if (record.buffer.length > max) {
|
|
1974
|
+
const drop = record.buffer.length - max;
|
|
1975
|
+
record.base += drop;
|
|
1976
|
+
record.buffer = record.buffer.slice(drop);
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
/**
|
|
1980
|
+
* Find a live record for one workspace and configuration name.
|
|
1981
|
+
* @param workspace - canonical workspace path.
|
|
1982
|
+
* @param name - configuration name.
|
|
1983
|
+
* @returns the record, when one exists.
|
|
1984
|
+
*/
|
|
1985
|
+
find(workspace, name) {
|
|
1986
|
+
for (const record of this.records.values()) if (record.workspace === workspace && record.name === name) return record;
|
|
1987
|
+
}
|
|
1988
|
+
/**
|
|
1989
|
+
* Stop one process tree without letting a cleanup fault escape.
|
|
1990
|
+
* @param record - the server to stop.
|
|
1991
|
+
* @returns after the tree has exited.
|
|
1992
|
+
*/
|
|
1993
|
+
async terminate(record) {
|
|
1994
|
+
record.abort.abort();
|
|
1995
|
+
this.records.delete(record.serverId);
|
|
1996
|
+
try {
|
|
1997
|
+
record.handle.terminate();
|
|
1998
|
+
await record.handle.waitForExit();
|
|
1999
|
+
} catch {}
|
|
2000
|
+
}
|
|
2001
|
+
/**
|
|
2002
|
+
* Project one configuration into its view, attaching a live record when one exists.
|
|
2003
|
+
* @param resolved - the configuration and its origin.
|
|
2004
|
+
* @param workspace - canonical workspace path.
|
|
2005
|
+
* @returns the view.
|
|
2006
|
+
*/
|
|
2007
|
+
view(resolved, workspace) {
|
|
2008
|
+
const record = this.find(workspace, resolved.launch.name);
|
|
2009
|
+
if (record !== void 0) return this.viewOf(record);
|
|
2010
|
+
const startable = resolved.launch.runtimeExecutable !== void 0 && resolved.launch.runtimeExecutable !== "";
|
|
2011
|
+
const url = urlOf(resolved.launch);
|
|
2012
|
+
return {
|
|
2013
|
+
name: resolved.launch.name,
|
|
2014
|
+
origin: resolved.origin,
|
|
2015
|
+
startable,
|
|
2016
|
+
state: startable ? "stopped" : "ready",
|
|
2017
|
+
...url === void 0 ? {} : { url },
|
|
2018
|
+
...resolved.launch.port === void 0 ? {} : { port: resolved.launch.port },
|
|
2019
|
+
...startable ? {} : { detail: "this configuration opens its url and starts nothing" }
|
|
2020
|
+
};
|
|
2021
|
+
}
|
|
2022
|
+
/**
|
|
2023
|
+
* Project one live record into its view.
|
|
2024
|
+
* @param record - the server.
|
|
2025
|
+
* @returns the view.
|
|
2026
|
+
*/
|
|
2027
|
+
viewOf(record) {
|
|
2028
|
+
return {
|
|
2029
|
+
serverId: record.serverId,
|
|
2030
|
+
name: record.name,
|
|
2031
|
+
origin: record.origin,
|
|
2032
|
+
startable: true,
|
|
2033
|
+
state: record.state,
|
|
2034
|
+
...record.url === void 0 ? {} : { url: record.url },
|
|
2035
|
+
...record.port === void 0 ? {} : { port: record.port },
|
|
2036
|
+
...record.handle.pid < 0 ? {} : { pid: record.handle.pid },
|
|
2037
|
+
...record.state === "exited" || record.state === "failed" ? { exitCode: record.exitCode } : {},
|
|
2038
|
+
...record.detail === void 0 ? {} : { detail: record.detail },
|
|
2039
|
+
startedAt: record.startedAt
|
|
2040
|
+
};
|
|
2041
|
+
}
|
|
2042
|
+
};
|
|
2043
|
+
|
|
2044
|
+
//#endregion
|
|
2045
|
+
//#region tsbuild/host/terminals.js
|
|
2046
|
+
/** Compose one classified failure. */
|
|
2047
|
+
function fail$1(code, message) {
|
|
2048
|
+
return {
|
|
2049
|
+
ok: false,
|
|
2050
|
+
code,
|
|
2051
|
+
message
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
/** Terminal geometry the substrate will accept, whatever the panel measured. */
|
|
2055
|
+
function clampGeometry(cols, rows) {
|
|
2056
|
+
const bound = (value, low, high) => Number.isFinite(value) ? Math.min(Math.max(Math.round(value), low), high) : low;
|
|
2057
|
+
return {
|
|
2058
|
+
cols: bound(cols, 20, 500),
|
|
2059
|
+
rows: bound(rows, 5, 200)
|
|
2060
|
+
};
|
|
2061
|
+
}
|
|
2062
|
+
/**
|
|
2063
|
+
* Owns every panel terminal in the process. One instance is created by the service and disposed
|
|
2064
|
+
* with it, which is what guarantees no shell outlives the plugin.
|
|
2065
|
+
*/
|
|
2066
|
+
var PanelTerminals = class {
|
|
2067
|
+
ctx;
|
|
2068
|
+
source;
|
|
2069
|
+
records = /* @__PURE__ */ new Map();
|
|
2070
|
+
closing = false;
|
|
2071
|
+
/**
|
|
2072
|
+
* @param ctx - Host context carrying the subprocess and filesystem capabilities.
|
|
2073
|
+
* @param source - reads the current settings section; called per request.
|
|
2074
|
+
*/
|
|
2075
|
+
constructor(ctx, source) {
|
|
2076
|
+
this.ctx = ctx;
|
|
2077
|
+
this.source = source;
|
|
2078
|
+
}
|
|
2079
|
+
/**
|
|
2080
|
+
* Report whether a panel terminal can be allocated on this Host.
|
|
2081
|
+
* @returns availability plus the shell that would answer.
|
|
2082
|
+
*/
|
|
2083
|
+
describe() {
|
|
2084
|
+
if (this.ctx.get("subprocess") === void 0) return {
|
|
2085
|
+
available: false,
|
|
2086
|
+
reason: "no subprocess capability is mounted: this deployment composes no @deepseek-ai/dsh-subprocess provider"
|
|
2087
|
+
};
|
|
2088
|
+
if (this.ctx.get("fs") === void 0) return {
|
|
2089
|
+
available: false,
|
|
2090
|
+
reason: "no filesystem capability is mounted: the terminal has no way to resolve its working directory"
|
|
2091
|
+
};
|
|
2092
|
+
return {
|
|
2093
|
+
available: true,
|
|
2094
|
+
detail: this.shell()
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
/**
|
|
2098
|
+
* Allocate one terminal in a workspace.
|
|
2099
|
+
* @param request - the directory and the panel's measured geometry.
|
|
2100
|
+
* @param signal - cancellation of the allocation; a published terminal owns its later lifetime.
|
|
2101
|
+
* @returns the handle, or a classified failure.
|
|
2102
|
+
*/
|
|
2103
|
+
async open(request, signal) {
|
|
2104
|
+
if (this.closing) return fail$1("closed", "the plugin is unloading");
|
|
2105
|
+
const subprocess = this.ctx.get("subprocess");
|
|
2106
|
+
if (subprocess === void 0) return fail$1("no-subprocess", "no subprocess capability is mounted");
|
|
2107
|
+
const settings = this.source();
|
|
2108
|
+
if ([...this.records.values()].filter((record$1) => record$1.running).length >= settings.maxTerminals) return fail$1("limit-reached", `${String(settings.maxTerminals)} panel terminals are already open`);
|
|
2109
|
+
const workspace = await resolveWorkspace(this.ctx, request.workspacePath, signal);
|
|
2110
|
+
if (!workspace.ok) return fail$1(workspace.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied", workspace.rejection.message);
|
|
2111
|
+
const shell = this.shell();
|
|
2112
|
+
const { cols, rows } = clampGeometry(request.cols, request.rows);
|
|
2113
|
+
let handle;
|
|
2114
|
+
try {
|
|
2115
|
+
handle = await subprocess.spawnTerminal({
|
|
2116
|
+
argv: [shell],
|
|
2117
|
+
cwd: workspace.value.processPath,
|
|
2118
|
+
rows,
|
|
2119
|
+
cols,
|
|
2120
|
+
graceMs: settings.terminalGraceMs,
|
|
2121
|
+
signal,
|
|
2122
|
+
env: {
|
|
2123
|
+
TERM: "xterm-256color",
|
|
2124
|
+
COLUMNS: String(cols),
|
|
2125
|
+
LINES: String(rows)
|
|
2126
|
+
}
|
|
2127
|
+
});
|
|
2128
|
+
} catch (error) {
|
|
2129
|
+
return fail$1("spawn-failed", error instanceof Error ? error.message : String(error));
|
|
2130
|
+
}
|
|
2131
|
+
const record = {
|
|
2132
|
+
id: randomUUID(),
|
|
2133
|
+
handle,
|
|
2134
|
+
shell,
|
|
2135
|
+
cwd: workspace.value.processPath,
|
|
2136
|
+
base: 0,
|
|
2137
|
+
buffer: "",
|
|
2138
|
+
running: true,
|
|
2139
|
+
exitCode: null,
|
|
2140
|
+
signal: null
|
|
2141
|
+
};
|
|
2142
|
+
this.records.set(record.id, record);
|
|
2143
|
+
this.collect(record);
|
|
2144
|
+
return {
|
|
2145
|
+
ok: true,
|
|
2146
|
+
terminalId: record.id,
|
|
2147
|
+
shell,
|
|
2148
|
+
cwd: record.cwd,
|
|
2149
|
+
pid: handle.pid
|
|
2150
|
+
};
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* Read output from a caller-owned offset.
|
|
2154
|
+
* @param request - the handle and the offset already rendered.
|
|
2155
|
+
* @returns the delta and the process state, or a classified failure.
|
|
2156
|
+
*/
|
|
2157
|
+
read(request) {
|
|
2158
|
+
const record = this.records.get(request.terminalId);
|
|
2159
|
+
if (record === void 0) return fail$1("unknown-terminal", `no panel terminal ${request.terminalId}`);
|
|
2160
|
+
const total = record.base + record.buffer.length;
|
|
2161
|
+
const from = Number.isFinite(request.fromOffset) ? Math.max(0, Math.floor(request.fromOffset)) : 0;
|
|
2162
|
+
const lossy = from < record.base;
|
|
2163
|
+
const text = lossy ? record.buffer : record.buffer.slice(Math.min(from - record.base, record.buffer.length));
|
|
2164
|
+
return {
|
|
2165
|
+
ok: true,
|
|
2166
|
+
terminalId: record.id,
|
|
2167
|
+
text,
|
|
2168
|
+
nextOffset: total,
|
|
2169
|
+
lossy,
|
|
2170
|
+
running: record.running,
|
|
2171
|
+
...record.running ? {} : {
|
|
2172
|
+
exitCode: record.exitCode,
|
|
2173
|
+
signal: record.signal
|
|
2174
|
+
}
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
/**
|
|
2178
|
+
* Send keystrokes.
|
|
2179
|
+
* @param request - the handle and the text to deliver verbatim.
|
|
2180
|
+
* @returns settlement, or a classified failure.
|
|
2181
|
+
*/
|
|
2182
|
+
async write(request) {
|
|
2183
|
+
const record = this.records.get(request.terminalId);
|
|
2184
|
+
if (record === void 0) return fail$1("unknown-terminal", `no panel terminal ${request.terminalId}`);
|
|
2185
|
+
if (!record.running) return fail$1("unknown-terminal", `panel terminal ${record.id} has exited`);
|
|
2186
|
+
try {
|
|
2187
|
+
await record.handle.write(request.data);
|
|
2188
|
+
} catch (error) {
|
|
2189
|
+
return fail$1("spawn-failed", error instanceof Error ? error.message : String(error));
|
|
2190
|
+
}
|
|
2191
|
+
return { ok: true };
|
|
2192
|
+
}
|
|
2193
|
+
/**
|
|
2194
|
+
* Deliver a signal to the terminal's foreground process group.
|
|
2195
|
+
* @param request - the handle and the signal.
|
|
2196
|
+
* @returns settlement, or a classified failure.
|
|
2197
|
+
*/
|
|
2198
|
+
async signal(request) {
|
|
2199
|
+
const record = this.records.get(request.terminalId);
|
|
2200
|
+
if (record === void 0) return fail$1("unknown-terminal", `no panel terminal ${request.terminalId}`);
|
|
2201
|
+
if (!record.running) return fail$1("unknown-terminal", `panel terminal ${record.id} has exited`);
|
|
2202
|
+
try {
|
|
2203
|
+
await record.handle.signalForeground(request.signal);
|
|
2204
|
+
} catch {}
|
|
2205
|
+
return { ok: true };
|
|
2206
|
+
}
|
|
2207
|
+
/**
|
|
2208
|
+
* Close one terminal and forget it.
|
|
2209
|
+
* @param request - the handle.
|
|
2210
|
+
* @returns settlement, or a classified failure.
|
|
2211
|
+
*/
|
|
2212
|
+
async close(request) {
|
|
2213
|
+
const record = this.records.get(request.terminalId);
|
|
2214
|
+
if (record === void 0) return fail$1("unknown-terminal", `no panel terminal ${request.terminalId}`);
|
|
2215
|
+
this.records.delete(record.id);
|
|
2216
|
+
await terminateQuietly(record.handle);
|
|
2217
|
+
return { ok: true };
|
|
2218
|
+
}
|
|
2219
|
+
/**
|
|
2220
|
+
* Terminate every terminal and refuse new ones. Called from the service's teardown effect.
|
|
2221
|
+
* @returns after every terminal session has settled.
|
|
2222
|
+
*/
|
|
2223
|
+
async disposeAll() {
|
|
2224
|
+
this.closing = true;
|
|
2225
|
+
const live = [...this.records.values()];
|
|
2226
|
+
this.records.clear();
|
|
2227
|
+
await Promise.all(live.map((record) => terminateQuietly(record.handle)));
|
|
2228
|
+
}
|
|
2229
|
+
/**
|
|
2230
|
+
* The shell a new terminal starts: the configured one, then `$SHELL`, then the platform default.
|
|
2231
|
+
* @returns an executable name or path.
|
|
2232
|
+
*/
|
|
2233
|
+
shell() {
|
|
2234
|
+
const configured = this.source().terminalShell.trim();
|
|
2235
|
+
if (configured !== "") return configured;
|
|
2236
|
+
if (process.platform === "win32") return process.env.COMSPEC ?? "powershell.exe";
|
|
2237
|
+
const login = process.env.SHELL;
|
|
2238
|
+
return login !== void 0 && login !== "" ? login : "/bin/sh";
|
|
2239
|
+
}
|
|
2240
|
+
/**
|
|
2241
|
+
* Drain one terminal's output into its retained scrollback and record its settlement.
|
|
2242
|
+
*
|
|
2243
|
+
* Attached at allocation rather than at first read: a shell prints its prompt immediately, and a
|
|
2244
|
+
* stream nobody is reading would otherwise apply backpressure until the panel polled.
|
|
2245
|
+
* @param record - the freshly registered terminal.
|
|
2246
|
+
*/
|
|
2247
|
+
collect(record) {
|
|
2248
|
+
const decoder = new TextDecoder("utf-8", { fatal: false });
|
|
2249
|
+
record.handle.output.on("data", (chunk) => {
|
|
2250
|
+
const text = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
2251
|
+
if (text === "") return;
|
|
2252
|
+
record.buffer += text;
|
|
2253
|
+
const max = Math.max(this.source().terminalScrollback, 1024);
|
|
2254
|
+
if (record.buffer.length > max) {
|
|
2255
|
+
const drop = record.buffer.length - max;
|
|
2256
|
+
record.base += drop;
|
|
2257
|
+
record.buffer = record.buffer.slice(drop);
|
|
2258
|
+
}
|
|
2259
|
+
});
|
|
2260
|
+
record.handle.done.then((outcome) => {
|
|
2261
|
+
record.running = false;
|
|
2262
|
+
record.exitCode = outcome.exitCode;
|
|
2263
|
+
record.signal = outcome.signal;
|
|
2264
|
+
}, (error) => {
|
|
2265
|
+
record.running = false;
|
|
2266
|
+
record.buffer += `\r\n[terminal transport failed: ${error instanceof Error ? error.message : String(error)}]\r\n`;
|
|
2267
|
+
});
|
|
2268
|
+
}
|
|
2269
|
+
};
|
|
2270
|
+
/**
|
|
2271
|
+
* Terminate one terminal session without letting a cleanup fault escape.
|
|
2272
|
+
* @param handle - the terminal to close.
|
|
2273
|
+
* @returns after the session tree has settled.
|
|
2274
|
+
*/
|
|
2275
|
+
async function terminateQuietly(handle) {
|
|
2276
|
+
try {
|
|
2277
|
+
await handle.terminate();
|
|
2278
|
+
} catch {}
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
//#endregion
|
|
2282
|
+
//#region tsbuild/host/tasks.js
|
|
2283
|
+
/** Compose one classified failure. */
|
|
2284
|
+
function fail(code, message) {
|
|
2285
|
+
return {
|
|
2286
|
+
ok: false,
|
|
2287
|
+
code,
|
|
2288
|
+
message
|
|
2289
|
+
};
|
|
2290
|
+
}
|
|
2291
|
+
/** Whether the registry still holds the record open. */
|
|
2292
|
+
function isLive(snapshot) {
|
|
2293
|
+
return snapshot.status === "running" || snapshot.status === "stopping";
|
|
2294
|
+
}
|
|
2295
|
+
/**
|
|
2296
|
+
* Stops and reads background tasks on behalf of the Tasks panel. One instance serves every request
|
|
2297
|
+
* and retains the output it has already drained.
|
|
2298
|
+
*/
|
|
2299
|
+
var TaskController = class {
|
|
2300
|
+
ctx;
|
|
2301
|
+
source;
|
|
2302
|
+
/** Accumulated output per job id, so a second panel open is not an empty read. */
|
|
2303
|
+
collected = /* @__PURE__ */ new Map();
|
|
2304
|
+
/**
|
|
2305
|
+
* Tasks whose output has already been taken.
|
|
2306
|
+
*
|
|
2307
|
+
* `read()` is CONSUMING for a stream job and IDEMPOTENT for a final-output one, and the snapshot
|
|
2308
|
+
* does not say which kind a job is. Appending every read would therefore duplicate a
|
|
2309
|
+
* final-output job's text once per poll, so each task is drained exactly once — after settlement,
|
|
2310
|
+
* when there is nothing further to come.
|
|
2311
|
+
*/
|
|
2312
|
+
drained = /* @__PURE__ */ new Set();
|
|
2313
|
+
/**
|
|
2314
|
+
* @param ctx - Host context carrying the optional job registry and the agent registry.
|
|
2315
|
+
* @param source - reads the current settings section; called per request.
|
|
2316
|
+
*/
|
|
2317
|
+
constructor(ctx, source) {
|
|
2318
|
+
this.ctx = ctx;
|
|
2319
|
+
this.source = source;
|
|
2320
|
+
}
|
|
2321
|
+
/**
|
|
2322
|
+
* Report what the Tasks panel may do on this Host.
|
|
2323
|
+
* @returns availability plus the two per-verb permissions.
|
|
2324
|
+
*/
|
|
2325
|
+
describe() {
|
|
2326
|
+
const settings = this.source();
|
|
2327
|
+
if (this.ctx.get("jobs") === void 0) return {
|
|
2328
|
+
available: false,
|
|
2329
|
+
reason: "no job registry is mounted: this deployment composes no @deepseek-ai/dsh-jobs provider",
|
|
2330
|
+
canKill: false,
|
|
2331
|
+
canReadOutput: false
|
|
2332
|
+
};
|
|
2333
|
+
return {
|
|
2334
|
+
available: true,
|
|
2335
|
+
canKill: settings.allowTaskKill,
|
|
2336
|
+
canReadOutput: settings.showTaskOutput
|
|
2337
|
+
};
|
|
2338
|
+
}
|
|
2339
|
+
/**
|
|
2340
|
+
* Stop one live task.
|
|
2341
|
+
*
|
|
2342
|
+
* The registry marks a killed record reported, which suppresses the completion notice its
|
|
2343
|
+
* producer would otherwise open a model turn to deliver. That is the correct trade for a person
|
|
2344
|
+
* pressing Stop — the work is being cancelled on their authority, not the model's — and it is why
|
|
2345
|
+
* this verb is gated by `allowTaskKill` rather than always on.
|
|
2346
|
+
* @param request - the owning session and the task id.
|
|
2347
|
+
* @returns what the registry did, or a classified failure.
|
|
2348
|
+
*/
|
|
2349
|
+
async kill(request) {
|
|
2350
|
+
if (!this.source().allowTaskKill) return fail("disabled", "stopping a background task is switched off in the advanced-sidebar settings");
|
|
2351
|
+
const bound = this.bind(request.sessionId, request.taskId);
|
|
2352
|
+
if ("failure" in bound) return bound.failure;
|
|
2353
|
+
try {
|
|
2354
|
+
const outcome = bound.jobs.kill(bound.id, bound.agent, "stopped from the DeepSeek Harness sidebar");
|
|
2355
|
+
await this.absorb(request.sessionId, request.taskId);
|
|
2356
|
+
return {
|
|
2357
|
+
ok: true,
|
|
2358
|
+
outcome
|
|
2359
|
+
};
|
|
2360
|
+
} catch (error) {
|
|
2361
|
+
return fail("registry-refused", error instanceof Error ? error.message : String(error));
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
/**
|
|
2365
|
+
* Read one task's output, or say why it is withheld.
|
|
2366
|
+
* @param request - the owning session and the task id.
|
|
2367
|
+
* @returns the accumulated output, or a classified failure.
|
|
2368
|
+
*/
|
|
2369
|
+
async output(request) {
|
|
2370
|
+
if (!this.source().showTaskOutput) return fail("disabled", "task output is switched off in the advanced-sidebar settings");
|
|
2371
|
+
const bound = this.bind(request.sessionId, request.taskId);
|
|
2372
|
+
if ("failure" in bound) return bound.failure;
|
|
2373
|
+
let snapshot;
|
|
2374
|
+
try {
|
|
2375
|
+
snapshot = bound.jobs.get(bound.id, bound.agent);
|
|
2376
|
+
} catch (error) {
|
|
2377
|
+
return fail("unknown-task", error instanceof Error ? error.message : String(error));
|
|
2378
|
+
}
|
|
2379
|
+
const retained = this.collected.get(request.taskId) ?? "";
|
|
2380
|
+
if (isLive(snapshot) || !snapshot.reported) return {
|
|
2381
|
+
ok: true,
|
|
2382
|
+
taskId: request.taskId,
|
|
2383
|
+
readable: retained !== "",
|
|
2384
|
+
text: retained,
|
|
2385
|
+
reason: isLive(snapshot) ? "the task is still running; its output stream belongs to the model until the task settles" : "the task has settled but its completion has not been reported to the model yet"
|
|
2386
|
+
};
|
|
2387
|
+
await this.absorb(request.sessionId, request.taskId);
|
|
2388
|
+
return {
|
|
2389
|
+
ok: true,
|
|
2390
|
+
taskId: request.taskId,
|
|
2391
|
+
readable: true,
|
|
2392
|
+
text: this.collected.get(request.taskId) ?? ""
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
/** Drop retained output. Called from the service's teardown effect. */
|
|
2396
|
+
dispose() {
|
|
2397
|
+
this.collected.clear();
|
|
2398
|
+
this.drained.clear();
|
|
2399
|
+
}
|
|
2400
|
+
/**
|
|
2401
|
+
* Drain whatever the registry will still hand over and append it to the retained text.
|
|
2402
|
+
* @param sessionId - the owning session.
|
|
2403
|
+
* @param taskId - the task id.
|
|
2404
|
+
*/
|
|
2405
|
+
async absorb(sessionId, taskId) {
|
|
2406
|
+
if (this.drained.has(taskId)) return;
|
|
2407
|
+
const bound = this.bind(sessionId, taskId);
|
|
2408
|
+
if ("failure" in bound) return;
|
|
2409
|
+
this.drained.add(taskId);
|
|
2410
|
+
try {
|
|
2411
|
+
const read = bound.jobs.read(bound.id, bound.agent);
|
|
2412
|
+
if (read.text !== "") this.collected.set(taskId, (this.collected.get(taskId) ?? "") + read.text);
|
|
2413
|
+
} catch {}
|
|
2414
|
+
await Promise.resolve();
|
|
2415
|
+
}
|
|
2416
|
+
/**
|
|
2417
|
+
* Resolve the registry, the owning agent, and the branded job id together.
|
|
2418
|
+
* @param sessionId - the owning session, as the browser spelled it.
|
|
2419
|
+
* @param taskId - the task id, as the browser spelled it.
|
|
2420
|
+
* @returns the bound handles, or the failure to return.
|
|
2421
|
+
*/
|
|
2422
|
+
bind(sessionId, taskId) {
|
|
2423
|
+
const jobs = this.ctx.get("jobs");
|
|
2424
|
+
if (jobs === void 0) return { failure: fail("no-registry", "no job registry is mounted") };
|
|
2425
|
+
const agent = this.ctx.agents.get(sessionId);
|
|
2426
|
+
if (agent === void 0) return { failure: fail("unknown-session", `no live agent answers for session ${sessionId}`) };
|
|
2427
|
+
return {
|
|
2428
|
+
jobs,
|
|
2429
|
+
agent,
|
|
2430
|
+
id: JobId(taskId)
|
|
2431
|
+
};
|
|
2432
|
+
}
|
|
2433
|
+
};
|
|
2434
|
+
|
|
2435
|
+
//#endregion
|
|
2436
|
+
//#region tsbuild/host/index.js
|
|
2437
|
+
/**
|
|
2438
|
+
* The advanced-sidebar plugin's node half: one Remote namespace serving every operation the browser
|
|
2439
|
+
* structurally cannot perform, plus the `advanced-sidebar` settings section both halves address.
|
|
2440
|
+
*
|
|
2441
|
+
* What is here and what is not follows one rule — the Host owns only what a browser cannot do. The
|
|
2442
|
+
* session list, the workspace list, the background-task list, archiving, and directory listing all
|
|
2443
|
+
* already reach the Web Client through capabilities it holds, so this endpoint adds no second copy
|
|
2444
|
+
* of any of them. It answers for git (a subprocess), panel terminals (a pseudo-terminal), file
|
|
2445
|
+
* previews (a filesystem read), external applications (a launch), stopping a background task
|
|
2446
|
+
* (an owner-fenced registry), and deletion (a durable artifact).
|
|
2447
|
+
*
|
|
2448
|
+
* Nothing here is model-facing: no tool, no prompt section, no session event. Every result is a
|
|
2449
|
+
* discriminated value rather than a throw, because the RPC gateway erases a business exception's
|
|
2450
|
+
* classification and each panel's next move depends on which class it was.
|
|
2451
|
+
* @module @achasoft/dsh-advanced-sidebar/host
|
|
2452
|
+
*/
|
|
2453
|
+
var __runInitializers = void 0 && (void 0).__runInitializers || function(thisArg, initializers, value) {
|
|
2454
|
+
var useValue = arguments.length > 2;
|
|
2455
|
+
for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
2456
|
+
return useValue ? value : void 0;
|
|
2457
|
+
};
|
|
2458
|
+
var __esDecorate = void 0 && (void 0).__esDecorate || function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
2459
|
+
function accept(f) {
|
|
2460
|
+
if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
|
|
2461
|
+
return f;
|
|
2462
|
+
}
|
|
2463
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
2464
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
2465
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
2466
|
+
var _, done = false;
|
|
2467
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
2468
|
+
var context = {};
|
|
2469
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
2470
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
2471
|
+
context.addInitializer = function(f) {
|
|
2472
|
+
if (done) throw new TypeError("Cannot add initializers after decoration has completed");
|
|
2473
|
+
extraInitializers.push(accept(f || null));
|
|
2474
|
+
};
|
|
2475
|
+
var result = (0, decorators[i])(kind === "accessor" ? {
|
|
2476
|
+
get: descriptor.get,
|
|
2477
|
+
set: descriptor.set
|
|
2478
|
+
} : descriptor[key], context);
|
|
2479
|
+
if (kind === "accessor") {
|
|
2480
|
+
if (result === void 0) continue;
|
|
2481
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
2482
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
2483
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
2484
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
2485
|
+
} else if (_ = accept(result)) if (kind === "field") initializers.unshift(_);
|
|
2486
|
+
else descriptor[key] = _;
|
|
2487
|
+
}
|
|
2488
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
2489
|
+
done = true;
|
|
2490
|
+
};
|
|
2491
|
+
/**
|
|
2492
|
+
* The settings namespace both halves address; the browser card joins the plugin tab on it.
|
|
2493
|
+
*
|
|
2494
|
+
* Kebab-case, unlike the Remote namespace below: a settings namespace is a kebab-case grammar the
|
|
2495
|
+
* Host validates, while a Remote namespace is read as `ctx.remote.advancedSidebar.…` and so must be
|
|
2496
|
+
* an identifier.
|
|
2497
|
+
*/
|
|
2498
|
+
const ADVANCED_SIDEBAR_SETTINGS_NAMESPACE = settingsNamespace("advanced-sidebar");
|
|
2499
|
+
/** An Open in target id must be usable in a menu and on the wire. */
|
|
2500
|
+
const TARGET_ID_PATTERN = /^[a-z][a-z0-9-]*$/u;
|
|
2501
|
+
/**
|
|
2502
|
+
* Reject a section this service could not act on, for the constraints the schema cannot express.
|
|
2503
|
+
*
|
|
2504
|
+
* Called from the constructor as well as from the settings hook: the settings seam is optional, so
|
|
2505
|
+
* a composition without it never runs the hook — and these constraints come straight off the
|
|
2506
|
+
* composition file, where being wrong is a load-time mistake rather than a running deployment.
|
|
2507
|
+
* @param value - the resolved section, schema-valid by construction.
|
|
2508
|
+
*/
|
|
2509
|
+
function validateConfig(value) {
|
|
2510
|
+
const seen = new Set(["reveal"]);
|
|
2511
|
+
for (const editor of value.editors) {
|
|
2512
|
+
if (!TARGET_ID_PATTERN.test(editor.id)) throw new TypeError(`advanced-sidebar: editor id "${editor.id}" must match ${String(TARGET_ID_PATTERN)}`);
|
|
2513
|
+
if (seen.has(editor.id)) throw new TypeError(`advanced-sidebar: editor id "${editor.id}" is used twice (or collides with the built-in file-manager target)`);
|
|
2514
|
+
seen.add(editor.id);
|
|
2515
|
+
if (editor.command.trim() === "") throw new TypeError(`advanced-sidebar: editor "${editor.id}" has an empty command`);
|
|
2516
|
+
}
|
|
2517
|
+
const names = /* @__PURE__ */ new Set();
|
|
2518
|
+
for (const preview of value.previews) {
|
|
2519
|
+
if (preview.name.trim() === "") throw new TypeError("advanced-sidebar: a preview row has an empty name");
|
|
2520
|
+
if (names.has(preview.name)) throw new TypeError(`advanced-sidebar: preview name "${preview.name}" is used twice`);
|
|
2521
|
+
names.add(preview.name);
|
|
2522
|
+
if (preview.runtimeExecutable === "" && preview.url === "" && preview.port <= 0) throw new TypeError(`advanced-sidebar: preview "${preview.name}" names no command, no url, and no port, so there is nothing to start and nowhere to point the frame`);
|
|
2523
|
+
}
|
|
2524
|
+
if (value.deleteMode === "purge" && !value.confirmDelete) throw new TypeError("advanced-sidebar: deleteMode \"purge\" removes a session log irreversibly, so confirmDelete cannot be false");
|
|
2525
|
+
}
|
|
2526
|
+
/** Schemastery shape of one preview launch row. */
|
|
2527
|
+
const PreviewSchema = z.object({
|
|
2528
|
+
name: z.string().required(),
|
|
2529
|
+
runtimeExecutable: z.string().required(),
|
|
2530
|
+
runtimeArgs: z.array(z.string()).required(),
|
|
2531
|
+
port: z.number().step(1).min(0).max(65535).required(),
|
|
2532
|
+
url: z.string().required(),
|
|
2533
|
+
cwd: z.string().required()
|
|
2534
|
+
});
|
|
2535
|
+
/** Schemastery shape of one Open in target row. */
|
|
2536
|
+
const EditorSchema = z.object({
|
|
2537
|
+
id: z.string().required(),
|
|
2538
|
+
label: z.string().required(),
|
|
2539
|
+
command: z.string().required(),
|
|
2540
|
+
args: z.array(z.string()).required()
|
|
2541
|
+
});
|
|
2542
|
+
/** Host endpoint for the sidebar's advanced operations, and owner of the settings section. */
|
|
2543
|
+
let AdvancedSidebarService = (() => {
|
|
2544
|
+
let _classSuper = TypertRemoteService;
|
|
2545
|
+
let _instanceExtraInitializers = [];
|
|
2546
|
+
let _describe_decorators;
|
|
2547
|
+
let _gitStatus_decorators;
|
|
2548
|
+
let _gitDiff_decorators;
|
|
2549
|
+
let _gitStage_decorators;
|
|
2550
|
+
let _gitUnstage_decorators;
|
|
2551
|
+
let _gitCommit_decorators;
|
|
2552
|
+
let _gitPush_decorators;
|
|
2553
|
+
let _gitCommitMessage_decorators;
|
|
2554
|
+
let _terminalOpen_decorators;
|
|
2555
|
+
let _terminalRead_decorators;
|
|
2556
|
+
let _terminalWrite_decorators;
|
|
2557
|
+
let _terminalSignal_decorators;
|
|
2558
|
+
let _terminalClose_decorators;
|
|
2559
|
+
let _listEntries_decorators;
|
|
2560
|
+
let _previewList_decorators;
|
|
2561
|
+
let _previewStart_decorators;
|
|
2562
|
+
let _previewStop_decorators;
|
|
2563
|
+
let _previewLogs_decorators;
|
|
2564
|
+
let _readFile_decorators;
|
|
2565
|
+
let _openIn_decorators;
|
|
2566
|
+
let _taskKill_decorators;
|
|
2567
|
+
let _taskOutput_decorators;
|
|
2568
|
+
let _deleteSession_decorators;
|
|
2569
|
+
return class AdvancedSidebarService$1 extends _classSuper {
|
|
2570
|
+
static {
|
|
2571
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
2572
|
+
_describe_decorators = [Remote("describe")];
|
|
2573
|
+
_gitStatus_decorators = [Remote("gitStatus")];
|
|
2574
|
+
_gitDiff_decorators = [Remote("gitDiff")];
|
|
2575
|
+
_gitStage_decorators = [Remote("gitStage")];
|
|
2576
|
+
_gitUnstage_decorators = [Remote("gitUnstage")];
|
|
2577
|
+
_gitCommit_decorators = [Remote("gitCommit")];
|
|
2578
|
+
_gitPush_decorators = [Remote("gitPush")];
|
|
2579
|
+
_gitCommitMessage_decorators = [Remote("gitCommitMessage")];
|
|
2580
|
+
_terminalOpen_decorators = [Remote("terminalOpen")];
|
|
2581
|
+
_terminalRead_decorators = [Remote("terminalRead")];
|
|
2582
|
+
_terminalWrite_decorators = [Remote("terminalWrite")];
|
|
2583
|
+
_terminalSignal_decorators = [Remote("terminalSignal")];
|
|
2584
|
+
_terminalClose_decorators = [Remote("terminalClose")];
|
|
2585
|
+
_listEntries_decorators = [Remote("listEntries")];
|
|
2586
|
+
_previewList_decorators = [Remote("previewList")];
|
|
2587
|
+
_previewStart_decorators = [Remote("previewStart")];
|
|
2588
|
+
_previewStop_decorators = [Remote("previewStop")];
|
|
2589
|
+
_previewLogs_decorators = [Remote("previewLogs")];
|
|
2590
|
+
_readFile_decorators = [Remote("readFile")];
|
|
2591
|
+
_openIn_decorators = [Remote("openIn")];
|
|
2592
|
+
_taskKill_decorators = [Remote("taskKill")];
|
|
2593
|
+
_taskOutput_decorators = [Remote("taskOutput")];
|
|
2594
|
+
_deleteSession_decorators = [Remote("deleteSession")];
|
|
2595
|
+
__esDecorate(this, null, _describe_decorators, {
|
|
2596
|
+
kind: "method",
|
|
2597
|
+
name: "describe",
|
|
2598
|
+
static: false,
|
|
2599
|
+
private: false,
|
|
2600
|
+
access: {
|
|
2601
|
+
has: (obj) => "describe" in obj,
|
|
2602
|
+
get: (obj) => obj.describe
|
|
2603
|
+
},
|
|
2604
|
+
metadata: _metadata
|
|
2605
|
+
}, null, _instanceExtraInitializers);
|
|
2606
|
+
__esDecorate(this, null, _gitStatus_decorators, {
|
|
2607
|
+
kind: "method",
|
|
2608
|
+
name: "gitStatus",
|
|
2609
|
+
static: false,
|
|
2610
|
+
private: false,
|
|
2611
|
+
access: {
|
|
2612
|
+
has: (obj) => "gitStatus" in obj,
|
|
2613
|
+
get: (obj) => obj.gitStatus
|
|
2614
|
+
},
|
|
2615
|
+
metadata: _metadata
|
|
2616
|
+
}, null, _instanceExtraInitializers);
|
|
2617
|
+
__esDecorate(this, null, _gitDiff_decorators, {
|
|
2618
|
+
kind: "method",
|
|
2619
|
+
name: "gitDiff",
|
|
2620
|
+
static: false,
|
|
2621
|
+
private: false,
|
|
2622
|
+
access: {
|
|
2623
|
+
has: (obj) => "gitDiff" in obj,
|
|
2624
|
+
get: (obj) => obj.gitDiff
|
|
2625
|
+
},
|
|
2626
|
+
metadata: _metadata
|
|
2627
|
+
}, null, _instanceExtraInitializers);
|
|
2628
|
+
__esDecorate(this, null, _gitStage_decorators, {
|
|
2629
|
+
kind: "method",
|
|
2630
|
+
name: "gitStage",
|
|
2631
|
+
static: false,
|
|
2632
|
+
private: false,
|
|
2633
|
+
access: {
|
|
2634
|
+
has: (obj) => "gitStage" in obj,
|
|
2635
|
+
get: (obj) => obj.gitStage
|
|
2636
|
+
},
|
|
2637
|
+
metadata: _metadata
|
|
2638
|
+
}, null, _instanceExtraInitializers);
|
|
2639
|
+
__esDecorate(this, null, _gitUnstage_decorators, {
|
|
2640
|
+
kind: "method",
|
|
2641
|
+
name: "gitUnstage",
|
|
2642
|
+
static: false,
|
|
2643
|
+
private: false,
|
|
2644
|
+
access: {
|
|
2645
|
+
has: (obj) => "gitUnstage" in obj,
|
|
2646
|
+
get: (obj) => obj.gitUnstage
|
|
2647
|
+
},
|
|
2648
|
+
metadata: _metadata
|
|
2649
|
+
}, null, _instanceExtraInitializers);
|
|
2650
|
+
__esDecorate(this, null, _gitCommit_decorators, {
|
|
2651
|
+
kind: "method",
|
|
2652
|
+
name: "gitCommit",
|
|
2653
|
+
static: false,
|
|
2654
|
+
private: false,
|
|
2655
|
+
access: {
|
|
2656
|
+
has: (obj) => "gitCommit" in obj,
|
|
2657
|
+
get: (obj) => obj.gitCommit
|
|
2658
|
+
},
|
|
2659
|
+
metadata: _metadata
|
|
2660
|
+
}, null, _instanceExtraInitializers);
|
|
2661
|
+
__esDecorate(this, null, _gitPush_decorators, {
|
|
2662
|
+
kind: "method",
|
|
2663
|
+
name: "gitPush",
|
|
2664
|
+
static: false,
|
|
2665
|
+
private: false,
|
|
2666
|
+
access: {
|
|
2667
|
+
has: (obj) => "gitPush" in obj,
|
|
2668
|
+
get: (obj) => obj.gitPush
|
|
2669
|
+
},
|
|
2670
|
+
metadata: _metadata
|
|
2671
|
+
}, null, _instanceExtraInitializers);
|
|
2672
|
+
__esDecorate(this, null, _gitCommitMessage_decorators, {
|
|
2673
|
+
kind: "method",
|
|
2674
|
+
name: "gitCommitMessage",
|
|
2675
|
+
static: false,
|
|
2676
|
+
private: false,
|
|
2677
|
+
access: {
|
|
2678
|
+
has: (obj) => "gitCommitMessage" in obj,
|
|
2679
|
+
get: (obj) => obj.gitCommitMessage
|
|
2680
|
+
},
|
|
2681
|
+
metadata: _metadata
|
|
2682
|
+
}, null, _instanceExtraInitializers);
|
|
2683
|
+
__esDecorate(this, null, _terminalOpen_decorators, {
|
|
2684
|
+
kind: "method",
|
|
2685
|
+
name: "terminalOpen",
|
|
2686
|
+
static: false,
|
|
2687
|
+
private: false,
|
|
2688
|
+
access: {
|
|
2689
|
+
has: (obj) => "terminalOpen" in obj,
|
|
2690
|
+
get: (obj) => obj.terminalOpen
|
|
2691
|
+
},
|
|
2692
|
+
metadata: _metadata
|
|
2693
|
+
}, null, _instanceExtraInitializers);
|
|
2694
|
+
__esDecorate(this, null, _terminalRead_decorators, {
|
|
2695
|
+
kind: "method",
|
|
2696
|
+
name: "terminalRead",
|
|
2697
|
+
static: false,
|
|
2698
|
+
private: false,
|
|
2699
|
+
access: {
|
|
2700
|
+
has: (obj) => "terminalRead" in obj,
|
|
2701
|
+
get: (obj) => obj.terminalRead
|
|
2702
|
+
},
|
|
2703
|
+
metadata: _metadata
|
|
2704
|
+
}, null, _instanceExtraInitializers);
|
|
2705
|
+
__esDecorate(this, null, _terminalWrite_decorators, {
|
|
2706
|
+
kind: "method",
|
|
2707
|
+
name: "terminalWrite",
|
|
2708
|
+
static: false,
|
|
2709
|
+
private: false,
|
|
2710
|
+
access: {
|
|
2711
|
+
has: (obj) => "terminalWrite" in obj,
|
|
2712
|
+
get: (obj) => obj.terminalWrite
|
|
2713
|
+
},
|
|
2714
|
+
metadata: _metadata
|
|
2715
|
+
}, null, _instanceExtraInitializers);
|
|
2716
|
+
__esDecorate(this, null, _terminalSignal_decorators, {
|
|
2717
|
+
kind: "method",
|
|
2718
|
+
name: "terminalSignal",
|
|
2719
|
+
static: false,
|
|
2720
|
+
private: false,
|
|
2721
|
+
access: {
|
|
2722
|
+
has: (obj) => "terminalSignal" in obj,
|
|
2723
|
+
get: (obj) => obj.terminalSignal
|
|
2724
|
+
},
|
|
2725
|
+
metadata: _metadata
|
|
2726
|
+
}, null, _instanceExtraInitializers);
|
|
2727
|
+
__esDecorate(this, null, _terminalClose_decorators, {
|
|
2728
|
+
kind: "method",
|
|
2729
|
+
name: "terminalClose",
|
|
2730
|
+
static: false,
|
|
2731
|
+
private: false,
|
|
2732
|
+
access: {
|
|
2733
|
+
has: (obj) => "terminalClose" in obj,
|
|
2734
|
+
get: (obj) => obj.terminalClose
|
|
2735
|
+
},
|
|
2736
|
+
metadata: _metadata
|
|
2737
|
+
}, null, _instanceExtraInitializers);
|
|
2738
|
+
__esDecorate(this, null, _listEntries_decorators, {
|
|
2739
|
+
kind: "method",
|
|
2740
|
+
name: "listEntries",
|
|
2741
|
+
static: false,
|
|
2742
|
+
private: false,
|
|
2743
|
+
access: {
|
|
2744
|
+
has: (obj) => "listEntries" in obj,
|
|
2745
|
+
get: (obj) => obj.listEntries
|
|
2746
|
+
},
|
|
2747
|
+
metadata: _metadata
|
|
2748
|
+
}, null, _instanceExtraInitializers);
|
|
2749
|
+
__esDecorate(this, null, _previewList_decorators, {
|
|
2750
|
+
kind: "method",
|
|
2751
|
+
name: "previewList",
|
|
2752
|
+
static: false,
|
|
2753
|
+
private: false,
|
|
2754
|
+
access: {
|
|
2755
|
+
has: (obj) => "previewList" in obj,
|
|
2756
|
+
get: (obj) => obj.previewList
|
|
2757
|
+
},
|
|
2758
|
+
metadata: _metadata
|
|
2759
|
+
}, null, _instanceExtraInitializers);
|
|
2760
|
+
__esDecorate(this, null, _previewStart_decorators, {
|
|
2761
|
+
kind: "method",
|
|
2762
|
+
name: "previewStart",
|
|
2763
|
+
static: false,
|
|
2764
|
+
private: false,
|
|
2765
|
+
access: {
|
|
2766
|
+
has: (obj) => "previewStart" in obj,
|
|
2767
|
+
get: (obj) => obj.previewStart
|
|
2768
|
+
},
|
|
2769
|
+
metadata: _metadata
|
|
2770
|
+
}, null, _instanceExtraInitializers);
|
|
2771
|
+
__esDecorate(this, null, _previewStop_decorators, {
|
|
2772
|
+
kind: "method",
|
|
2773
|
+
name: "previewStop",
|
|
2774
|
+
static: false,
|
|
2775
|
+
private: false,
|
|
2776
|
+
access: {
|
|
2777
|
+
has: (obj) => "previewStop" in obj,
|
|
2778
|
+
get: (obj) => obj.previewStop
|
|
2779
|
+
},
|
|
2780
|
+
metadata: _metadata
|
|
2781
|
+
}, null, _instanceExtraInitializers);
|
|
2782
|
+
__esDecorate(this, null, _previewLogs_decorators, {
|
|
2783
|
+
kind: "method",
|
|
2784
|
+
name: "previewLogs",
|
|
2785
|
+
static: false,
|
|
2786
|
+
private: false,
|
|
2787
|
+
access: {
|
|
2788
|
+
has: (obj) => "previewLogs" in obj,
|
|
2789
|
+
get: (obj) => obj.previewLogs
|
|
2790
|
+
},
|
|
2791
|
+
metadata: _metadata
|
|
2792
|
+
}, null, _instanceExtraInitializers);
|
|
2793
|
+
__esDecorate(this, null, _readFile_decorators, {
|
|
2794
|
+
kind: "method",
|
|
2795
|
+
name: "readFile",
|
|
2796
|
+
static: false,
|
|
2797
|
+
private: false,
|
|
2798
|
+
access: {
|
|
2799
|
+
has: (obj) => "readFile" in obj,
|
|
2800
|
+
get: (obj) => obj.readFile
|
|
2801
|
+
},
|
|
2802
|
+
metadata: _metadata
|
|
2803
|
+
}, null, _instanceExtraInitializers);
|
|
2804
|
+
__esDecorate(this, null, _openIn_decorators, {
|
|
2805
|
+
kind: "method",
|
|
2806
|
+
name: "openIn",
|
|
2807
|
+
static: false,
|
|
2808
|
+
private: false,
|
|
2809
|
+
access: {
|
|
2810
|
+
has: (obj) => "openIn" in obj,
|
|
2811
|
+
get: (obj) => obj.openIn
|
|
2812
|
+
},
|
|
2813
|
+
metadata: _metadata
|
|
2814
|
+
}, null, _instanceExtraInitializers);
|
|
2815
|
+
__esDecorate(this, null, _taskKill_decorators, {
|
|
2816
|
+
kind: "method",
|
|
2817
|
+
name: "taskKill",
|
|
2818
|
+
static: false,
|
|
2819
|
+
private: false,
|
|
2820
|
+
access: {
|
|
2821
|
+
has: (obj) => "taskKill" in obj,
|
|
2822
|
+
get: (obj) => obj.taskKill
|
|
2823
|
+
},
|
|
2824
|
+
metadata: _metadata
|
|
2825
|
+
}, null, _instanceExtraInitializers);
|
|
2826
|
+
__esDecorate(this, null, _taskOutput_decorators, {
|
|
2827
|
+
kind: "method",
|
|
2828
|
+
name: "taskOutput",
|
|
2829
|
+
static: false,
|
|
2830
|
+
private: false,
|
|
2831
|
+
access: {
|
|
2832
|
+
has: (obj) => "taskOutput" in obj,
|
|
2833
|
+
get: (obj) => obj.taskOutput
|
|
2834
|
+
},
|
|
2835
|
+
metadata: _metadata
|
|
2836
|
+
}, null, _instanceExtraInitializers);
|
|
2837
|
+
__esDecorate(this, null, _deleteSession_decorators, {
|
|
2838
|
+
kind: "method",
|
|
2839
|
+
name: "deleteSession",
|
|
2840
|
+
static: false,
|
|
2841
|
+
private: false,
|
|
2842
|
+
access: {
|
|
2843
|
+
has: (obj) => "deleteSession" in obj,
|
|
2844
|
+
get: (obj) => obj.deleteSession
|
|
2845
|
+
},
|
|
2846
|
+
metadata: _metadata
|
|
2847
|
+
}, null, _instanceExtraInitializers);
|
|
2848
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, {
|
|
2849
|
+
enumerable: true,
|
|
2850
|
+
configurable: true,
|
|
2851
|
+
writable: true,
|
|
2852
|
+
value: _metadata
|
|
2853
|
+
});
|
|
2854
|
+
}
|
|
2855
|
+
/** Loader validation for every deployment-varying choice this plugin makes. */
|
|
2856
|
+
static Config = z.object({
|
|
2857
|
+
showInSessionHeader: z.boolean().required(),
|
|
2858
|
+
showChanges: z.boolean().required(),
|
|
2859
|
+
showTerminal: z.boolean().required(),
|
|
2860
|
+
showFiles: z.boolean().required(),
|
|
2861
|
+
showTasks: z.boolean().required(),
|
|
2862
|
+
showOpenIn: z.boolean().required(),
|
|
2863
|
+
showArchive: z.boolean().required(),
|
|
2864
|
+
showDelete: z.boolean().required(),
|
|
2865
|
+
showPreview: z.boolean().required(),
|
|
2866
|
+
panelWidth: z.number().step(1).min(280).max(1400).required(),
|
|
2867
|
+
confirmDelete: z.boolean().required(),
|
|
2868
|
+
deleteMode: z.union(["archive", "purge"]).required(),
|
|
2869
|
+
allowTaskKill: z.boolean().required(),
|
|
2870
|
+
showTaskOutput: z.boolean().required(),
|
|
2871
|
+
gitMaxFiles: z.number().step(1).min(1).max(1e4).required(),
|
|
2872
|
+
gitDiffMaxBytes: z.number().step(1).min(1024).max(16 * 1024 * 1024).required(),
|
|
2873
|
+
gitTimeoutMs: z.number().step(1).min(1e3).max(6e5).required(),
|
|
2874
|
+
gitCommitTimeoutMs: z.number().step(1).min(1e3).max(18e5).required(),
|
|
2875
|
+
allowGitStaging: z.boolean().required(),
|
|
2876
|
+
allowGitCommit: z.boolean().required(),
|
|
2877
|
+
allowGitPush: z.boolean().required(),
|
|
2878
|
+
gitPushTimeoutMs: z.number().step(1).min(1e3).max(18e5).required(),
|
|
2879
|
+
allowCommitMessageDraft: z.boolean().required(),
|
|
2880
|
+
commitMessagePrompt: z.string(),
|
|
2881
|
+
commitMessageMaxBytes: z.number().step(1).min(1024).max(4 * 1024 * 1024).required(),
|
|
2882
|
+
terminalShell: z.string(),
|
|
2883
|
+
terminalScrollback: z.number().step(1).min(1024).max(4 * 1024 * 1024).required(),
|
|
2884
|
+
maxTerminals: z.number().step(1).min(1).max(32).required(),
|
|
2885
|
+
terminalGraceMs: z.number().step(1).min(100).max(6e4).required(),
|
|
2886
|
+
filesMaxPreviewBytes: z.number().step(1).min(1024).max(16 * 1024 * 1024).required(),
|
|
2887
|
+
filesMaxEntries: z.number().step(1).min(1).max(2e4).required(),
|
|
2888
|
+
filesShowHidden: z.boolean().required(),
|
|
2889
|
+
editors: z.array(EditorSchema).required(),
|
|
2890
|
+
previews: z.array(PreviewSchema).required(),
|
|
2891
|
+
previewsFromLaunchFile: z.boolean().required(),
|
|
2892
|
+
maxPreviews: z.number().step(1).min(1).max(16).required(),
|
|
2893
|
+
previewReadyTimeoutMs: z.number().step(1).min(1e3).max(6e5).required(),
|
|
2894
|
+
previewScrollback: z.number().step(1).min(1024).max(4 * 1024 * 1024).required(),
|
|
2895
|
+
previewGraceMs: z.number().step(1).min(100).max(6e4).required()
|
|
2896
|
+
});
|
|
2897
|
+
source = __runInitializers(this, _instanceExtraInitializers);
|
|
2898
|
+
git;
|
|
2899
|
+
terminals;
|
|
2900
|
+
launcher;
|
|
2901
|
+
files;
|
|
2902
|
+
tasks;
|
|
2903
|
+
deleter;
|
|
2904
|
+
preview;
|
|
2905
|
+
/**
|
|
2906
|
+
* @param ctx - Host context; every capability this service uses is resolved optionally, so a
|
|
2907
|
+
* deployment missing one still serves a view that explains which panel is dark and why.
|
|
2908
|
+
* @param config - the composition-layer preferences, used as the section's base layer.
|
|
2909
|
+
*/
|
|
2910
|
+
constructor(ctx, config) {
|
|
2911
|
+
super(ctx, "advancedSidebar");
|
|
2912
|
+
validateConfig(config);
|
|
2913
|
+
this.source = () => config;
|
|
2914
|
+
const read = () => this.source();
|
|
2915
|
+
this.git = new GitReader(ctx, read);
|
|
2916
|
+
this.terminals = new PanelTerminals(ctx, read);
|
|
2917
|
+
this.launcher = new OpenInLauncher(ctx, read);
|
|
2918
|
+
this.files = new FileReader(ctx, read);
|
|
2919
|
+
this.tasks = new TaskController(ctx, read);
|
|
2920
|
+
this.deleter = new SessionDeleter(ctx, read);
|
|
2921
|
+
this.preview = new PreviewServers(ctx, read);
|
|
2922
|
+
installSettingsSection(ctx, ADVANCED_SIDEBAR_SETTINGS_NAMESPACE, AdvancedSidebarService$1.Config, config, {
|
|
2923
|
+
setSource: (current) => {
|
|
2924
|
+
this.source = current;
|
|
2925
|
+
},
|
|
2926
|
+
onChange: () => {
|
|
2927
|
+
this.launcher.forget();
|
|
2928
|
+
},
|
|
2929
|
+
validate: validateConfig
|
|
2930
|
+
});
|
|
2931
|
+
ctx.effect(() => async () => {
|
|
2932
|
+
this.tasks.dispose();
|
|
2933
|
+
await Promise.all([this.terminals.disposeAll(), this.preview.disposeAll()]);
|
|
2934
|
+
}, "advanced-sidebar: panel terminals, preview servers, retained task output");
|
|
2935
|
+
}
|
|
2936
|
+
/**
|
|
2937
|
+
* Describe which operations this Host can serve, so the menu can disable an entry with a reason
|
|
2938
|
+
* instead of offering one that fails when it is pressed.
|
|
2939
|
+
* @param signal - gateway-supplied cancellation for the executable probes.
|
|
2940
|
+
* @returns the capability view.
|
|
2941
|
+
*/
|
|
2942
|
+
async describe(signal) {
|
|
2943
|
+
const settings = this.source();
|
|
2944
|
+
const deletion = this.deleter.describe();
|
|
2945
|
+
return {
|
|
2946
|
+
git: await this.git.describe(signal),
|
|
2947
|
+
terminal: this.terminals.describe(),
|
|
2948
|
+
files: this.files.describe(),
|
|
2949
|
+
preview: this.preview.describe(),
|
|
2950
|
+
tasks: this.tasks.describe(),
|
|
2951
|
+
openIn: await this.launcher.describe(signal),
|
|
2952
|
+
settings,
|
|
2953
|
+
deletion: {
|
|
2954
|
+
canPurge: deletion.canPurge,
|
|
2955
|
+
mode: settings.deleteMode === "purge" && deletion.canPurge ? "purge" : "archive",
|
|
2956
|
+
...deletion.reason === void 0 ? {} : { reason: deletion.reason }
|
|
2957
|
+
},
|
|
2958
|
+
readAt: Date.now()
|
|
2959
|
+
};
|
|
2960
|
+
}
|
|
2961
|
+
/**
|
|
2962
|
+
* Read one workspace's git status.
|
|
2963
|
+
* @param request - the workspace directory.
|
|
2964
|
+
* @param signal - gateway-supplied cancellation for the caller's abandoned request.
|
|
2965
|
+
* @returns the reading, or a classified failure.
|
|
2966
|
+
*/
|
|
2967
|
+
gitStatus(request, signal) {
|
|
2968
|
+
return this.git.status(request, signal);
|
|
2969
|
+
}
|
|
2970
|
+
/**
|
|
2971
|
+
* Read one path's patch.
|
|
2972
|
+
* @param request - the path and which index to compare.
|
|
2973
|
+
* @param signal - gateway-supplied cancellation for the caller's abandoned request.
|
|
2974
|
+
* @returns the patch, or a classified failure.
|
|
2975
|
+
*/
|
|
2976
|
+
gitDiff(request, signal) {
|
|
2977
|
+
return this.git.diff(request, signal);
|
|
2978
|
+
}
|
|
2979
|
+
/**
|
|
2980
|
+
* Stage paths into the index.
|
|
2981
|
+
* @param request - the workspace and the repository-relative paths.
|
|
2982
|
+
* @param signal - gateway-supplied cancellation for the caller's abandoned request.
|
|
2983
|
+
* @returns the reading after the write, or a classified failure.
|
|
2984
|
+
*/
|
|
2985
|
+
gitStage(request, signal) {
|
|
2986
|
+
return this.git.stage(request, signal);
|
|
2987
|
+
}
|
|
2988
|
+
/**
|
|
2989
|
+
* Take paths back out of the index, leaving the working tree alone.
|
|
2990
|
+
* @param request - the workspace and the repository-relative paths.
|
|
2991
|
+
* @param signal - gateway-supplied cancellation for the caller's abandoned request.
|
|
2992
|
+
* @returns the reading after the write, or a classified failure.
|
|
2993
|
+
*/
|
|
2994
|
+
gitUnstage(request, signal) {
|
|
2995
|
+
return this.git.unstage(request, signal);
|
|
2996
|
+
}
|
|
2997
|
+
/**
|
|
2998
|
+
* Record the staged changes.
|
|
2999
|
+
* @param request - the workspace, the message, and whether to amend.
|
|
3000
|
+
* @param signal - gateway-supplied cancellation; hooks run under `gitCommitTimeoutMs`.
|
|
3001
|
+
* @returns the new commit and the reading after it, or a classified failure.
|
|
3002
|
+
*/
|
|
3003
|
+
gitCommit(request, signal) {
|
|
3004
|
+
return this.git.commit(request, signal);
|
|
3005
|
+
}
|
|
3006
|
+
/**
|
|
3007
|
+
* Send the current branch's commits to its remote.
|
|
3008
|
+
* @param request - the workspace, and whether an unpublished branch may be published.
|
|
3009
|
+
* @param signal - gateway-supplied cancellation; the network wait runs under `gitPushTimeoutMs`.
|
|
3010
|
+
* @returns the push and the reading after it, or a classified failure.
|
|
3011
|
+
*/
|
|
3012
|
+
gitPush(request, signal) {
|
|
3013
|
+
return this.git.push(request, signal);
|
|
3014
|
+
}
|
|
3015
|
+
/**
|
|
3016
|
+
* Ask the deployment's own model to write a commit message for what is staged.
|
|
3017
|
+
* @param request - the workspace, and whether the message is for an amend.
|
|
3018
|
+
* @param signal - gateway-supplied cancellation for the readings and the model call.
|
|
3019
|
+
* @returns the drafted message, or a classified failure.
|
|
3020
|
+
*/
|
|
3021
|
+
gitCommitMessage(request, signal) {
|
|
3022
|
+
return this.git.draftCommitMessage(request, signal);
|
|
3023
|
+
}
|
|
3024
|
+
/**
|
|
3025
|
+
* Allocate a panel terminal.
|
|
3026
|
+
* @param request - the workspace directory and the panel's measured geometry.
|
|
3027
|
+
* @param signal - gateway-supplied cancellation of the allocation.
|
|
3028
|
+
* @returns the handle, or a classified failure.
|
|
3029
|
+
*/
|
|
3030
|
+
terminalOpen(request, signal) {
|
|
3031
|
+
return this.terminals.open(request, signal);
|
|
3032
|
+
}
|
|
3033
|
+
/**
|
|
3034
|
+
* Read a panel terminal's output from a caller-owned offset.
|
|
3035
|
+
* @param request - the handle and the offset already rendered.
|
|
3036
|
+
* @returns the delta and the process state, or a classified failure.
|
|
3037
|
+
*/
|
|
3038
|
+
terminalRead(request) {
|
|
3039
|
+
return Promise.resolve(this.terminals.read(request));
|
|
3040
|
+
}
|
|
3041
|
+
/**
|
|
3042
|
+
* Send keystrokes to a panel terminal.
|
|
3043
|
+
* @param request - the handle and the text to deliver verbatim.
|
|
3044
|
+
* @returns settlement, or a classified failure.
|
|
3045
|
+
*/
|
|
3046
|
+
terminalWrite(request) {
|
|
3047
|
+
return this.terminals.write(request);
|
|
3048
|
+
}
|
|
3049
|
+
/**
|
|
3050
|
+
* Deliver a signal to a panel terminal's foreground process group.
|
|
3051
|
+
* @param request - the handle and the signal.
|
|
3052
|
+
* @returns settlement, or a classified failure.
|
|
3053
|
+
*/
|
|
3054
|
+
terminalSignal(request) {
|
|
3055
|
+
return this.terminals.signal(request);
|
|
3056
|
+
}
|
|
3057
|
+
/**
|
|
3058
|
+
* Close a panel terminal.
|
|
3059
|
+
* @param request - the handle.
|
|
3060
|
+
* @returns settlement, or a classified failure.
|
|
3061
|
+
*/
|
|
3062
|
+
terminalClose(request) {
|
|
3063
|
+
return this.terminals.close(request);
|
|
3064
|
+
}
|
|
3065
|
+
/**
|
|
3066
|
+
* List one directory level for the Files panel.
|
|
3067
|
+
* @param request - the directory and the workspace it must stay inside.
|
|
3068
|
+
* @param signal - gateway-supplied cancellation for the caller's abandoned request.
|
|
3069
|
+
* @returns the level, or a classified failure.
|
|
3070
|
+
*/
|
|
3071
|
+
listEntries(request, signal) {
|
|
3072
|
+
return this.files.list(request, signal);
|
|
3073
|
+
}
|
|
3074
|
+
/**
|
|
3075
|
+
* List one workspace's preview launch configurations, each with its current state.
|
|
3076
|
+
* @param request - the workspace to read.
|
|
3077
|
+
* @param signal - gateway-supplied cancellation for the caller's abandoned request.
|
|
3078
|
+
* @returns the list, or a classified failure.
|
|
3079
|
+
*/
|
|
3080
|
+
previewList(request, signal) {
|
|
3081
|
+
return this.preview.list(request, signal);
|
|
3082
|
+
}
|
|
3083
|
+
/**
|
|
3084
|
+
* Start one preview configuration.
|
|
3085
|
+
* @param request - the workspace and the configuration name.
|
|
3086
|
+
* @param signal - gateway-supplied cancellation of the start.
|
|
3087
|
+
* @returns the started row, or a classified failure.
|
|
3088
|
+
*/
|
|
3089
|
+
previewStart(request, signal) {
|
|
3090
|
+
return this.preview.start(request, signal);
|
|
3091
|
+
}
|
|
3092
|
+
/**
|
|
3093
|
+
* Stop one running preview server.
|
|
3094
|
+
* @param request - the handle.
|
|
3095
|
+
* @returns settlement, or a classified failure.
|
|
3096
|
+
*/
|
|
3097
|
+
previewStop(request) {
|
|
3098
|
+
return this.preview.stop(request);
|
|
3099
|
+
}
|
|
3100
|
+
/**
|
|
3101
|
+
* Read one preview server's output from a caller-owned offset, with its state at read time.
|
|
3102
|
+
* @param request - the handle and the offset already rendered.
|
|
3103
|
+
* @returns the delta and the state, or a classified failure.
|
|
3104
|
+
*/
|
|
3105
|
+
previewLogs(request) {
|
|
3106
|
+
return Promise.resolve(this.preview.logs(request));
|
|
3107
|
+
}
|
|
3108
|
+
/**
|
|
3109
|
+
* Read one file for the Files panel preview.
|
|
3110
|
+
* @param request - the file and the workspace it must stay inside.
|
|
3111
|
+
* @param signal - gateway-supplied cancellation for the caller's abandoned request.
|
|
3112
|
+
* @returns the preview, or a classified failure.
|
|
3113
|
+
*/
|
|
3114
|
+
readFile(request, signal) {
|
|
3115
|
+
return this.files.read(request, signal);
|
|
3116
|
+
}
|
|
3117
|
+
/**
|
|
3118
|
+
* Hand one path to an external application or to the operating system's file manager.
|
|
3119
|
+
* @param request - the target and the path.
|
|
3120
|
+
* @param signal - gateway-supplied cancellation for the launch.
|
|
3121
|
+
* @returns settlement, or a classified failure.
|
|
3122
|
+
*/
|
|
3123
|
+
openIn(request, signal) {
|
|
3124
|
+
return this.launcher.open(request, signal);
|
|
3125
|
+
}
|
|
3126
|
+
/**
|
|
3127
|
+
* Stop one live background task.
|
|
3128
|
+
* @param request - the owning session and the task id.
|
|
3129
|
+
* @returns what the registry did, or a classified failure.
|
|
3130
|
+
*/
|
|
3131
|
+
taskKill(request) {
|
|
3132
|
+
return this.tasks.kill(request);
|
|
3133
|
+
}
|
|
3134
|
+
/**
|
|
3135
|
+
* Read one settled background task's output.
|
|
3136
|
+
* @param request - the owning session and the task id.
|
|
3137
|
+
* @returns the accumulated output, or a classified failure.
|
|
3138
|
+
*/
|
|
3139
|
+
taskOutput(request) {
|
|
3140
|
+
return this.tasks.output(request);
|
|
3141
|
+
}
|
|
3142
|
+
/**
|
|
3143
|
+
* Delete one session: archive it, and remove its durable artifact when the mode and Host allow.
|
|
3144
|
+
* @param request - the session to delete.
|
|
3145
|
+
* @param signal - gateway-supplied cancellation for the persistence listing.
|
|
3146
|
+
* @returns what was actually done, or a classified failure.
|
|
3147
|
+
*/
|
|
3148
|
+
deleteSession(request, signal) {
|
|
3149
|
+
return this.deleter.delete(request, signal);
|
|
3150
|
+
}
|
|
3151
|
+
};
|
|
3152
|
+
})();
|
|
3153
|
+
var host_default = AdvancedSidebarService;
|
|
3154
|
+
|
|
3155
|
+
//#endregion
|
|
3156
|
+
export { ADVANCED_SIDEBAR_SETTINGS_NAMESPACE, AdvancedSidebarService, REVEAL_TARGET_ID, host_default as default };
|