@achasoft/dsh-advanced-sidebar 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +279 -128
- package/cordis.patch.yml +31 -3
- package/lib/client.js +2803 -466
- package/lib/client.js.map +1 -1
- package/lib/host.js +2071 -418
- package/lib/index.js +6 -2
- package/lib/preview-content-BVUQ5oOR.js +465 -0
- package/lib/remote.js +330 -25
- package/lib/typert.host.js +330 -25
- package/lib/ui-preview.js +352 -0
- package/package.json +8 -2
- package/types/client/ActionMenu.d.ts +16 -1
- package/types/client/LogDownloadDialog.d.ts +24 -0
- package/types/client/contract.d.ts +57 -1
- package/types/client/index.d.ts +4 -2
- package/types/client/locales.d.ts +100 -0
- package/types/client/log-download.d.ts +179 -0
- package/types/client/panels/PreviewPanel.d.ts +20 -15
- package/types/client/panels/preview-file.d.ts +61 -0
- package/types/client/panels/preview-mode.d.ts +67 -0
- package/types/client/panels/preview-scratchpad.d.ts +53 -0
- package/types/client/panels/preview-url.d.ts +17 -0
- package/types/client/panels/shared.d.ts +15 -2
- package/types/client/preview-driver.d.ts +121 -0
- package/types/client/preview-storage.d.ts +43 -0
- package/types/client/preview-types.d.ts +21 -0
- package/types/client/preview-values.d.ts +43 -0
- package/types/host/deletion.d.ts +32 -23
- package/types/host/git.d.ts +94 -8
- package/types/host/index.d.ts +97 -5
- package/types/host/preview-content.d.ts +179 -0
- package/types/host/preview-serve.d.ts +242 -0
- package/types/host/settings-section.d.ts +49 -0
- package/types/host/types.d.ts +341 -0
- package/types/host/ui-bridge.d.ts +197 -0
- package/types/host/ui-preview-tool.d.ts +60 -0
- package/types/index.d.ts +6 -2
- package/types/ui-preview.d.ts +11 -0
package/lib/host.js
CHANGED
|
@@ -1,22 +1,100 @@
|
|
|
1
|
+
import { a as contentTypeOf, c as fileUrl, f as proxyUrlFor, h as resolveWorkspace, i as classifyFile, l as injectBase, m as resolveInside, n as PROXY_ROUTE, o as decodeText, p as validateProxyTarget, r as SCRATCHPAD_ROUTE, s as encodeQuery, t as FILE_ROUTE, u as isTextual } from "./preview-content-BVUQ5oOR.js";
|
|
1
2
|
import z from "@deepseek-ai/schemastery";
|
|
2
|
-
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
3
3
|
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
4
|
-
import { rm } from "node:fs/promises";
|
|
5
4
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
5
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
7
|
-
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
8
7
|
import { connect } from "node:net";
|
|
8
|
+
import { request } from "node:http";
|
|
9
|
+
import { request as request$1 } from "node:https";
|
|
9
10
|
import { JobId } from "@deepseek-ai/dsh-jobs";
|
|
10
11
|
|
|
12
|
+
//#region tsbuild/host/settings-section.js
|
|
13
|
+
/**
|
|
14
|
+
* Attaching this plugin's settings section to the harness settings provider.
|
|
15
|
+
*
|
|
16
|
+
* Harness 0.1.1-rc.2 and earlier exported two free helpers from `@deepseek-ai/dsh-settings` for
|
|
17
|
+
* this, `installSettingsSection` and `settingsNamespace`. Current harnesses removed both: the same
|
|
18
|
+
* wiring is the provider's own `settings.installSection(owner, ns, schema, entry, hooks)`, reached
|
|
19
|
+
* through `ctx.inject(['settings'], …)`. Importing the old helpers by name fails to link on a
|
|
20
|
+
* current install, so the plugin carries these two small equivalents instead of depending on a
|
|
21
|
+
* compatibility shim that only exists on a development machine.
|
|
22
|
+
*
|
|
23
|
+
* Injecting `settings` rather than requiring it keeps the plugin loadable in a deployment that
|
|
24
|
+
* mounts no settings provider: the section simply never attaches, and the composition entry the
|
|
25
|
+
* plugin was configured with stays the source.
|
|
26
|
+
* @module @achasoft/dsh-advanced-sidebar/host/settings-section
|
|
27
|
+
*/
|
|
28
|
+
/** The provider's namespace grammar: a lowercase, hyphenated identifier. */
|
|
29
|
+
const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/u;
|
|
30
|
+
/**
|
|
31
|
+
* Check a settings namespace against the provider's grammar, failing at load rather than at attach.
|
|
32
|
+
* @param value - the namespace.
|
|
33
|
+
* @returns the same namespace.
|
|
34
|
+
* @throws TypeError when it is not a lowercase hyphenated identifier.
|
|
35
|
+
*/
|
|
36
|
+
function settingsNamespace(value) {
|
|
37
|
+
if (!NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`);
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Attach one settings section whenever a settings provider is present.
|
|
42
|
+
* @param ctx - the owning plugin context; its unload detaches the section.
|
|
43
|
+
* @param ns - the plugin's settings namespace.
|
|
44
|
+
* @param schema - schema resolving the section.
|
|
45
|
+
* @param entry - the composition entry, used as the base layer and as the fallback without a provider.
|
|
46
|
+
* @param hooks - source sink, change notification, and optional validation.
|
|
47
|
+
*/
|
|
48
|
+
function installSettingsSection(ctx, ns, schema, entry, hooks) {
|
|
49
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
50
|
+
settingsCtx.settings.installSection(ctx, ns, schema, entry, hooks);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
//#endregion
|
|
11
55
|
//#region tsbuild/host/deletion.js
|
|
12
56
|
/**
|
|
57
|
+
* Session deletion.
|
|
58
|
+
*
|
|
59
|
+
* No harness capability deletes a session. Delete is therefore the one thing that does exist — the
|
|
60
|
+
* workspace registry's ARCHIVE, which hides a session while keeping its log and its accounting slot —
|
|
61
|
+
* and it is honest that this is all it did.
|
|
62
|
+
*
|
|
63
|
+
* `deleteMode: 'purge'` asked for more: the backend's own per-session artifact removed as well. On
|
|
64
|
+
* the harness this release targets (`0.1.5-rc.2`) that cannot be done correctly, and so it is not
|
|
65
|
+
* attempted:
|
|
66
|
+
*
|
|
67
|
+
* - The published `SessionPersistence` contract is `create`, `open`, `flush`, `stat` and `list`. It
|
|
68
|
+
* has no removal verb and no way to ask where a session's bytes live; the `supportsRawArtifacts`
|
|
69
|
+
* flag and `locate()` this module used to read were never part of it, so purging was silently
|
|
70
|
+
* impossible while the settings still offered it.
|
|
71
|
+
* - The JSONL backend keeps a session as a DIRECTORY — one immutable file per format generation plus
|
|
72
|
+
* a `session.lock` write lease — behind an in-process cold-log memo, and the workspace registry and
|
|
73
|
+
* the session projection cache index its header. Unlinking files underneath all of that bypasses the
|
|
74
|
+
* lease another process may hold, leaves stale caches answering for a log that is gone, and is the
|
|
75
|
+
* same class of out-of-band edit that produces "torn record" corruption reports.
|
|
76
|
+
*
|
|
77
|
+
* So the capability is reported as unavailable with that reason, the settings card shows the reason
|
|
78
|
+
* and refuses to select `purge`, and a composition that still configures it gets an archive whose
|
|
79
|
+
* result says, every time, why nothing was removed. When the harness grows a supported removal verb,
|
|
80
|
+
* this is the one module to change.
|
|
81
|
+
* @module @achasoft/dsh-advanced-sidebar/host/deletion
|
|
82
|
+
*/
|
|
83
|
+
/**
|
|
84
|
+
* Why `purge` is unavailable, in the words the settings card and the Delete result show.
|
|
85
|
+
*
|
|
86
|
+
* One sentence for both, so the reason a person reads before choosing a mode is the reason they read
|
|
87
|
+
* after pressing Delete.
|
|
88
|
+
*/
|
|
89
|
+
const PURGE_UNAVAILABLE_REASON = "this harness's session persistence has no supported way to remove a session log, so Delete archives the session and keeps its log";
|
|
90
|
+
/**
|
|
13
91
|
* Commits Delete for the sidebar menu. Stateless apart from the context and settings it reads.
|
|
14
92
|
*/
|
|
15
93
|
var SessionDeleter = class {
|
|
16
94
|
ctx;
|
|
17
95
|
source;
|
|
18
96
|
/**
|
|
19
|
-
* @param ctx - Host context carrying the workspace registry
|
|
97
|
+
* @param ctx - Host context carrying the workspace registry.
|
|
20
98
|
* @param source - reads the current settings section; called per request.
|
|
21
99
|
*/
|
|
22
100
|
constructor(ctx, source) {
|
|
@@ -25,27 +103,20 @@ var SessionDeleter = class {
|
|
|
25
103
|
}
|
|
26
104
|
/**
|
|
27
105
|
* Report whether the durable log can be removed at all.
|
|
28
|
-
* @returns the capability, with
|
|
106
|
+
* @returns the capability, with the reason purging is impossible.
|
|
29
107
|
*/
|
|
30
108
|
describe() {
|
|
31
|
-
|
|
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 {
|
|
109
|
+
return {
|
|
37
110
|
canPurge: false,
|
|
38
|
-
reason:
|
|
111
|
+
reason: PURGE_UNAVAILABLE_REASON
|
|
39
112
|
};
|
|
40
|
-
return { canPurge: true };
|
|
41
113
|
}
|
|
42
114
|
/**
|
|
43
|
-
* Hide one session, and
|
|
115
|
+
* Hide one session, and say plainly when a requested purge did not happen.
|
|
44
116
|
* @param request - the session to delete.
|
|
45
|
-
* @param signal - cancellation for the persistence listing.
|
|
46
117
|
* @returns what was actually done, or a classified failure.
|
|
47
118
|
*/
|
|
48
|
-
async delete(request
|
|
119
|
+
async delete(request$2) {
|
|
49
120
|
const settings = this.source();
|
|
50
121
|
if (!settings.showDelete) return {
|
|
51
122
|
ok: false,
|
|
@@ -58,10 +129,8 @@ var SessionDeleter = class {
|
|
|
58
129
|
code: "no-registry",
|
|
59
130
|
message: "no workspace registry is mounted"
|
|
60
131
|
};
|
|
61
|
-
const sessionId = request.sessionId;
|
|
62
|
-
const artifact = settings.deleteMode === "purge" ? await this.locateArtifact(sessionId, signal) : void 0;
|
|
63
132
|
try {
|
|
64
|
-
await registry.archiveSession(sessionId);
|
|
133
|
+
await registry.archiveSession(request$2.sessionId);
|
|
65
134
|
} catch (error) {
|
|
66
135
|
return {
|
|
67
136
|
ok: false,
|
|
@@ -69,168 +138,23 @@ var SessionDeleter = class {
|
|
|
69
138
|
message: error instanceof Error ? error.message : String(error)
|
|
70
139
|
};
|
|
71
140
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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)}`
|
|
141
|
+
switch (settings.deleteMode) {
|
|
142
|
+
case "archive": return {
|
|
143
|
+
ok: true,
|
|
144
|
+
archived: true,
|
|
145
|
+
purged: false
|
|
90
146
|
};
|
|
147
|
+
case "purge": return {
|
|
148
|
+
ok: true,
|
|
149
|
+
archived: true,
|
|
150
|
+
purged: false,
|
|
151
|
+
purgeSkippedReason: PURGE_UNAVAILABLE_REASON
|
|
152
|
+
};
|
|
153
|
+
default: throw new TypeError(`advanced-sidebar: unexpected deleteMode ${JSON.stringify(settings.deleteMode)}`);
|
|
91
154
|
}
|
|
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
155
|
}
|
|
122
156
|
};
|
|
123
157
|
|
|
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
158
|
//#endregion
|
|
235
159
|
//#region tsbuild/host/files.js
|
|
236
160
|
/**
|
|
@@ -271,20 +195,20 @@ var FileReader = class {
|
|
|
271
195
|
* @param signal - cancellation for the listing.
|
|
272
196
|
* @returns the level, or a classified failure.
|
|
273
197
|
*/
|
|
274
|
-
async list(request, signal) {
|
|
198
|
+
async list(request$2, signal) {
|
|
275
199
|
const fs = this.ctx.get("fs");
|
|
276
200
|
if (fs === void 0) return {
|
|
277
201
|
ok: false,
|
|
278
202
|
code: "no-filesystem",
|
|
279
203
|
message: "no filesystem capability is mounted"
|
|
280
204
|
};
|
|
281
|
-
const workspace = await resolveWorkspace(this.ctx, request.workspacePath, signal);
|
|
205
|
+
const workspace = await resolveWorkspace(this.ctx, request$2.workspacePath, signal);
|
|
282
206
|
if (!workspace.ok) return {
|
|
283
207
|
ok: false,
|
|
284
208
|
code: workspace.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied",
|
|
285
209
|
message: workspace.rejection.message
|
|
286
210
|
};
|
|
287
|
-
const directory = await resolveInside(this.ctx, workspace.value, request.path, signal);
|
|
211
|
+
const directory = await resolveInside(this.ctx, workspace.value, request$2.path, signal);
|
|
288
212
|
if (!directory.ok) return {
|
|
289
213
|
ok: false,
|
|
290
214
|
code: directory.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied",
|
|
@@ -294,7 +218,7 @@ var FileReader = class {
|
|
|
294
218
|
if (info === void 0 || info.type !== "directory") return {
|
|
295
219
|
ok: false,
|
|
296
220
|
code: "not-a-file",
|
|
297
|
-
message: `${request.path} is not a directory`
|
|
221
|
+
message: `${request$2.path} is not a directory`
|
|
298
222
|
};
|
|
299
223
|
let children;
|
|
300
224
|
try {
|
|
@@ -333,20 +257,20 @@ var FileReader = class {
|
|
|
333
257
|
* @param signal - cancellation for the read.
|
|
334
258
|
* @returns the preview, or a classified failure.
|
|
335
259
|
*/
|
|
336
|
-
async read(request, signal) {
|
|
260
|
+
async read(request$2, signal) {
|
|
337
261
|
const fs = this.ctx.get("fs");
|
|
338
262
|
if (fs === void 0) return {
|
|
339
263
|
ok: false,
|
|
340
264
|
code: "no-filesystem",
|
|
341
265
|
message: "no filesystem capability is mounted"
|
|
342
266
|
};
|
|
343
|
-
const workspace = await resolveWorkspace(this.ctx, request.workspacePath, signal);
|
|
267
|
+
const workspace = await resolveWorkspace(this.ctx, request$2.workspacePath, signal);
|
|
344
268
|
if (!workspace.ok) return {
|
|
345
269
|
ok: false,
|
|
346
270
|
code: workspace.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied",
|
|
347
271
|
message: workspace.rejection.message
|
|
348
272
|
};
|
|
349
|
-
const file = await resolveInside(this.ctx, workspace.value, request.path, signal);
|
|
273
|
+
const file = await resolveInside(this.ctx, workspace.value, request$2.path, signal);
|
|
350
274
|
if (!file.ok) return {
|
|
351
275
|
ok: false,
|
|
352
276
|
code: file.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied",
|
|
@@ -356,7 +280,7 @@ var FileReader = class {
|
|
|
356
280
|
if (info === void 0 || info.type !== "file") return {
|
|
357
281
|
ok: false,
|
|
358
282
|
code: "not-a-file",
|
|
359
|
-
message: `${request.path} is not a regular file`
|
|
283
|
+
message: `${request$2.path} is not a regular file`
|
|
360
284
|
};
|
|
361
285
|
const max = this.source().filesMaxPreviewBytes;
|
|
362
286
|
let bytes;
|
|
@@ -372,7 +296,7 @@ var FileReader = class {
|
|
|
372
296
|
const size = info.size ?? bytes.byteLength;
|
|
373
297
|
if (isBinary(bytes)) return {
|
|
374
298
|
ok: true,
|
|
375
|
-
path: request.path,
|
|
299
|
+
path: request$2.path,
|
|
376
300
|
text: "",
|
|
377
301
|
binary: true,
|
|
378
302
|
truncated: false,
|
|
@@ -380,7 +304,7 @@ var FileReader = class {
|
|
|
380
304
|
};
|
|
381
305
|
return {
|
|
382
306
|
ok: true,
|
|
383
|
-
path: request.path,
|
|
307
|
+
path: request$2.path,
|
|
384
308
|
text: new TextDecoder("utf-8", { fatal: false }).decode(bytes),
|
|
385
309
|
binary: false,
|
|
386
310
|
truncated: size > bytes.byteLength,
|
|
@@ -688,6 +612,47 @@ const MISSING_BINARY_EXIT = 127;
|
|
|
688
612
|
/** The remote a branch with no upstream is published to when nothing else names one. */
|
|
689
613
|
const DEFAULT_REMOTE = "origin";
|
|
690
614
|
/**
|
|
615
|
+
* Configuration every invocation carries, ahead of the subcommand.
|
|
616
|
+
*
|
|
617
|
+
* `core.fsmonitor` names an executable git runs whenever it refreshes the index — which `status`,
|
|
618
|
+
* `diff`, `add` and `commit` all do — and it is read from the repository's own `.git/config`. A
|
|
619
|
+
* workspace whose config was written by someone else (an unpacked archive, a shared directory) would
|
|
620
|
+
* otherwise run their program the moment the Changes panel opened. The monitor is only a speed-up, so
|
|
621
|
+
* turning it off costs a slower status on a very large repository and nothing else. `-c` on the command
|
|
622
|
+
* line outranks every config file, and git passes it on to the child gits it starts (submodules).
|
|
623
|
+
*/
|
|
624
|
+
const INVOCATION_CONFIG = ["-c", "core.fsmonitor=false"];
|
|
625
|
+
/**
|
|
626
|
+
* Flags every `git diff` carries, so a patch is git's own rendering and never a configured program's.
|
|
627
|
+
*
|
|
628
|
+
* `--no-ext-diff` refuses `diff.external` and per-attribute `diff.<driver>.command`; `--no-textconv`
|
|
629
|
+
* refuses `diff.<driver>.textconv`. Both name executables from repository config and attributes, and
|
|
630
|
+
* both would otherwise run on a reading nobody asked to be a command.
|
|
631
|
+
*/
|
|
632
|
+
const DIFF_SAFETY_ARGS = ["--no-ext-diff", "--no-textconv"];
|
|
633
|
+
/**
|
|
634
|
+
* Config keys that name a content filter's executable.
|
|
635
|
+
*
|
|
636
|
+
* `filter.<driver>.clean` (and the long-running `process` protocol) run when git hashes a working-tree
|
|
637
|
+
* file — which `git status` does for every file whose stat data changed. A driver is attached by
|
|
638
|
+
* `.gitattributes`, which a repository ships, and defined in config, which is where the executable
|
|
639
|
+
* comes from; `smudge` runs only on checkout, which no reading here performs.
|
|
640
|
+
*/
|
|
641
|
+
const FILTER_EXECUTABLE_KEYS = String.raw`^filter\..*\.(clean|process)$`;
|
|
642
|
+
/** The key shape one filter listing line carries, split into its driver name and its variable. */
|
|
643
|
+
const FILTER_KEY = /^filter\.(.+)\.(clean|process)$/u;
|
|
644
|
+
/**
|
|
645
|
+
* Config scopes whose filter programs a reading may run.
|
|
646
|
+
*
|
|
647
|
+
* `system` and `global` are files the operator (or their administrator) wrote, and they are where
|
|
648
|
+
* `git lfs install` puts its filter, so neutralizing them would turn every LFS file into a phantom
|
|
649
|
+
* change. `local` and `worktree` live inside the repository's own `.git`, which is exactly the
|
|
650
|
+
* config a workspace can arrive with.
|
|
651
|
+
*/
|
|
652
|
+
const TRUSTED_CONFIG_SCOPES = new Set(["system", "global"]);
|
|
653
|
+
/** `git config --get-regexp` exits 1 to mean "no key matched", which is an ordinary answer. */
|
|
654
|
+
const CONFIG_NO_MATCH_EXIT = 1;
|
|
655
|
+
/**
|
|
691
656
|
* What the model is told a commit message is, when the settings section supplies no prompt of its
|
|
692
657
|
* own.
|
|
693
658
|
*
|
|
@@ -740,6 +705,36 @@ function stripFence(text) {
|
|
|
740
705
|
return (/^```[^\n]*\n([\s\S]*)\n```$/u.exec(trimmed)?.[1] ?? trimmed).trim();
|
|
741
706
|
}
|
|
742
707
|
/**
|
|
708
|
+
* The `-c` overrides that disarm the untrusted filter programs one config listing names.
|
|
709
|
+
*
|
|
710
|
+
* An empty value is git's own "no command" for a filter (`convert.c` runs a driver only when its
|
|
711
|
+
* command is non-empty), so the file is then hashed as its bytes, exactly as with no driver at all.
|
|
712
|
+
* @param listing - `git config --show-scope --name-only --get-regexp` output, one `scope<TAB>key` per
|
|
713
|
+
* line; a line with no scope (an older git) is treated as untrusted.
|
|
714
|
+
* @returns the overrides, or the key git could not be told about safely.
|
|
715
|
+
*/
|
|
716
|
+
function filterOverrides(listing) {
|
|
717
|
+
const config = [];
|
|
718
|
+
for (const line of listing.split("\n")) {
|
|
719
|
+
if (line.trim() === "") continue;
|
|
720
|
+
const tab = line.indexOf(" ");
|
|
721
|
+
const scope = tab < 0 ? void 0 : line.slice(0, tab);
|
|
722
|
+
const key = tab < 0 ? line : line.slice(tab + 1);
|
|
723
|
+
if (scope !== void 0 && TRUSTED_CONFIG_SCOPES.has(scope)) continue;
|
|
724
|
+
if (key.includes("=") || FILTER_KEY.exec(key) === null) return { unsafeKey: key };
|
|
725
|
+
config.push("-c", `${key}=`);
|
|
726
|
+
}
|
|
727
|
+
return { config };
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Whether a name could be read as an option by a git command it is passed to.
|
|
731
|
+
* @param name - a branch or remote name taken from repository state.
|
|
732
|
+
* @returns true when it begins with `-`.
|
|
733
|
+
*/
|
|
734
|
+
function isOptionShaped(name) {
|
|
735
|
+
return name.startsWith("-");
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
743
738
|
* Reads one workspace's git state. One instance serves every request; the resolved `git` path is
|
|
744
739
|
* cached across calls and dropped whenever a lookup fails, so installing git later needs no restart.
|
|
745
740
|
*/
|
|
@@ -788,17 +783,17 @@ var GitReader = class {
|
|
|
788
783
|
* @param signal - cancellation for the reading.
|
|
789
784
|
* @returns the reading, or a classified failure.
|
|
790
785
|
*/
|
|
791
|
-
async status(request, signal) {
|
|
792
|
-
const prepared = await this.prepare(request.workspacePath, signal);
|
|
786
|
+
async status(request$2, signal) {
|
|
787
|
+
const prepared = await this.prepare(request$2.workspacePath, signal);
|
|
793
788
|
if ("failure" in prepared) return prepared.failure;
|
|
794
789
|
const { repository, workspace } = prepared;
|
|
795
|
-
const outcome = await this.
|
|
790
|
+
const outcome = await this.read(repository, [
|
|
796
791
|
"status",
|
|
797
792
|
"--porcelain=v2",
|
|
798
793
|
"--branch",
|
|
799
794
|
"--untracked-files=all",
|
|
800
795
|
...NUL_ARGS
|
|
801
|
-
], signal);
|
|
796
|
+
], signal, void 0, workspace.value.processPath);
|
|
802
797
|
if (outcome.exitCode !== 0) return classify(outcome);
|
|
803
798
|
const parsed = parsePorcelainV2(outcome.stdout);
|
|
804
799
|
const limit = this.source().gitMaxFiles;
|
|
@@ -807,7 +802,7 @@ var GitReader = class {
|
|
|
807
802
|
const conflicted = changes.filter((change) => change.conflicted);
|
|
808
803
|
return {
|
|
809
804
|
ok: true,
|
|
810
|
-
write: await this.writeCapability(repository
|
|
805
|
+
write: await this.writeCapability(repository, signal),
|
|
811
806
|
repositoryRoot: repository.root,
|
|
812
807
|
prefix: repository.prefix,
|
|
813
808
|
...parsed.branch.branch === void 0 ? {} : { branch: parsed.branch.branch },
|
|
@@ -829,39 +824,39 @@ var GitReader = class {
|
|
|
829
824
|
* @param signal - cancellation for the reading.
|
|
830
825
|
* @returns the patch, or a classified failure.
|
|
831
826
|
*/
|
|
832
|
-
async diff(request, signal) {
|
|
833
|
-
const prepared = await this.prepare(request.workspacePath, signal);
|
|
827
|
+
async diff(request$2, signal) {
|
|
828
|
+
const prepared = await this.prepare(request$2.workspacePath, signal);
|
|
834
829
|
if ("failure" in prepared) return prepared.failure;
|
|
835
830
|
const { repository } = prepared;
|
|
836
|
-
const contained = await this.contain(repository.root, [request.path], signal);
|
|
831
|
+
const contained = await this.contain(repository.root, [request$2.path], signal);
|
|
837
832
|
if (contained !== void 0) return contained;
|
|
838
833
|
const common = [
|
|
839
834
|
"--no-pager",
|
|
840
835
|
"diff",
|
|
841
836
|
"--no-color",
|
|
842
|
-
|
|
837
|
+
...DIFF_SAFETY_ARGS
|
|
843
838
|
];
|
|
844
|
-
const argv = request.untracked ? [
|
|
839
|
+
const argv = request$2.untracked ? [
|
|
845
840
|
...common,
|
|
846
841
|
"--no-index",
|
|
847
842
|
"--",
|
|
848
843
|
devNull(),
|
|
849
|
-
request.path
|
|
844
|
+
request$2.path
|
|
850
845
|
] : [
|
|
851
846
|
...common,
|
|
852
|
-
...request.staged ? ["--cached"] : [],
|
|
847
|
+
...request$2.staged ? ["--cached"] : [],
|
|
853
848
|
"--",
|
|
854
|
-
request.path
|
|
849
|
+
request$2.path
|
|
855
850
|
];
|
|
856
851
|
const max = this.source().gitDiffMaxBytes;
|
|
857
|
-
const outcome = await this.
|
|
858
|
-
const differed = request.untracked && outcome.exitCode === 1 && outcome.stderr.trim() === "";
|
|
852
|
+
const outcome = await this.read(repository, argv, signal, max);
|
|
853
|
+
const differed = request$2.untracked && outcome.exitCode === 1 && outcome.stderr.trim() === "";
|
|
859
854
|
if (outcome.exitCode !== 0 && !differed) return classify(outcome);
|
|
860
855
|
const patch = outcome.stdout;
|
|
861
856
|
const truncated = outcome.stdoutLossy || patch.length > max;
|
|
862
857
|
return {
|
|
863
858
|
ok: true,
|
|
864
|
-
path: request.path,
|
|
859
|
+
path: request$2.path,
|
|
865
860
|
patch: patch.length > max ? patch.slice(0, max) : patch,
|
|
866
861
|
binary: /^Binary files .* differ$/mu.test(patch),
|
|
867
862
|
truncated
|
|
@@ -873,9 +868,9 @@ var GitReader = class {
|
|
|
873
868
|
* @param signal - cancellation for the write and the reading that follows it.
|
|
874
869
|
* @returns the reading after the write, or a classified failure.
|
|
875
870
|
*/
|
|
876
|
-
stage(request, signal) {
|
|
871
|
+
stage(request$2, signal) {
|
|
877
872
|
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) => [
|
|
873
|
+
return this.write(request$2, signal, (paths) => [
|
|
879
874
|
"add",
|
|
880
875
|
"--",
|
|
881
876
|
...paths
|
|
@@ -887,9 +882,9 @@ var GitReader = class {
|
|
|
887
882
|
* @param signal - cancellation for the write and the reading that follows it.
|
|
888
883
|
* @returns the reading after the write, or a classified failure.
|
|
889
884
|
*/
|
|
890
|
-
unstage(request, signal) {
|
|
885
|
+
unstage(request$2, signal) {
|
|
891
886
|
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) => [
|
|
887
|
+
return this.write(request$2, signal, (paths) => [
|
|
893
888
|
"restore",
|
|
894
889
|
"--staged",
|
|
895
890
|
"--",
|
|
@@ -906,29 +901,29 @@ var GitReader = class {
|
|
|
906
901
|
* @param signal - cancellation for the commit and the reading that follows it.
|
|
907
902
|
* @returns the new commit and the reading after it, or a classified failure.
|
|
908
903
|
*/
|
|
909
|
-
async commit(request, signal) {
|
|
904
|
+
async commit(request$2, signal) {
|
|
910
905
|
const settings = this.source();
|
|
911
906
|
if (!settings.allowGitCommit) return fail$4("disabled", "committing is switched off in the advanced-sidebar settings");
|
|
912
|
-
const message = request.message.trim();
|
|
907
|
+
const message = request$2.message.trim();
|
|
913
908
|
if (message === "") return fail$4("empty-message", "a commit needs a message");
|
|
914
|
-
const prepared = await this.prepare(request.workspacePath, signal);
|
|
909
|
+
const prepared = await this.prepare(request$2.workspacePath, signal);
|
|
915
910
|
if ("failure" in prepared) return prepared.failure;
|
|
916
911
|
const { repository } = prepared;
|
|
917
|
-
if (await this.author(repository
|
|
918
|
-
if (!request.amend && !await this.hasStaged(repository
|
|
912
|
+
if (await this.author(repository, 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`");
|
|
913
|
+
if (!request$2.amend && !await this.hasStaged(repository, signal)) return fail$4("nothing-staged", "nothing is staged, so there is nothing to commit");
|
|
919
914
|
const outcome = await this.git(repository.root, [
|
|
920
915
|
"commit",
|
|
921
|
-
...request.amend ? ["--amend"] : [],
|
|
916
|
+
...request$2.amend ? ["--amend"] : [],
|
|
922
917
|
"-m",
|
|
923
918
|
message
|
|
924
919
|
], signal, void 0, settings.gitCommitTimeoutMs);
|
|
925
920
|
if (outcome.exitCode !== 0) return classify(outcome);
|
|
926
|
-
const [commit, subject] = (await this.
|
|
921
|
+
const [commit, subject] = (await this.read(repository, [
|
|
927
922
|
"log",
|
|
928
923
|
"-1",
|
|
929
924
|
"--format=%h%n%s"
|
|
930
925
|
], signal)).stdout.split("\n");
|
|
931
|
-
const status = await this.status({ workspacePath: request.workspacePath }, signal);
|
|
926
|
+
const status = await this.status({ workspacePath: request$2.workspacePath }, signal);
|
|
932
927
|
if (!status.ok) return status;
|
|
933
928
|
return {
|
|
934
929
|
ok: true,
|
|
@@ -949,28 +944,25 @@ var GitReader = class {
|
|
|
949
944
|
* @param signal - cancellation; the network wait runs under `gitPushTimeoutMs`.
|
|
950
945
|
* @returns the push, the reading after it, or a classified failure.
|
|
951
946
|
*/
|
|
952
|
-
async push(request, signal) {
|
|
947
|
+
async push(request$2, signal) {
|
|
953
948
|
const settings = this.source();
|
|
954
949
|
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);
|
|
950
|
+
const prepared = await this.prepare(request$2.workspacePath, signal);
|
|
956
951
|
if ("failure" in prepared) return prepared.failure;
|
|
957
952
|
const { repository } = prepared;
|
|
958
|
-
const before = await this.status({ workspacePath: request.workspacePath }, signal);
|
|
953
|
+
const before = await this.status({ workspacePath: request$2.workspacePath }, signal);
|
|
959
954
|
if (!before.ok) return before;
|
|
960
955
|
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
956
|
const branch = before.branch;
|
|
962
957
|
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
|
|
958
|
+
if (upstream === void 0 && !request$2.setUpstream) return fail$4("no-upstream", `${branch} has no upstream; publish it to a remote first, or use Publish to record one`);
|
|
959
|
+
const remote = upstream === void 0 ? await this.defaultRemote(repository, signal) : upstream.split("/")[0] ?? DEFAULT_REMOTE;
|
|
965
960
|
if (remote === void 0) return fail$4("no-upstream", "this repository has no remote to push to");
|
|
966
|
-
const
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
remote,
|
|
970
|
-
branch
|
|
971
|
-
] : ["push"], signal, void 0, settings.gitPushTimeoutMs);
|
|
961
|
+
const argv = upstream === void 0 ? await this.publishArgv(repository, remote, branch, signal) : { argv: ["push"] };
|
|
962
|
+
if ("failure" in argv) return argv.failure;
|
|
963
|
+
const outcome = await this.git(repository.root, argv.argv, signal, void 0, settings.gitPushTimeoutMs);
|
|
972
964
|
if (outcome.exitCode !== 0) return classify(outcome);
|
|
973
|
-
const status = await this.status({ workspacePath: request.workspacePath }, signal);
|
|
965
|
+
const status = await this.status({ workspacePath: request$2.workspacePath }, signal);
|
|
974
966
|
if (!status.ok) return status;
|
|
975
967
|
return {
|
|
976
968
|
ok: true,
|
|
@@ -982,6 +974,67 @@ var GitReader = class {
|
|
|
982
974
|
};
|
|
983
975
|
}
|
|
984
976
|
/**
|
|
977
|
+
* The arguments that publish one branch to one remote and record it as the upstream.
|
|
978
|
+
*
|
|
979
|
+
* Both names come from repository state, not from the browser — and repository state is not
|
|
980
|
+
* trusted either. A HEAD of `refs/heads/--receive-pack=/tmp/x` is a valid ref (`check-ref-format`
|
|
981
|
+
* accepts it) that `git status` reports as the branch `--receive-pack=/tmp/x`; handed to
|
|
982
|
+
* `git push` as a bare argument, git parsed it as the option and ran `/tmp/x` as the remote's
|
|
983
|
+
* receive-pack. So three independent things stand in the way:
|
|
984
|
+
*
|
|
985
|
+
* 1. Both names are refused outright when they begin with `-`.
|
|
986
|
+
* 2. The branch must pass `git check-ref-format --branch`, which is git's own branch-name grammar
|
|
987
|
+
* (it rejects a leading `-`, `..`, control characters, `@{`) and must echo back unchanged, so a
|
|
988
|
+
* `@{-1}` shorthand cannot be expanded into some other branch. The remote must make a valid
|
|
989
|
+
* remote-tracking ref, which is how git itself validates a remote name.
|
|
990
|
+
* 3. The push names the refs after `--`, where `git push` (parse-options) stops reading options,
|
|
991
|
+
* and as a fully qualified `refs/heads/<b>:refs/heads/<b>` refspec, which also cannot be read as
|
|
992
|
+
* a shorter ref with the same name on the remote. `--set-upstream` records the same tracking
|
|
993
|
+
* branch it records for the short spelling.
|
|
994
|
+
* @param repository - the resolved repository.
|
|
995
|
+
* @param remote - the remote to publish to, from `git remote`.
|
|
996
|
+
* @param branch - the current branch, from `git status`.
|
|
997
|
+
* @param signal - cancellation for the validation invocations.
|
|
998
|
+
* @returns the push arguments, or the failure to return.
|
|
999
|
+
*/
|
|
1000
|
+
async publishArgv(repository, remote, branch, signal) {
|
|
1001
|
+
if (isOptionShaped(branch) || !await this.isValidBranchName(repository, branch, signal)) return { failure: fail$4("git-failed", `refusing to publish: ${JSON.stringify(branch)} is not a valid branch name`) };
|
|
1002
|
+
if (isOptionShaped(remote) || !await this.isValidRemoteName(repository, remote, signal)) return { failure: fail$4("git-failed", `refusing to publish: ${JSON.stringify(remote)} is not a valid remote name`) };
|
|
1003
|
+
const ref = `refs/heads/${branch}`;
|
|
1004
|
+
return { argv: [
|
|
1005
|
+
"push",
|
|
1006
|
+
"--set-upstream",
|
|
1007
|
+
"--",
|
|
1008
|
+
remote,
|
|
1009
|
+
`${ref}:${ref}`
|
|
1010
|
+
] };
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Whether git accepts a name as a branch name, spelled exactly as given.
|
|
1014
|
+
* @param repository - the resolved repository.
|
|
1015
|
+
* @param branch - the name, already known not to begin with `-`.
|
|
1016
|
+
* @param signal - cancellation for the invocation.
|
|
1017
|
+
* @returns true when `check-ref-format --branch` accepts it and echoes it back unchanged.
|
|
1018
|
+
*/
|
|
1019
|
+
async isValidBranchName(repository, branch, signal) {
|
|
1020
|
+
const outcome = await this.read(repository, [
|
|
1021
|
+
"check-ref-format",
|
|
1022
|
+
"--branch",
|
|
1023
|
+
branch
|
|
1024
|
+
], signal);
|
|
1025
|
+
return outcome.exitCode === 0 && outcome.stdout.replace(/\n$/u, "") === branch;
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Whether git accepts a name as a remote name.
|
|
1029
|
+
* @param repository - the resolved repository.
|
|
1030
|
+
* @param remote - the name, already known not to begin with `-`.
|
|
1031
|
+
* @param signal - cancellation for the invocation.
|
|
1032
|
+
* @returns true when `refs/remotes/<remote>/HEAD` is a well-formed ref, git's own remote-name rule.
|
|
1033
|
+
*/
|
|
1034
|
+
async isValidRemoteName(repository, remote, signal) {
|
|
1035
|
+
return (await this.read(repository, ["check-ref-format", `refs/remotes/${remote}/HEAD`], signal)).exitCode === 0;
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
985
1038
|
* Ask the deployment's own model to write a commit message for what is staged.
|
|
986
1039
|
*
|
|
987
1040
|
* The model sees the staged patch and nothing else — not the working tree, not the repository's
|
|
@@ -991,16 +1044,16 @@ var GitReader = class {
|
|
|
991
1044
|
* @param signal - cancellation for the readings and the model call.
|
|
992
1045
|
* @returns the drafted message, or a classified failure.
|
|
993
1046
|
*/
|
|
994
|
-
async draftCommitMessage(request, signal) {
|
|
1047
|
+
async draftCommitMessage(request$2, signal) {
|
|
995
1048
|
const settings = this.source();
|
|
996
1049
|
if (!settings.allowGitCommit || !settings.allowCommitMessageDraft) return fail$4("disabled", "the drafted commit message is switched off in the advanced-sidebar settings");
|
|
997
1050
|
const llm = this.ctx.get("llm");
|
|
998
1051
|
const models = this.ctx.get("agentDefaultModel");
|
|
999
1052
|
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);
|
|
1053
|
+
const prepared = await this.prepare(request$2.workspacePath, signal);
|
|
1001
1054
|
if ("failure" in prepared) return prepared.failure;
|
|
1002
1055
|
const { repository } = prepared;
|
|
1003
|
-
const patch = await this.stagedPatch(repository
|
|
1056
|
+
const patch = await this.stagedPatch(repository, request$2.amend, signal);
|
|
1004
1057
|
if ("failure" in patch) return patch.failure;
|
|
1005
1058
|
if (patch.text.trim() === "") return fail$4("nothing-staged", "nothing is staged, so there is nothing to describe");
|
|
1006
1059
|
const selection = models.currentSelection();
|
|
@@ -1038,24 +1091,26 @@ var GitReader = class {
|
|
|
1038
1091
|
}
|
|
1039
1092
|
/**
|
|
1040
1093
|
* The patch a drafted message describes, bounded so a large change cannot become a large request.
|
|
1041
|
-
* @param
|
|
1094
|
+
* @param repository - the resolved repository.
|
|
1042
1095
|
* @param amend - describe the previous commit's content as well as the index.
|
|
1043
1096
|
* @param signal - cancellation for the invocations.
|
|
1044
1097
|
* @returns the patch and whether it was cut, or the failure to return.
|
|
1045
1098
|
*/
|
|
1046
|
-
async stagedPatch(
|
|
1099
|
+
async stagedPatch(repository, amend, signal) {
|
|
1047
1100
|
const settings = this.source();
|
|
1048
|
-
const base = amend && await this.hasParent(
|
|
1049
|
-
const stat = await this.
|
|
1101
|
+
const base = amend && await this.hasParent(repository, signal) ? ["HEAD~1"] : [];
|
|
1102
|
+
const stat = await this.read(repository, [
|
|
1050
1103
|
"diff",
|
|
1104
|
+
...DIFF_SAFETY_ARGS,
|
|
1051
1105
|
"--cached",
|
|
1052
1106
|
"--stat",
|
|
1053
1107
|
...base
|
|
1054
1108
|
], signal);
|
|
1055
1109
|
if (stat.exitCode !== 0) return { failure: classify(stat) };
|
|
1056
1110
|
const cap = settings.commitMessageMaxBytes;
|
|
1057
|
-
const patch = await this.
|
|
1111
|
+
const patch = await this.read(repository, [
|
|
1058
1112
|
"diff",
|
|
1113
|
+
...DIFF_SAFETY_ARGS,
|
|
1059
1114
|
"--cached",
|
|
1060
1115
|
"--no-color",
|
|
1061
1116
|
...base
|
|
@@ -1074,12 +1129,12 @@ var GitReader = class {
|
|
|
1074
1129
|
}
|
|
1075
1130
|
/**
|
|
1076
1131
|
* Whether HEAD has a parent commit.
|
|
1077
|
-
* @param
|
|
1132
|
+
* @param repository - the resolved repository.
|
|
1078
1133
|
* @param signal - cancellation for the invocation.
|
|
1079
1134
|
* @returns true when `HEAD~1` resolves.
|
|
1080
1135
|
*/
|
|
1081
|
-
async hasParent(
|
|
1082
|
-
return (await this.
|
|
1136
|
+
async hasParent(repository, signal) {
|
|
1137
|
+
return (await this.read(repository, [
|
|
1083
1138
|
"rev-parse",
|
|
1084
1139
|
"--verify",
|
|
1085
1140
|
"--quiet",
|
|
@@ -1088,12 +1143,12 @@ var GitReader = class {
|
|
|
1088
1143
|
}
|
|
1089
1144
|
/**
|
|
1090
1145
|
* The remote an unpublished branch would be published to.
|
|
1091
|
-
* @param
|
|
1146
|
+
* @param repository - the resolved repository.
|
|
1092
1147
|
* @param signal - cancellation for the invocation.
|
|
1093
1148
|
* @returns `origin` when it exists, else the first remote, else undefined.
|
|
1094
1149
|
*/
|
|
1095
|
-
async defaultRemote(
|
|
1096
|
-
const outcome = await this.
|
|
1150
|
+
async defaultRemote(repository, signal) {
|
|
1151
|
+
const outcome = await this.read(repository, ["remote"], signal);
|
|
1097
1152
|
if (outcome.exitCode !== 0) return void 0;
|
|
1098
1153
|
const remotes = outcome.stdout.split("\n").map((line) => line.trim()).filter((line) => line !== "");
|
|
1099
1154
|
return remotes.includes(DEFAULT_REMOTE) ? DEFAULT_REMOTE : remotes[0];
|
|
@@ -1105,16 +1160,16 @@ var GitReader = class {
|
|
|
1105
1160
|
* @param argv - builds the git arguments from the accepted paths.
|
|
1106
1161
|
* @returns the reading after the write, or a classified failure.
|
|
1107
1162
|
*/
|
|
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);
|
|
1163
|
+
async write(request$2, signal, argv) {
|
|
1164
|
+
if (request$2.paths.length === 0) return fail$4("path-denied", "no paths were given");
|
|
1165
|
+
const prepared = await this.prepare(request$2.workspacePath, signal);
|
|
1111
1166
|
if ("failure" in prepared) return prepared.failure;
|
|
1112
1167
|
const { repository } = prepared;
|
|
1113
|
-
const contained = await this.contain(repository.root, request.paths, signal);
|
|
1168
|
+
const contained = await this.contain(repository.root, request$2.paths, signal);
|
|
1114
1169
|
if (contained !== void 0) return contained;
|
|
1115
|
-
const outcome = await this.git(repository.root, argv(request.paths), signal);
|
|
1170
|
+
const outcome = await this.git(repository.root, argv(request$2.paths), signal);
|
|
1116
1171
|
if (outcome.exitCode !== 0) return classify(outcome);
|
|
1117
|
-
const status = await this.status({ workspacePath: request.workspacePath }, signal);
|
|
1172
|
+
const status = await this.status({ workspacePath: request$2.workspacePath }, signal);
|
|
1118
1173
|
return status.ok ? {
|
|
1119
1174
|
ok: true,
|
|
1120
1175
|
status
|
|
@@ -1142,12 +1197,12 @@ var GitReader = class {
|
|
|
1142
1197
|
}
|
|
1143
1198
|
/**
|
|
1144
1199
|
* The author `git commit` would record.
|
|
1145
|
-
* @param
|
|
1200
|
+
* @param repository - the resolved repository.
|
|
1146
1201
|
* @param signal - cancellation for the invocation.
|
|
1147
1202
|
* @returns `Name <email>`, or undefined when git has no identity configured.
|
|
1148
1203
|
*/
|
|
1149
|
-
async author(
|
|
1150
|
-
const outcome = await this.
|
|
1204
|
+
async author(repository, signal) {
|
|
1205
|
+
const outcome = await this.read(repository, ["var", "GIT_AUTHOR_IDENT"], signal);
|
|
1151
1206
|
if (outcome.exitCode !== 0) return void 0;
|
|
1152
1207
|
const ident = outcome.stdout.trim();
|
|
1153
1208
|
const at = ident.lastIndexOf(">");
|
|
@@ -1155,29 +1210,30 @@ var GitReader = class {
|
|
|
1155
1210
|
}
|
|
1156
1211
|
/**
|
|
1157
1212
|
* Whether the index differs from HEAD.
|
|
1158
|
-
* @param
|
|
1213
|
+
* @param repository - the resolved repository.
|
|
1159
1214
|
* @param signal - cancellation for the invocation.
|
|
1160
1215
|
* @returns true when a commit would record something.
|
|
1161
1216
|
*/
|
|
1162
|
-
async hasStaged(
|
|
1163
|
-
return (await this.
|
|
1217
|
+
async hasStaged(repository, signal) {
|
|
1218
|
+
return (await this.read(repository, [
|
|
1164
1219
|
"diff",
|
|
1220
|
+
...DIFF_SAFETY_ARGS,
|
|
1165
1221
|
"--cached",
|
|
1166
1222
|
"--quiet"
|
|
1167
1223
|
], signal)).exitCode !== 0;
|
|
1168
1224
|
}
|
|
1169
1225
|
/**
|
|
1170
1226
|
* Report what the panel may do to this repository.
|
|
1171
|
-
* @param
|
|
1227
|
+
* @param repository - the resolved repository.
|
|
1172
1228
|
* @param signal - cancellation for the identity lookup.
|
|
1173
1229
|
* @returns the write capability.
|
|
1174
1230
|
*/
|
|
1175
|
-
async writeCapability(
|
|
1231
|
+
async writeCapability(repository, signal) {
|
|
1176
1232
|
const settings = this.source();
|
|
1177
1233
|
const canStage = settings.allowGitStaging;
|
|
1178
1234
|
const canCommit = canStage && settings.allowGitCommit;
|
|
1179
1235
|
const canDraftMessage = canCommit && settings.allowCommitMessageDraft && this.ctx.get("llm") !== void 0 && this.ctx.get("agentDefaultModel") !== void 0;
|
|
1180
|
-
const author = canCommit ? await this.author(
|
|
1236
|
+
const author = canCommit ? await this.author(repository, signal) : void 0;
|
|
1181
1237
|
return {
|
|
1182
1238
|
canStage,
|
|
1183
1239
|
canCommit,
|
|
@@ -1217,16 +1273,70 @@ var GitReader = class {
|
|
|
1217
1273
|
if (outcome.exitCode !== 0) return { failure: classify(outcome) };
|
|
1218
1274
|
const [root, prefix] = outcome.stdout.split("\n");
|
|
1219
1275
|
if (root === void 0 || root.trim() === "") return { failure: fail$4("not-a-repository", `${cwd} is not inside a git repository`) };
|
|
1276
|
+
const readingConfig = await this.untrustedFilterConfig(root.trim(), signal);
|
|
1277
|
+
if ("failure" in readingConfig) return { failure: readingConfig.failure };
|
|
1220
1278
|
return { repository: {
|
|
1221
1279
|
root: root.trim(),
|
|
1222
|
-
prefix: (prefix ?? "").trim().replace(/\/$/u, "")
|
|
1280
|
+
prefix: (prefix ?? "").trim().replace(/\/$/u, ""),
|
|
1281
|
+
readingConfig: readingConfig.config
|
|
1223
1282
|
} };
|
|
1224
1283
|
}
|
|
1225
1284
|
/**
|
|
1226
|
-
*
|
|
1285
|
+
* The overrides that switch off the content filters a repository's own config defines.
|
|
1286
|
+
*
|
|
1287
|
+
* Listing config runs nothing — `git config` reads files — so this is safe to ask before any
|
|
1288
|
+
* reading. `--show-scope` arrived in git 2.26; an older git refuses the flag, and the listing is
|
|
1289
|
+
* then repeated without it and every filter it names is treated as untrusted, which can only make
|
|
1290
|
+
* a reading more conservative. A listing that fails both ways fails the reading: a status that
|
|
1291
|
+
* cannot prove its filters are disarmed is not run.
|
|
1292
|
+
* @param root - absolute repository root.
|
|
1293
|
+
* @param signal - cancellation for the listing.
|
|
1294
|
+
* @returns the `-c` pairs, or the failure to return.
|
|
1295
|
+
*/
|
|
1296
|
+
async untrustedFilterConfig(root, signal) {
|
|
1297
|
+
const scoped = await this.git(root, [
|
|
1298
|
+
"config",
|
|
1299
|
+
"--show-scope",
|
|
1300
|
+
"--name-only",
|
|
1301
|
+
"--get-regexp",
|
|
1302
|
+
FILTER_EXECUTABLE_KEYS
|
|
1303
|
+
], signal);
|
|
1304
|
+
const listing = isConfigListing(scoped) ? scoped : await this.git(root, [
|
|
1305
|
+
"config",
|
|
1306
|
+
"--name-only",
|
|
1307
|
+
"--get-regexp",
|
|
1308
|
+
FILTER_EXECUTABLE_KEYS
|
|
1309
|
+
], signal);
|
|
1310
|
+
if (!isConfigListing(listing)) return { failure: classify(listing) };
|
|
1311
|
+
const overrides = filterOverrides(listing.stdout);
|
|
1312
|
+
if ("unsafeKey" in overrides) return { failure: fail$4("git-failed", `refusing to read this repository: its config defines ${JSON.stringify(overrides.unsafeKey)}, a filter program that cannot be switched off from the command line`) };
|
|
1313
|
+
return { config: overrides.config };
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* Run one READING: an invocation the panel makes on its own, which must run no program the
|
|
1317
|
+
* repository's config names.
|
|
1318
|
+
*
|
|
1319
|
+
* On top of {@link git}'s own `core.fsmonitor` override it disarms the repository's content
|
|
1320
|
+
* filters. Writes (`add`, `restore`, `commit`, `push`) deliberately do not go through here: they are
|
|
1321
|
+
* an operator's explicit action, a filter such as LFS is part of what staging correctly means, and a
|
|
1322
|
+
* commit's hooks are a real answer the panel reports.
|
|
1323
|
+
* @param repository - the resolved repository, carrying its filter overrides.
|
|
1324
|
+
* @param args - arguments after the executable and the overrides.
|
|
1325
|
+
* @param signal - the caller's cancellation.
|
|
1326
|
+
* @param maxBytes - the caller's own output bound, when it has one.
|
|
1327
|
+
* @param cwd - directory to run in; the repository root unless the reading is relative to the workspace.
|
|
1328
|
+
* @returns the finished command.
|
|
1329
|
+
*/
|
|
1330
|
+
read(repository, args, signal, maxBytes, cwd = repository.root) {
|
|
1331
|
+
return this.git(cwd, [...repository.readingConfig, ...args], signal, maxBytes);
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Run one git invocation with this plugin's own bounds and {@link INVOCATION_CONFIG}.
|
|
1227
1335
|
* @param cwd - directory to run in.
|
|
1228
|
-
* @param args - arguments after the executable.
|
|
1336
|
+
* @param args - arguments after the executable and the invocation config.
|
|
1229
1337
|
* @param signal - the caller's cancellation.
|
|
1338
|
+
* @param maxBytes - the caller's own output bound, when it has one.
|
|
1339
|
+
* @param timeoutMs - the caller's own wall-clock bound, when it has one.
|
|
1230
1340
|
* @returns the finished command.
|
|
1231
1341
|
*/
|
|
1232
1342
|
async git(cwd, args, signal, maxBytes, timeoutMs) {
|
|
@@ -1242,7 +1352,11 @@ var GitReader = class {
|
|
|
1242
1352
|
};
|
|
1243
1353
|
const settings = this.source();
|
|
1244
1354
|
return runCommand(this.ctx, {
|
|
1245
|
-
argv: [
|
|
1355
|
+
argv: [
|
|
1356
|
+
executable,
|
|
1357
|
+
...INVOCATION_CONFIG,
|
|
1358
|
+
...args
|
|
1359
|
+
],
|
|
1246
1360
|
cwd,
|
|
1247
1361
|
timeoutMs: timeoutMs ?? settings.gitTimeoutMs,
|
|
1248
1362
|
maxBytes: maxBytes ?? Math.max(settings.gitDiffMaxBytes, 1 << 20),
|
|
@@ -1284,6 +1398,15 @@ var GitReader = class {
|
|
|
1284
1398
|
}
|
|
1285
1399
|
};
|
|
1286
1400
|
/**
|
|
1401
|
+
* Whether a `git config --get-regexp` finished with a usable answer.
|
|
1402
|
+
* @param outcome - the finished listing.
|
|
1403
|
+
* @returns true for a listing (exit 0) or for "nothing matched" (exit 1 with a silent stderr).
|
|
1404
|
+
*/
|
|
1405
|
+
function isConfigListing(outcome) {
|
|
1406
|
+
if (outcome.exitCode === 0) return true;
|
|
1407
|
+
return outcome.exitCode === CONFIG_NO_MATCH_EXIT && outcome.stderr.trim() === "";
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1287
1410
|
* The empty left-hand side of an untracked file's synthesized patch.
|
|
1288
1411
|
* @returns the platform's null device path.
|
|
1289
1412
|
*/
|
|
@@ -1360,14 +1483,14 @@ var OpenInLauncher = class {
|
|
|
1360
1483
|
* @param signal - cancellation for the launch.
|
|
1361
1484
|
* @returns settlement, or a classified failure.
|
|
1362
1485
|
*/
|
|
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`);
|
|
1486
|
+
async open(request$2, signal) {
|
|
1487
|
+
const resolvedPath = await resolveWorkspace(this.ctx, request$2.path, signal);
|
|
1488
|
+
const target = resolvedPath.ok ? resolvedPath.value.processPath : await this.resolveFile(request$2.path, signal);
|
|
1489
|
+
if (target === void 0) return fail$3("path-denied", `${request$2.path} does not exist on this Host`);
|
|
1367
1490
|
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}"`);
|
|
1491
|
+
if (request$2.targetId === REVEAL_TARGET_ID) return this.reveal(target, directory, signal);
|
|
1492
|
+
const editor = this.source().editors.find((entry) => entry.id === request$2.targetId);
|
|
1493
|
+
if (editor === void 0) return fail$3("unknown-target", `no Open in target "${request$2.targetId}"`);
|
|
1371
1494
|
const executable = await this.locate(editor.command, signal);
|
|
1372
1495
|
if (executable === void 0) return fail$3("unavailable", `${editor.label}: "${editor.command}" does not resolve on this Host`);
|
|
1373
1496
|
return this.launch([
|
|
@@ -1685,8 +1808,8 @@ var PreviewServers = class {
|
|
|
1685
1808
|
* @param signal - cancellation for the file read.
|
|
1686
1809
|
* @returns the list, or a classified failure.
|
|
1687
1810
|
*/
|
|
1688
|
-
async list(request, signal) {
|
|
1689
|
-
const workspace = await resolveWorkspace(this.ctx, request.workspacePath, signal);
|
|
1811
|
+
async list(request$2, signal) {
|
|
1812
|
+
const workspace = await resolveWorkspace(this.ctx, request$2.workspacePath, signal);
|
|
1690
1813
|
if (!workspace.ok) return fail$2(workspace.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied", workspace.rejection.message);
|
|
1691
1814
|
const file = await this.readLaunchFile(workspace, signal);
|
|
1692
1815
|
return {
|
|
@@ -1702,13 +1825,13 @@ var PreviewServers = class {
|
|
|
1702
1825
|
* @param signal - cancellation of the start itself; a started server owns its later lifetime.
|
|
1703
1826
|
* @returns the started row, or a classified failure.
|
|
1704
1827
|
*/
|
|
1705
|
-
async start(request, signal) {
|
|
1828
|
+
async start(request$2, signal) {
|
|
1706
1829
|
if (this.closing) return fail$2("closed", "the plugin is unloading");
|
|
1707
1830
|
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);
|
|
1831
|
+
const workspace = await resolveWorkspace(this.ctx, request$2.workspacePath, signal);
|
|
1709
1832
|
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}"`);
|
|
1833
|
+
const resolved = mergeLaunches((await this.readLaunchFile(workspace, signal)).launches, this.settingsLaunches()).find((entry) => entry.launch.name === request$2.name);
|
|
1834
|
+
if (resolved === void 0) return fail$2("unknown-server", `no launch configuration named "${request$2.name}"`);
|
|
1712
1835
|
const launch = resolved.launch;
|
|
1713
1836
|
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
1837
|
const key = `${workspace.value.processPath}\u0000${launch.name}`;
|
|
@@ -1804,9 +1927,9 @@ var PreviewServers = class {
|
|
|
1804
1927
|
* @param request - the handle.
|
|
1805
1928
|
* @returns settlement, or a classified failure.
|
|
1806
1929
|
*/
|
|
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}`);
|
|
1930
|
+
async stop(request$2) {
|
|
1931
|
+
const record = this.records.get(request$2.serverId);
|
|
1932
|
+
if (record === void 0) return fail$2("unknown-server", `no preview server ${request$2.serverId}`);
|
|
1810
1933
|
this.records.delete(record.serverId);
|
|
1811
1934
|
await this.terminate(record);
|
|
1812
1935
|
return { ok: true };
|
|
@@ -1816,12 +1939,12 @@ var PreviewServers = class {
|
|
|
1816
1939
|
* @param request - the handle and the offset already rendered.
|
|
1817
1940
|
* @returns the delta and the state, or a classified failure.
|
|
1818
1941
|
*/
|
|
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}`);
|
|
1942
|
+
logs(request$2) {
|
|
1943
|
+
const record = this.records.get(request$2.serverId);
|
|
1944
|
+
if (record === void 0) return fail$2("unknown-server", `no preview server ${request$2.serverId}`);
|
|
1822
1945
|
this.drain(record);
|
|
1823
1946
|
const total = record.base + record.buffer.length;
|
|
1824
|
-
const from = Number.isFinite(request.fromOffset) ? Math.max(0, Math.floor(request.fromOffset)) : 0;
|
|
1947
|
+
const from = Number.isFinite(request$2.fromOffset) ? Math.max(0, Math.floor(request$2.fromOffset)) : 0;
|
|
1825
1948
|
const lossy = from < record.base;
|
|
1826
1949
|
const text = lossy ? record.buffer : record.buffer.slice(Math.min(from - record.base, record.buffer.length));
|
|
1827
1950
|
return {
|
|
@@ -2042,34 +2165,101 @@ var PreviewServers = class {
|
|
|
2042
2165
|
};
|
|
2043
2166
|
|
|
2044
2167
|
//#endregion
|
|
2045
|
-
//#region tsbuild/host/
|
|
2046
|
-
/**
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2168
|
+
//#region tsbuild/host/preview-serve.js
|
|
2169
|
+
/**
|
|
2170
|
+
* Largest scratchpad document the Host will echo back.
|
|
2171
|
+
*
|
|
2172
|
+
* The scratchpad is a person typing HTML into a text area; a megabyte of it is a mistake or an
|
|
2173
|
+
* attempt to make the Host hold memory, and neither is worth serving.
|
|
2174
|
+
*/
|
|
2175
|
+
const SCRATCHPAD_MAX_BYTES = 1024 * 1024;
|
|
2176
|
+
/**
|
|
2177
|
+
* How many bytes of a file's head feed the change token.
|
|
2178
|
+
*
|
|
2179
|
+
* The token is what makes the panel reload when a file changes on disk. Hashing the whole file
|
|
2180
|
+
* would read a video twice per poll; hashing the head misses an edit past the first 8 KB, so the
|
|
2181
|
+
* token is `version + size + head digest` — the backend's own opaque version already changes on any
|
|
2182
|
+
* write, and the head digest is the fallback for a backend that reports a constant version.
|
|
2183
|
+
*/
|
|
2184
|
+
const TOKEN_PROBE_BYTES = 8192;
|
|
2185
|
+
/**
|
|
2186
|
+
* Largest byte window this module will ask a filesystem for.
|
|
2187
|
+
*
|
|
2188
|
+
* `ctx.fs.readBytes` takes a cap rather than a range, so a request for the middle of a file is
|
|
2189
|
+
* impossible through it; the window is what the fallback path reads, and it is bounded well below
|
|
2190
|
+
* `previewMaxFileBytes` so an over-limit read cannot become an out-of-memory.
|
|
2191
|
+
*/
|
|
2192
|
+
const FALLBACK_READ_BYTES = 4 * 1024 * 1024;
|
|
2193
|
+
/**
|
|
2194
|
+
* Whether a filesystem implementation can read one byte window.
|
|
2195
|
+
* @param fs - the filesystem, or the value `ctx.get('fs')` returned.
|
|
2196
|
+
* @returns true when the ranged read is available.
|
|
2197
|
+
*/
|
|
2198
|
+
function hasRangeRead(fs) {
|
|
2199
|
+
return "readByteRange" in fs && typeof fs.readByteRange === "function";
|
|
2061
2200
|
}
|
|
2201
|
+
/** Upstream statuses re-sent as-is; everything else keeps its status but loses its body only on HEAD. */
|
|
2202
|
+
const HOP_BY_HOP = new Set([
|
|
2203
|
+
"connection",
|
|
2204
|
+
"keep-alive",
|
|
2205
|
+
"proxy-authenticate",
|
|
2206
|
+
"proxy-authorization",
|
|
2207
|
+
"te",
|
|
2208
|
+
"trailer",
|
|
2209
|
+
"transfer-encoding",
|
|
2210
|
+
"upgrade"
|
|
2211
|
+
]);
|
|
2062
2212
|
/**
|
|
2063
|
-
*
|
|
2064
|
-
*
|
|
2213
|
+
* Headers the proxy decides for itself.
|
|
2214
|
+
*
|
|
2215
|
+
* `host` is rewritten to the upstream's own authority, because a dev server routes on it and a
|
|
2216
|
+
* virtual-hosted one would answer the wrong site; `origin` and `referer` point at the GUI, and
|
|
2217
|
+
* forwarding them would make the dev server reject a request it thinks is cross-site; `accept-encoding`
|
|
2218
|
+
* is dropped so the upstream's response and the GUI's compression middleware never double-encode.
|
|
2065
2219
|
*/
|
|
2066
|
-
|
|
2220
|
+
const REWRITTEN_REQUEST_HEADERS = new Set([
|
|
2221
|
+
"host",
|
|
2222
|
+
"origin",
|
|
2223
|
+
"referer",
|
|
2224
|
+
"accept-encoding",
|
|
2225
|
+
"connection"
|
|
2226
|
+
]);
|
|
2227
|
+
/**
|
|
2228
|
+
* Name prefix of the harness's browser-session cookie (`dsh-client-connection`, `COOKIE_PREFIX`).
|
|
2229
|
+
*
|
|
2230
|
+
* The full name is this prefix plus a digest of the authority, so the prefix is the stable part. A
|
|
2231
|
+
* request forwarded to a dev server must not carry it — whatever listens on a loopback port would
|
|
2232
|
+
* otherwise hold a credential that authenticates it to the whole harness API — and a dev server's
|
|
2233
|
+
* `Set-Cookie` must not be able to replace it on the GUI's origin.
|
|
2234
|
+
*/
|
|
2235
|
+
const HOST_AUTH_COOKIE_PREFIX = "dsh-auth-";
|
|
2236
|
+
/** The hop-by-hop headers a websocket tunnel must still forward, because they are the handshake. */
|
|
2237
|
+
const UPGRADE_HANDSHAKE_HEADERS = new Set(["connection", "upgrade"]);
|
|
2238
|
+
/**
|
|
2239
|
+
* Serves workspace files and proxies loopback dev servers on the GUI's own origin.
|
|
2240
|
+
*
|
|
2241
|
+
* One instance is created by the service and disposed with it, which is what guarantees no route
|
|
2242
|
+
* outlives the plugin: every registration returns a disposer, and `dispose()` runs them all.
|
|
2243
|
+
*/
|
|
2244
|
+
var PreviewSurface = class {
|
|
2067
2245
|
ctx;
|
|
2068
2246
|
source;
|
|
2069
|
-
records = /* @__PURE__ */ new Map();
|
|
2070
|
-
closing = false;
|
|
2071
2247
|
/**
|
|
2072
|
-
*
|
|
2248
|
+
* The upstream URL each panel last pointed its frame at, keyed by its client id.
|
|
2249
|
+
*
|
|
2250
|
+
* Only a fallback: a proxied document's own URLs all carry their target explicitly, because the
|
|
2251
|
+
* browser rewrites them from the injected `<base>`. What needs this map is a request the base
|
|
2252
|
+
* cannot reach — a `fetch()` from inside the page to a relative path, or a link with no base of
|
|
2253
|
+
* its own — and there is exactly one sensible target for those.
|
|
2254
|
+
*/
|
|
2255
|
+
targets = /* @__PURE__ */ new Map();
|
|
2256
|
+
/** Every request currently in flight, so a dispose does not leave sockets open. */
|
|
2257
|
+
live = /* @__PURE__ */ new Set();
|
|
2258
|
+
closed = false;
|
|
2259
|
+
/** Whether the routes are registered; see {@link RouteState}. */
|
|
2260
|
+
routes = "absent";
|
|
2261
|
+
/**
|
|
2262
|
+
* @param ctx - Host context carrying the optional filesystem capability.
|
|
2073
2263
|
* @param source - reads the current settings section; called per request.
|
|
2074
2264
|
*/
|
|
2075
2265
|
constructor(ctx, source) {
|
|
@@ -2077,53 +2267,881 @@ var PanelTerminals = class {
|
|
|
2077
2267
|
this.source = source;
|
|
2078
2268
|
}
|
|
2079
2269
|
/**
|
|
2080
|
-
*
|
|
2081
|
-
* @returns
|
|
2270
|
+
* What this surface is, for `describe()`.
|
|
2271
|
+
* @returns the route paths and whether the routes are actually mounted.
|
|
2082
2272
|
*/
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2273
|
+
info() {
|
|
2274
|
+
switch (this.routes) {
|
|
2275
|
+
case "mounted": return {
|
|
2276
|
+
fileRoute: FILE_ROUTE,
|
|
2277
|
+
proxyRoute: PROXY_ROUTE,
|
|
2278
|
+
available: true
|
|
2279
|
+
};
|
|
2280
|
+
case "absent": return {
|
|
2281
|
+
fileRoute: FILE_ROUTE,
|
|
2282
|
+
proxyRoute: PROXY_ROUTE,
|
|
2283
|
+
available: false,
|
|
2284
|
+
reason: "no web server capability is mounted: a workspace file has no same-origin URL to be framed from, and a dev server stays cross-origin"
|
|
2285
|
+
};
|
|
2286
|
+
case "ungated": return {
|
|
2287
|
+
fileRoute: FILE_ROUTE,
|
|
2288
|
+
proxyRoute: PROXY_ROUTE,
|
|
2289
|
+
available: false,
|
|
2290
|
+
reason: "this Host's connection exposes no request gate (requestRejection), so the preview routes are not mounted: without it they would serve workspace files to anything that can reach the port"
|
|
2291
|
+
};
|
|
2292
|
+
default: throw new TypeError(`advanced-sidebar: unexpected preview route state ${JSON.stringify(this.routes)}`);
|
|
2293
|
+
}
|
|
2096
2294
|
}
|
|
2097
2295
|
/**
|
|
2098
|
-
*
|
|
2099
|
-
* @param
|
|
2100
|
-
* @param
|
|
2101
|
-
* @returns the
|
|
2296
|
+
* The URL one workspace file is framed from, or undefined when this Host serves no routes.
|
|
2297
|
+
* @param workspacePath - absolute Host workspace directory.
|
|
2298
|
+
* @param filePath - absolute Host path inside it.
|
|
2299
|
+
* @returns the same-origin path, query included.
|
|
2102
2300
|
*/
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2301
|
+
fileUrl(workspacePath, filePath) {
|
|
2302
|
+
return this.info().available ? fileUrl(FILE_ROUTE, workspacePath, filePath) : void 0;
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* The same-origin URL that proxies one loopback URL.
|
|
2306
|
+
* @param target - the loopback URL, already validated by the caller.
|
|
2307
|
+
* @returns the same-origin path, query included.
|
|
2308
|
+
*/
|
|
2309
|
+
proxyUrl(target) {
|
|
2310
|
+
return proxyUrlFor(PROXY_ROUTE, target);
|
|
2311
|
+
}
|
|
2312
|
+
/**
|
|
2313
|
+
* Register every route with the mounted web server, each behind the connection's request gate.
|
|
2314
|
+
*
|
|
2315
|
+
* Registration goes through `ctx.inject(['connection', 'webServer'], …)` rather than a constructor
|
|
2316
|
+
* read: a headless deployment composes neither, and the inject face simply never runs, leaving the
|
|
2317
|
+
* rest of the plugin working. The connection is a hard requirement rather than an optional extra —
|
|
2318
|
+
* it is the only thing that can tell the GUI's own browser from any other client of the port — so a
|
|
2319
|
+
* connection without `requestRejection` mounts nothing and says so through {@link info}.
|
|
2320
|
+
*/
|
|
2321
|
+
install() {
|
|
2322
|
+
this.ctx.inject(["connection", "webServer"], (routeCtx) => {
|
|
2323
|
+
const connection = Reflect.get(routeCtx, "connection");
|
|
2324
|
+
const webServer = Reflect.get(routeCtx, "webServer");
|
|
2325
|
+
const requestRejection = connection?.requestRejection;
|
|
2326
|
+
if (typeof requestRejection !== "function") {
|
|
2327
|
+
this.routes = "ungated";
|
|
2328
|
+
return () => {
|
|
2329
|
+
this.routes = "absent";
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
const gate = (req) => requestRejection.call(connection, req);
|
|
2333
|
+
const disposeFile = webServer.register({
|
|
2334
|
+
kind: "exact",
|
|
2335
|
+
path: FILE_ROUTE,
|
|
2336
|
+
handler: (req, res) => refuseHttp(gate, req, res) ? void 0 : this.handleFile(req, res)
|
|
2337
|
+
});
|
|
2338
|
+
const disposeScratch = webServer.register({
|
|
2339
|
+
kind: "exact",
|
|
2340
|
+
path: SCRATCHPAD_ROUTE,
|
|
2341
|
+
handler: (req, res) => refuseHttp(gate, req, res) ? void 0 : this.handleScratchpad(req, res)
|
|
2342
|
+
});
|
|
2343
|
+
const disposeProxy = webServer.register({
|
|
2344
|
+
kind: "prefix",
|
|
2345
|
+
path: PROXY_ROUTE,
|
|
2346
|
+
handler: (req, res) => refuseHttp(gate, req, res) ? void 0 : this.handleProxy(req, res)
|
|
2347
|
+
});
|
|
2348
|
+
const disposeUpgrade = webServer.registerUpgrade({
|
|
2349
|
+
path: PROXY_ROUTE,
|
|
2350
|
+
handler: (req, socket, head) => {
|
|
2351
|
+
if (refuseUpgrade(gate, req, socket)) return;
|
|
2352
|
+
this.handleUpgrade(req, socket, head);
|
|
2353
|
+
}
|
|
2354
|
+
});
|
|
2355
|
+
this.routes = "mounted";
|
|
2356
|
+
return () => {
|
|
2357
|
+
this.routes = "absent";
|
|
2358
|
+
disposeUpgrade();
|
|
2359
|
+
disposeProxy();
|
|
2360
|
+
disposeScratch();
|
|
2361
|
+
disposeFile();
|
|
2362
|
+
};
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
/** Forget every target and abort every in-flight request. Called from the plugin's teardown. */
|
|
2366
|
+
dispose() {
|
|
2367
|
+
this.closed = true;
|
|
2368
|
+
this.targets.clear();
|
|
2369
|
+
for (const upstream of [...this.live]) upstream.abort();
|
|
2370
|
+
this.live.clear();
|
|
2371
|
+
}
|
|
2372
|
+
/**
|
|
2373
|
+
* Describe one workspace file for the panel, or refuse it.
|
|
2374
|
+
* @param workspacePath - absolute Host workspace directory.
|
|
2375
|
+
* @param path - absolute Host path, or a path relative to the workspace.
|
|
2376
|
+
* @param signal - cancellation for the resolution and the metadata read.
|
|
2377
|
+
* @returns the file's kind, size, frame URL, and change token.
|
|
2378
|
+
*/
|
|
2379
|
+
async info_(workspacePath, path, signal) {
|
|
2380
|
+
const resolved = await this.resolve(workspacePath, path, signal);
|
|
2381
|
+
if (!resolved.ok) return resolved.failure;
|
|
2382
|
+
return this.describeFile(resolved.workspace, resolved.target, resolved.display, signal);
|
|
2383
|
+
}
|
|
2384
|
+
/**
|
|
2385
|
+
* Serve one workspace file as the frame's document or as one of its subresources.
|
|
2386
|
+
* @param req - the incoming request.
|
|
2387
|
+
* @param res - the response the handler owns.
|
|
2388
|
+
*/
|
|
2389
|
+
async handleFile(req, res) {
|
|
2390
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2391
|
+
send(res, 405, "text/plain; charset=utf-8", "only GET and HEAD are served here");
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
const query = new URL(req.url ?? "/", "http://x").searchParams;
|
|
2395
|
+
const resolved = await this.resolve(query.get("workspace") ?? void 0, query.get("path") ?? "", void 0);
|
|
2396
|
+
if (!resolved.ok) {
|
|
2397
|
+
res.setHeader("cache-control", "no-store");
|
|
2398
|
+
send(res, 404, "text/plain; charset=utf-8", resolved.failure.message);
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
const fs = this.ctx.get("fs");
|
|
2402
|
+
/* v8 ignore next -- `resolve` refuses before this point when the capability is absent. */
|
|
2403
|
+
if (fs === void 0) {
|
|
2404
|
+
send(res, 500, "text/plain; charset=utf-8", "filesystem capability withdrawn mid-request");
|
|
2405
|
+
return;
|
|
2406
|
+
}
|
|
2407
|
+
const stat = await fs.stat(resolved.target);
|
|
2408
|
+
if (stat === void 0 || stat.type !== "file") {
|
|
2409
|
+
res.setHeader("cache-control", "no-store");
|
|
2410
|
+
send(res, 404, "text/plain; charset=utf-8", `${resolved.display} is not a regular file`);
|
|
2411
|
+
return;
|
|
2412
|
+
}
|
|
2413
|
+
const size = stat.size ?? 0;
|
|
2414
|
+
const contentType = contentTypeOf(resolved.display);
|
|
2415
|
+
const etag = await this.etag(resolved.target, stat.version, size, stat.size);
|
|
2416
|
+
res.setHeader("cache-control", "no-store");
|
|
2417
|
+
res.setHeader("etag", etag);
|
|
2418
|
+
res.setHeader("last-modified", (/* @__PURE__ */ new Date()).toUTCString());
|
|
2419
|
+
if (req.headers["if-none-match"] === etag) {
|
|
2420
|
+
res.writeHead(304);
|
|
2421
|
+
res.end();
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
if (size > this.source().previewMaxFileBytes) {
|
|
2425
|
+
send(res, 413, "text/plain; charset=utf-8", `${resolved.display} is ${String(size)} bytes, over previewMaxFileBytes`);
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
res.setHeader("content-type", isTextual(contentType) ? `${contentType}; charset=utf-8` : contentType);
|
|
2429
|
+
res.setHeader("x-content-type-options", "nosniff");
|
|
2430
|
+
res.setHeader("content-encoding", "identity");
|
|
2431
|
+
const ranged = hasRangeRead(fs) ? fs : void 0;
|
|
2432
|
+
res.setHeader("accept-ranges", ranged === void 0 ? "none" : "bytes");
|
|
2433
|
+
const asked = ranged === void 0 ? void 0 : parseRange(req.headers.range, size);
|
|
2434
|
+
if (asked === "unsatisfiable") {
|
|
2435
|
+
res.setHeader("content-range", `bytes */${String(size)}`);
|
|
2436
|
+
send(res, 416, "text/plain; charset=utf-8", "the requested range is past the end of the file");
|
|
2437
|
+
return;
|
|
2438
|
+
}
|
|
2439
|
+
const range = typeof asked === "string" ? void 0 : asked;
|
|
2440
|
+
let bytes;
|
|
2441
|
+
try {
|
|
2442
|
+
bytes = range === void 0 ? await fs.readBytes(resolved.target, void 0, size) : await (ranged ?? fs).readByteRange(resolved.target, {
|
|
2443
|
+
offset: range.start,
|
|
2444
|
+
length: range.end - range.start + 1
|
|
2445
|
+
});
|
|
2446
|
+
} catch (error) {
|
|
2447
|
+
send(res, 500, "text/plain; charset=utf-8", error instanceof Error ? error.message : String(error));
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2450
|
+
let body = bytes;
|
|
2451
|
+
if (range === void 0 && contentType === "text/html" && size <= FALLBACK_READ_BYTES) body = new TextEncoder().encode(injectBase(decodeText(bytes), `${FILE_ROUTE}?workspace=${encodeQuery(resolved.workspace.processPath)}&path=`));
|
|
2452
|
+
if (range !== void 0) res.setHeader("content-range", `bytes ${String(range.start)}-${String(range.end)}/${String(size)}`);
|
|
2453
|
+
res.setHeader("content-length", String(body.byteLength));
|
|
2454
|
+
res.writeHead(range === void 0 ? 200 : 206);
|
|
2455
|
+
if (req.method === "HEAD") res.end();
|
|
2456
|
+
else res.end(Buffer.from(body));
|
|
2457
|
+
}
|
|
2458
|
+
/**
|
|
2459
|
+
* Serve text the panel posted, as an HTML document on this origin.
|
|
2460
|
+
*
|
|
2461
|
+
* The scratchpad renders as `text/html` rather than through `srcdoc` so that the frame's document
|
|
2462
|
+
* has a real URL with this origin: `document.baseURI`, relative `fetch`, and `window.location` all
|
|
2463
|
+
* then behave the way the page under test expects, and an agent's `eval` sees them.
|
|
2464
|
+
* @param req - the incoming request.
|
|
2465
|
+
* @param res - the response the handler owns.
|
|
2466
|
+
*/
|
|
2467
|
+
handleScratchpad(req, res) {
|
|
2468
|
+
if (req.method !== "POST" && req.method !== "GET" && req.method !== "HEAD") {
|
|
2469
|
+
send(res, 405, "text/plain; charset=utf-8", "only GET, HEAD and POST are served here");
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2472
|
+
res.setHeader("cache-control", "no-store");
|
|
2473
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
2474
|
+
res.setHeader("x-content-type-options", "nosniff");
|
|
2475
|
+
res.setHeader("content-encoding", "identity");
|
|
2476
|
+
if (req.method !== "POST") {
|
|
2477
|
+
res.setHeader("content-length", "0");
|
|
2478
|
+
res.writeHead(204);
|
|
2479
|
+
res.end();
|
|
2480
|
+
return;
|
|
2481
|
+
}
|
|
2482
|
+
const chunks = [];
|
|
2483
|
+
let total = 0;
|
|
2484
|
+
let refused = false;
|
|
2485
|
+
req.on("data", (chunk) => {
|
|
2486
|
+
if (refused) return;
|
|
2487
|
+
total += chunk.byteLength;
|
|
2488
|
+
if (total > SCRATCHPAD_MAX_BYTES) {
|
|
2489
|
+
refused = true;
|
|
2490
|
+
send(res, 413, "text/plain; charset=utf-8", "the scratchpad document is over its size limit");
|
|
2491
|
+
req.destroy();
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
chunks.push(chunk);
|
|
2495
|
+
});
|
|
2496
|
+
req.on("end", () => {
|
|
2497
|
+
if (refused) return;
|
|
2498
|
+
const document = Buffer.concat(chunks).toString("utf8");
|
|
2499
|
+
const body = Buffer.from(injectBase(document, "/"), "utf8");
|
|
2500
|
+
res.setHeader("content-length", String(body.byteLength));
|
|
2501
|
+
res.writeHead(200);
|
|
2502
|
+
if (req.method === "HEAD") res.end();
|
|
2503
|
+
else res.end(body);
|
|
2504
|
+
});
|
|
2505
|
+
req.on("error", () => {
|
|
2506
|
+
if (!refused) res.destroy();
|
|
2507
|
+
});
|
|
2508
|
+
}
|
|
2509
|
+
/**
|
|
2510
|
+
* Forward one request to a loopback upstream and stream the answer back.
|
|
2511
|
+
* @param req - the incoming request.
|
|
2512
|
+
* @param res - the response the handler owns.
|
|
2513
|
+
*/
|
|
2514
|
+
async handleProxy(req, res) {
|
|
2515
|
+
if (this.closed) {
|
|
2516
|
+
send(res, 503, "text/plain; charset=utf-8", "the preview surface is unloading");
|
|
2517
|
+
return;
|
|
2518
|
+
}
|
|
2519
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
2520
|
+
const explicit = url.searchParams.get("url");
|
|
2521
|
+
const suffix = suffixOf(url.pathname);
|
|
2522
|
+
const target = explicit === null ? this.fallbackTarget(req, suffix) : validateProxyTarget(explicit);
|
|
2523
|
+
if (target === void 0) {
|
|
2524
|
+
send(res, 404, "text/plain; charset=utf-8", "this proxied request names no loopback target: the preview proxy forwards only requests that carry `?url=`, or that a bound preview frame could have produced");
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
if (!target.ok) {
|
|
2528
|
+
send(res, 403, "text/plain; charset=utf-8", target.message);
|
|
2529
|
+
return;
|
|
2530
|
+
}
|
|
2531
|
+
const upstreamUrl = new URL(target.url.href);
|
|
2532
|
+
if (explicit === null) {
|
|
2533
|
+
const basePath = upstreamUrl.pathname.replace(/\/$/u, "");
|
|
2534
|
+
if (suffix !== "") upstreamUrl.pathname = `${basePath}/${suffix}`;
|
|
2535
|
+
upstreamUrl.search = url.search;
|
|
2536
|
+
} else if (suffix !== "") {
|
|
2537
|
+
const basePath = upstreamUrl.pathname.replace(/\/$/u, "");
|
|
2538
|
+
upstreamUrl.pathname = suffix.startsWith(basePath.slice(1)) ? `/${suffix}` : `${basePath}/${suffix}`;
|
|
2539
|
+
const forwarded = new URLSearchParams(url.searchParams);
|
|
2540
|
+
forwarded.delete("url");
|
|
2541
|
+
const extra = forwarded.toString();
|
|
2542
|
+
upstreamUrl.search = extra === "" ? "" : `?${extra}`;
|
|
2543
|
+
}
|
|
2544
|
+
this.forward(req, res, upstreamUrl);
|
|
2545
|
+
}
|
|
2546
|
+
/**
|
|
2547
|
+
* Forward one upgraded connection to a loopback upstream.
|
|
2548
|
+
*
|
|
2549
|
+
* A dev server's live-reload socket is an optional convenience, not part of the inspection story:
|
|
2550
|
+
* a websocket carries no DOM and no console, and a page whose socket never opens still renders and
|
|
2551
|
+
* is still drivable. The tunnel is here because it is cheap — a raw `net` pipe with no protocol
|
|
2552
|
+
* knowledge — but a server that negotiates on a path other than the route's own root is not
|
|
2553
|
+
* tunnelled, because the web server's upgrade seat matches exact paths and claiming a wildcard
|
|
2554
|
+
* would collide with the app's own sockets.
|
|
2555
|
+
* @param req - the upgrade request.
|
|
2556
|
+
* @param socket - the client socket the handler owns.
|
|
2557
|
+
* @param head - bytes the parser already read past the request line.
|
|
2558
|
+
*/
|
|
2559
|
+
handleUpgrade(req, socket, head) {
|
|
2560
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
2561
|
+
const explicit = url.searchParams.get("url");
|
|
2562
|
+
const suffix = suffixOf(url.pathname);
|
|
2563
|
+
const target = explicit === null ? this.fallbackTarget(req, suffix) : validateProxyTarget(explicit);
|
|
2564
|
+
if (target === void 0 || !target.ok) {
|
|
2565
|
+
socket.end("HTTP/1.1 404 Not Found\r\nconnection: close\r\n\r\n");
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
const upstreamUrl = new URL(target.url.href);
|
|
2569
|
+
const rest = explicit === null ? suffix : "";
|
|
2570
|
+
if (rest !== "") upstreamUrl.pathname = `${upstreamUrl.pathname.replace(/\/$/u, "")}/${rest.replace(/^\//u, "")}`;
|
|
2571
|
+
const port = upstreamUrl.port === "" ? upstreamUrl.protocol === "https:" ? 443 : 80 : Number(upstreamUrl.port);
|
|
2572
|
+
const upstream = connect({
|
|
2573
|
+
host: upstreamUrl.hostname.replace(/^\[|\]$/gu, ""),
|
|
2574
|
+
port
|
|
2575
|
+
});
|
|
2576
|
+
const close = () => {
|
|
2577
|
+
upstream.destroy();
|
|
2578
|
+
socket.destroy();
|
|
2579
|
+
};
|
|
2580
|
+
upstream.on("error", close);
|
|
2581
|
+
socket.on("error", close);
|
|
2582
|
+
upstream.on("connect", () => {
|
|
2583
|
+
const headers = {
|
|
2584
|
+
...req.headers,
|
|
2585
|
+
host: upstreamUrl.host
|
|
2586
|
+
};
|
|
2587
|
+
const lines = [`${req.method ?? "GET"} ${upstreamUrl.pathname}${upstreamUrl.search} HTTP/1.1`];
|
|
2588
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
2589
|
+
if (value === void 0) continue;
|
|
2590
|
+
const lowered = name.toLowerCase();
|
|
2591
|
+
if (lowered === "cookie") {
|
|
2592
|
+
const kept = withoutHostAuthCookies(value);
|
|
2593
|
+
if (kept !== void 0) lines.push(`cookie: ${kept}`);
|
|
2594
|
+
continue;
|
|
2595
|
+
}
|
|
2596
|
+
if (HOP_BY_HOP.has(lowered) && !UPGRADE_HANDSHAKE_HEADERS.has(lowered)) continue;
|
|
2597
|
+
if (Array.isArray(value)) for (const one of value) lines.push(`${name}: ${one}`);
|
|
2598
|
+
else lines.push(`${name}: ${value}`);
|
|
2599
|
+
}
|
|
2600
|
+
upstream.write(`${lines.join("\r\n")}\r\n\r\n`);
|
|
2601
|
+
if (head.byteLength > 0) upstream.write(head);
|
|
2602
|
+
upstream.pipe(socket);
|
|
2603
|
+
socket.pipe(upstream);
|
|
2604
|
+
});
|
|
2605
|
+
}
|
|
2606
|
+
/**
|
|
2607
|
+
* One upstream request, with the response streamed straight through.
|
|
2608
|
+
* @param req - the client request.
|
|
2609
|
+
* @param res - the client response.
|
|
2610
|
+
* @param upstreamUrl - the absolute loopback URL to fetch.
|
|
2611
|
+
*/
|
|
2612
|
+
forward(req, res, upstreamUrl) {
|
|
2613
|
+
const headers = {};
|
|
2614
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
2615
|
+
if (value === void 0) continue;
|
|
2616
|
+
const lowered = name.toLowerCase();
|
|
2617
|
+
if (HOP_BY_HOP.has(lowered) || REWRITTEN_REQUEST_HEADERS.has(lowered)) continue;
|
|
2618
|
+
if (lowered === "cookie") {
|
|
2619
|
+
const kept = withoutHostAuthCookies(value);
|
|
2620
|
+
if (kept !== void 0) headers[name] = kept;
|
|
2621
|
+
continue;
|
|
2622
|
+
}
|
|
2623
|
+
headers[name] = value;
|
|
2624
|
+
}
|
|
2625
|
+
headers.host = upstreamUrl.host;
|
|
2626
|
+
headers.accept = typeof req.headers.accept === "string" ? req.headers.accept : "*/*";
|
|
2627
|
+
const upstreamReq = (upstreamUrl.protocol === "https:" ? request$1 : request)({
|
|
2628
|
+
protocol: upstreamUrl.protocol,
|
|
2629
|
+
hostname: upstreamUrl.hostname.replace(/^\[|\]$/gu, ""),
|
|
2630
|
+
port: upstreamUrl.port === "" ? void 0 : Number(upstreamUrl.port),
|
|
2631
|
+
path: `${upstreamUrl.pathname}${upstreamUrl.search}`,
|
|
2632
|
+
method: req.method ?? "GET",
|
|
2633
|
+
headers
|
|
2634
|
+
}, (upstreamRes) => {
|
|
2635
|
+
const contentType = String(upstreamRes.headers["content-type"] ?? "");
|
|
2636
|
+
const streaming = contentType.includes("text/event-stream");
|
|
2637
|
+
res.setHeader("cache-control", streaming ? "no-cache" : "no-store");
|
|
2638
|
+
res.setHeader("x-content-type-options", "nosniff");
|
|
2639
|
+
res.setHeader("content-encoding", "identity");
|
|
2640
|
+
let injected;
|
|
2641
|
+
if (contentType.startsWith("text/html") && req.method !== "HEAD") {
|
|
2642
|
+
const chunks = [];
|
|
2643
|
+
upstreamRes.on("data", (chunk) => {
|
|
2644
|
+
chunks.push(chunk);
|
|
2645
|
+
});
|
|
2646
|
+
upstreamRes.on("end", () => {
|
|
2647
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
2648
|
+
const base = baseOf(text) ?? `${PROXY_ROUTE}?url=${encodeQuery(upstreamUrl.href)}`;
|
|
2649
|
+
injected = Buffer.from(injectBase(text, base), "utf8");
|
|
2650
|
+
res.setHeader("content-length", String(injected.byteLength));
|
|
2651
|
+
res.writeHead(upstreamRes.statusCode ?? 502, forwardHeaders(upstreamRes));
|
|
2652
|
+
res.end(injected);
|
|
2653
|
+
});
|
|
2654
|
+
upstreamRes.on("error", () => {
|
|
2655
|
+
res.destroy();
|
|
2656
|
+
});
|
|
2657
|
+
upstreamReq.on("error", () => {
|
|
2658
|
+
if (!res.headersSent) send(res, 502, "text/plain; charset=utf-8", "the upstream dev server closed the connection");
|
|
2659
|
+
});
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2662
|
+
for (const [name, value] of Object.entries(upstreamRes.headers)) {
|
|
2663
|
+
if (value === void 0 || HOP_BY_HOP.has(name.toLowerCase())) continue;
|
|
2664
|
+
if (name.toLowerCase() === "set-cookie") {
|
|
2665
|
+
const kept = withoutHostAuthSetCookies(value);
|
|
2666
|
+
if (kept.length > 0) res.setHeader(name, kept);
|
|
2667
|
+
continue;
|
|
2668
|
+
}
|
|
2669
|
+
if (name.toLowerCase() === "content-type" && isTextual(String(value))) {
|
|
2670
|
+
res.setHeader(name, String(value).includes("charset") ? String(value) : `${String(value)}; charset=utf-8`);
|
|
2671
|
+
continue;
|
|
2672
|
+
}
|
|
2673
|
+
res.setHeader(name, value);
|
|
2674
|
+
}
|
|
2675
|
+
res.writeHead(upstreamRes.statusCode ?? 502);
|
|
2676
|
+
upstreamRes.pipe(res);
|
|
2677
|
+
upstreamRes.on("error", () => {
|
|
2678
|
+
res.destroy();
|
|
2679
|
+
});
|
|
2680
|
+
});
|
|
2681
|
+
const timeout = this.source().previewProxyTimeoutMs;
|
|
2682
|
+
upstreamReq.setTimeout(timeout, () => {
|
|
2683
|
+
upstreamReq.destroy(/* @__PURE__ */ new Error(`the upstream dev server did not answer within ${String(timeout)}ms`));
|
|
2684
|
+
});
|
|
2685
|
+
upstreamReq.on("error", (error) => {
|
|
2686
|
+
if (res.headersSent) {
|
|
2687
|
+
res.destroy();
|
|
2688
|
+
return;
|
|
2689
|
+
}
|
|
2690
|
+
send(res, 502, "text/plain; charset=utf-8", `the preview proxy could not reach ${upstreamUrl.origin}: ${error.message}`);
|
|
2691
|
+
});
|
|
2692
|
+
const upstream = { abort: () => {
|
|
2693
|
+
upstreamReq.destroy();
|
|
2694
|
+
} };
|
|
2695
|
+
this.live.add(upstream);
|
|
2696
|
+
res.on("close", () => {
|
|
2697
|
+
this.live.delete(upstream);
|
|
2698
|
+
upstreamReq.destroy();
|
|
2699
|
+
});
|
|
2700
|
+
req.pipe(upstreamReq);
|
|
2701
|
+
}
|
|
2702
|
+
/**
|
|
2703
|
+
* Resolve a workspace and one path inside it.
|
|
2704
|
+
* @param workspacePath - absolute Host workspace directory, absent to treat the path itself as one.
|
|
2705
|
+
* @param path - absolute Host path, or a path relative to the workspace.
|
|
2706
|
+
* @param signal - cancellation for the resolution.
|
|
2707
|
+
* @returns the workspace, the contained target, and the path as it should be displayed.
|
|
2708
|
+
*/
|
|
2709
|
+
async resolve(workspacePath, path, signal) {
|
|
2710
|
+
if (path.trim() === "") return {
|
|
2711
|
+
ok: false,
|
|
2712
|
+
failure: {
|
|
2713
|
+
ok: false,
|
|
2714
|
+
code: "path-denied",
|
|
2715
|
+
message: "no path was given"
|
|
2716
|
+
}
|
|
2717
|
+
};
|
|
2718
|
+
const workspace = await resolveWorkspace(this.ctx, workspacePath ?? path, signal);
|
|
2719
|
+
if (!workspace.ok) return {
|
|
2720
|
+
ok: false,
|
|
2721
|
+
failure: {
|
|
2722
|
+
ok: false,
|
|
2723
|
+
code: workspace.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied",
|
|
2724
|
+
message: workspace.rejection.message
|
|
2725
|
+
}
|
|
2726
|
+
};
|
|
2727
|
+
if (workspacePath === void 0) {
|
|
2728
|
+
const refused$1 = await this.outsideRegisteredWorkspaces(workspace.value.target, path, signal);
|
|
2729
|
+
if (refused$1 !== void 0) return {
|
|
2730
|
+
ok: false,
|
|
2731
|
+
failure: refused$1
|
|
2732
|
+
};
|
|
2733
|
+
return {
|
|
2734
|
+
ok: true,
|
|
2735
|
+
workspace: workspace.value,
|
|
2736
|
+
target: workspace.value.target,
|
|
2737
|
+
display: path
|
|
2738
|
+
};
|
|
2739
|
+
}
|
|
2740
|
+
const inside = await resolveInside(this.ctx, workspace.value, path, signal);
|
|
2741
|
+
if (!inside.ok) return {
|
|
2742
|
+
ok: false,
|
|
2743
|
+
failure: {
|
|
2744
|
+
ok: false,
|
|
2745
|
+
code: inside.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied",
|
|
2746
|
+
message: inside.rejection.message
|
|
2747
|
+
}
|
|
2748
|
+
};
|
|
2749
|
+
const refused = await this.outsideRegisteredWorkspaces(inside.value.target, path, signal);
|
|
2750
|
+
if (refused !== void 0) return {
|
|
2751
|
+
ok: false,
|
|
2752
|
+
failure: refused
|
|
2753
|
+
};
|
|
2754
|
+
return {
|
|
2755
|
+
ok: true,
|
|
2756
|
+
workspace: workspace.value,
|
|
2757
|
+
target: inside.value.target,
|
|
2758
|
+
display: inside.value.processPath
|
|
2759
|
+
};
|
|
2760
|
+
}
|
|
2761
|
+
/**
|
|
2762
|
+
* Refuse a target that sits inside none of the harness's registered workspaces.
|
|
2763
|
+
*
|
|
2764
|
+
* The workspace a request names is only the root its relative paths hang from; it is not evidence
|
|
2765
|
+
* that the directory is one the operator opened. `?workspace=/` would otherwise make the whole disk
|
|
2766
|
+
* a workspace, so the canonical target is checked against the registry's own canonical paths — the
|
|
2767
|
+
* list the sidebar shows, and nothing a request can add to. A subdirectory of a registered
|
|
2768
|
+
* workspace passes, because the file is still inside what the operator opened.
|
|
2769
|
+
*
|
|
2770
|
+
* Each registered path is resolved through the same filesystem as the target, so a symlinked
|
|
2771
|
+
* workspace compares as its real directory on both sides. A registered directory that no longer
|
|
2772
|
+
* resolves (deleted, unmounted) simply contains nothing.
|
|
2773
|
+
* @param target - the canonical target the request resolved to.
|
|
2774
|
+
* @param path - the path as asked, for the refusal message.
|
|
2775
|
+
* @param signal - cancellation for the resolutions.
|
|
2776
|
+
* @returns the refusal, or undefined when a registered workspace contains the target.
|
|
2777
|
+
*/
|
|
2778
|
+
async outsideRegisteredWorkspaces(target, path, signal) {
|
|
2779
|
+
const fs = this.ctx.get("fs");
|
|
2780
|
+
/* v8 ignore next 3 -- the caller resolved the target through the same service moments earlier. */
|
|
2781
|
+
if (fs === void 0) return {
|
|
2782
|
+
ok: false,
|
|
2783
|
+
code: "no-filesystem",
|
|
2784
|
+
message: "filesystem capability withdrawn mid-request"
|
|
2785
|
+
};
|
|
2786
|
+
const registry = this.ctx.get("workspaceRegistry");
|
|
2787
|
+
if (registry === void 0) return {
|
|
2788
|
+
ok: false,
|
|
2789
|
+
code: "path-denied",
|
|
2790
|
+
message: "no workspace registry is mounted, so no preview file can be proved to belong to a workspace"
|
|
2791
|
+
};
|
|
2792
|
+
const registered = registry.list().map((workspace) => workspace.path);
|
|
2793
|
+
if ((await Promise.allSettled(registered.map((root) => fs.resolve(root, signal === void 0 ? {} : { signal })))).some((root) => root.status === "fulfilled" && fs.contains(root.value, target))) return void 0;
|
|
2794
|
+
return {
|
|
2795
|
+
ok: false,
|
|
2796
|
+
code: "path-denied",
|
|
2797
|
+
message: `${path} is outside every registered workspace`
|
|
2798
|
+
};
|
|
2799
|
+
}
|
|
2800
|
+
/**
|
|
2801
|
+
* Describe one already-resolved file.
|
|
2802
|
+
* @param workspace - the resolved workspace.
|
|
2803
|
+
* @param target - the resolved target.
|
|
2804
|
+
* @param display - the path to report back and to base the content type on.
|
|
2805
|
+
* @param signal - cancellation for the metadata and token reads.
|
|
2806
|
+
* @returns the description.
|
|
2807
|
+
*/
|
|
2808
|
+
async describeFile(workspace, target, display, signal) {
|
|
2809
|
+
const fs = this.ctx.get("fs");
|
|
2810
|
+
/* v8 ignore next -- the caller resolved the workspace through the same service moments earlier. */
|
|
2811
|
+
if (fs === void 0) return {
|
|
2812
|
+
ok: false,
|
|
2813
|
+
code: "no-filesystem",
|
|
2814
|
+
message: "filesystem capability withdrawn mid-request"
|
|
2815
|
+
};
|
|
2816
|
+
const stat = await fs.stat(target, signal);
|
|
2817
|
+
if (stat === void 0 || stat.type !== "file") return {
|
|
2818
|
+
ok: false,
|
|
2819
|
+
code: "not-a-file",
|
|
2820
|
+
message: `${display} is not a regular file`
|
|
2821
|
+
};
|
|
2822
|
+
const size = stat.size ?? 0;
|
|
2823
|
+
const contentType = contentTypeOf(display);
|
|
2824
|
+
const kind = classifyFile(display, contentType);
|
|
2825
|
+
const name = display.slice(display.lastIndexOf("/") + 1);
|
|
2826
|
+
const url = kind === "other" || !this.info().available ? void 0 : fileUrl(FILE_ROUTE, workspace.processPath, display);
|
|
2827
|
+
return {
|
|
2828
|
+
ok: true,
|
|
2829
|
+
path: display,
|
|
2830
|
+
name,
|
|
2831
|
+
kind,
|
|
2832
|
+
contentType,
|
|
2833
|
+
bytes: size,
|
|
2834
|
+
withinLimit: size <= this.source().previewMaxFileBytes,
|
|
2835
|
+
...url === void 0 ? {} : { url },
|
|
2836
|
+
token: await this.etag(target, stat.version, size, stat.size, signal),
|
|
2837
|
+
regular: true
|
|
2838
|
+
};
|
|
2839
|
+
}
|
|
2840
|
+
/**
|
|
2841
|
+
* A weak validator for one file: the backend's version, its size, and a digest of its head.
|
|
2842
|
+
* @param target - the resolved target.
|
|
2843
|
+
* @param version - the backend's opaque freshness token.
|
|
2844
|
+
* @param size - the byte size; `undefined` asks for no probe read.
|
|
2845
|
+
* @param reportedSize - the size the backend reported, which may be absent.
|
|
2846
|
+
* @param signal - cancellation for the probe read.
|
|
2847
|
+
* @returns the ETag body, quotes included.
|
|
2848
|
+
*/
|
|
2849
|
+
async etag(target, version, size, reportedSize, signal) {
|
|
2850
|
+
const hash = createHash("sha1");
|
|
2851
|
+
hash.update(String(version));
|
|
2852
|
+
hash.update(`:${String(size)}:${String(reportedSize ?? "")}`);
|
|
2853
|
+
const fs = this.ctx.get("fs");
|
|
2854
|
+
if (fs !== void 0 && size > 0) try {
|
|
2855
|
+
const window = Math.min(size, TOKEN_PROBE_BYTES);
|
|
2856
|
+
const head = hasRangeRead(fs) ? await fs.readByteRange(target, {
|
|
2857
|
+
offset: 0,
|
|
2858
|
+
length: window
|
|
2859
|
+
}, signal) : await fs.readBytes(target, signal, window);
|
|
2860
|
+
hash.update(head);
|
|
2861
|
+
} catch {}
|
|
2862
|
+
return `W/"${hash.digest("hex").slice(0, 32)}"`;
|
|
2863
|
+
}
|
|
2864
|
+
/**
|
|
2865
|
+
* The target one subresource request should be forwarded to.
|
|
2866
|
+
* @param req - the subresource request.
|
|
2867
|
+
* @param suffix - the path below the proxy route, without a leading slash.
|
|
2868
|
+
* @returns the validated target, or undefined when this Host holds none for the caller.
|
|
2869
|
+
*/
|
|
2870
|
+
fallbackTarget(req, suffix) {
|
|
2871
|
+
const client = req.headers["x-dsh-preview-client"];
|
|
2872
|
+
const key = typeof client === "string" && client !== "" ? client : void 0;
|
|
2873
|
+
const known = key === void 0 ? void 0 : this.targets.get(key);
|
|
2874
|
+
if (known !== void 0) {
|
|
2875
|
+
const parsed = validateProxyTarget(known);
|
|
2876
|
+
if (parsed.ok) return parsed;
|
|
2877
|
+
}
|
|
2878
|
+
if (this.targets.size === 1) {
|
|
2879
|
+
const only = [...this.targets.values()][0];
|
|
2880
|
+
/* v8 ignore next -- `size === 1` guarantees the element exists. */
|
|
2881
|
+
if (only !== void 0) {
|
|
2882
|
+
const parsed = validateProxyTarget(only);
|
|
2883
|
+
if (parsed.ok) return parsed;
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
/**
|
|
2888
|
+
* Record the target one panel is framing, so its subresources resolve.
|
|
2889
|
+
* @param clientId - the panel's id.
|
|
2890
|
+
* @param target - the upstream URL, or undefined when the panel stopped framing one.
|
|
2891
|
+
*/
|
|
2892
|
+
rememberTarget(clientId, target) {
|
|
2893
|
+
if (target === void 0) this.targets.delete(clientId);
|
|
2894
|
+
else this.targets.set(clientId, target);
|
|
2895
|
+
}
|
|
2896
|
+
};
|
|
2897
|
+
/**
|
|
2898
|
+
* The path below the proxy route, without a leading slash.
|
|
2899
|
+
* @param pathname - the request's pathname.
|
|
2900
|
+
* @returns the suffix, or the empty string at the route's own root.
|
|
2901
|
+
*/
|
|
2902
|
+
function suffixOf(pathname) {
|
|
2903
|
+
return pathname.slice(PROXY_ROUTE.length).replace(/^\/+/u, "");
|
|
2904
|
+
}
|
|
2905
|
+
/**
|
|
2906
|
+
* The `<base href>` a proxied document already declares, when it declares one.
|
|
2907
|
+
*
|
|
2908
|
+
* A dev-server framework frequently injects its own base tag (`vite` does not, `next` does), and a
|
|
2909
|
+
* second one would be ignored by the parser in favour of the first. Reading it back means this
|
|
2910
|
+
* proxy either keeps the document's own answer or supplies one, never both.
|
|
2911
|
+
* @param html - the document text.
|
|
2912
|
+
* @returns the declared href, or undefined.
|
|
2913
|
+
*/
|
|
2914
|
+
function baseOf(html) {
|
|
2915
|
+
const match = /<base\s[^>]*href\s*=\s*("([^"]*)"|'([^']*)')/iu.exec(html);
|
|
2916
|
+
if (match === null) return void 0;
|
|
2917
|
+
return match[2] ?? match[3];
|
|
2918
|
+
}
|
|
2919
|
+
/**
|
|
2920
|
+
* Upstream headers minus the hop-by-hop set, for the buffered HTML branch.
|
|
2921
|
+
* @param res - the upstream response.
|
|
2922
|
+
* @returns the headers to copy.
|
|
2923
|
+
*/
|
|
2924
|
+
function forwardHeaders(res) {
|
|
2925
|
+
const headers = {};
|
|
2926
|
+
for (const [name, value] of Object.entries(res.headers)) {
|
|
2927
|
+
if (value === void 0 || HOP_BY_HOP.has(name.toLowerCase()) || name.toLowerCase() === "content-length") continue;
|
|
2928
|
+
if (name.toLowerCase() === "set-cookie") {
|
|
2929
|
+
const kept = withoutHostAuthSetCookies(value);
|
|
2930
|
+
if (kept.length > 0) headers[name] = [...kept];
|
|
2931
|
+
continue;
|
|
2932
|
+
}
|
|
2933
|
+
headers[name] = value;
|
|
2934
|
+
}
|
|
2935
|
+
return headers;
|
|
2936
|
+
}
|
|
2937
|
+
/**
|
|
2938
|
+
* Answer a refused HTTP request, when the gate refuses it.
|
|
2939
|
+
*
|
|
2940
|
+
* The body is one fixed word: the gate's reasons (which authority, which cookie) are exactly what a
|
|
2941
|
+
* probing client wants, and the GUI's own browser never sees this answer.
|
|
2942
|
+
* @param gate - the connection's request gate.
|
|
2943
|
+
* @param req - the incoming request.
|
|
2944
|
+
* @param res - the response the handler owns.
|
|
2945
|
+
* @returns true when the request was refused and answered.
|
|
2946
|
+
*/
|
|
2947
|
+
function refuseHttp(gate, req, res) {
|
|
2948
|
+
const rejection = gate(req);
|
|
2949
|
+
if (rejection === void 0) return false;
|
|
2950
|
+
send(res, rejection, "text/plain; charset=utf-8", rejectionText(rejection));
|
|
2951
|
+
return true;
|
|
2952
|
+
}
|
|
2953
|
+
/**
|
|
2954
|
+
* Close a refused upgrade with a well-formed HTTP answer, when the gate refuses it.
|
|
2955
|
+
*
|
|
2956
|
+
* Written as a complete response, the way the harness's own Typert websocket refuses one, so a
|
|
2957
|
+
* client sees `401`/`403` rather than a reset it would retry.
|
|
2958
|
+
* @param gate - the connection's request gate.
|
|
2959
|
+
* @param req - the upgrade request.
|
|
2960
|
+
* @param socket - the client socket the handler owns.
|
|
2961
|
+
* @returns true when the upgrade was refused and the socket closed.
|
|
2962
|
+
*/
|
|
2963
|
+
function refuseUpgrade(gate, req, socket) {
|
|
2964
|
+
const rejection = gate(req);
|
|
2965
|
+
if (rejection === void 0) return false;
|
|
2966
|
+
const body = rejectionText(rejection);
|
|
2967
|
+
socket.end([
|
|
2968
|
+
`HTTP/1.1 ${String(rejection)} ${rejection === 401 ? "Unauthorized" : "Forbidden"}`,
|
|
2969
|
+
"connection: close",
|
|
2970
|
+
"content-type: text/plain; charset=utf-8",
|
|
2971
|
+
`content-length: ${String(Buffer.byteLength(body))}`,
|
|
2972
|
+
"",
|
|
2973
|
+
body
|
|
2974
|
+
].join("\r\n"));
|
|
2975
|
+
return true;
|
|
2976
|
+
}
|
|
2977
|
+
/**
|
|
2978
|
+
* The body a refusal carries.
|
|
2979
|
+
* @param rejection - the gate's status.
|
|
2980
|
+
* @returns the word for it.
|
|
2981
|
+
*/
|
|
2982
|
+
function rejectionText(rejection) {
|
|
2983
|
+
switch (rejection) {
|
|
2984
|
+
case 401: return "unauthorized";
|
|
2985
|
+
case 403: return "forbidden";
|
|
2986
|
+
default: throw new TypeError(`advanced-sidebar: unexpected rejection ${String(rejection)}`);
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
/**
|
|
2990
|
+
* A `Cookie` request header minus the harness's own browser-session cookie.
|
|
2991
|
+
* @param header - the raw header; Node joins repeated `Cookie` headers into one string.
|
|
2992
|
+
* @returns the remaining pairs, or undefined when nothing remains.
|
|
2993
|
+
*/
|
|
2994
|
+
function withoutHostAuthCookies(header) {
|
|
2995
|
+
const kept = (typeof header === "string" ? header : header.join("; ")).split(";").map((pair) => pair.trim()).filter((pair) => pair !== "" && !pair.startsWith(HOST_AUTH_COOKIE_PREFIX));
|
|
2996
|
+
return kept.length === 0 ? void 0 : kept.join("; ");
|
|
2997
|
+
}
|
|
2998
|
+
/**
|
|
2999
|
+
* Upstream `Set-Cookie` values minus any that would replace the harness's own session cookie.
|
|
3000
|
+
* @param header - one value or the list Node collects.
|
|
3001
|
+
* @returns the values a dev server may still set on this origin.
|
|
3002
|
+
*/
|
|
3003
|
+
function withoutHostAuthSetCookies(header) {
|
|
3004
|
+
return (typeof header === "string" ? [header] : header).filter((value) => !value.trimStart().startsWith(HOST_AUTH_COOKIE_PREFIX));
|
|
3005
|
+
}
|
|
3006
|
+
/**
|
|
3007
|
+
* Parse one `Range` header against a known size.
|
|
3008
|
+
*
|
|
3009
|
+
* Only `bytes=` and only a single range are honoured: a multipart range costs a multipart encoder to
|
|
3010
|
+
* serve, and the one caller that matters — a `<video>` scrubber — asks for one range at a time.
|
|
3011
|
+
* @param header - the raw header value.
|
|
3012
|
+
* @param size - the file's size in bytes.
|
|
3013
|
+
* @returns the range, undefined for a full response, or `unsatisfiable`.
|
|
3014
|
+
*/
|
|
3015
|
+
function parseRange(header, size) {
|
|
3016
|
+
if (header === void 0 || !header.startsWith("bytes=")) return void 0;
|
|
3017
|
+
const parts = (header.slice(6).split(",")[0]?.trim() ?? "").split("-");
|
|
3018
|
+
if (parts.length !== 2) return void 0;
|
|
3019
|
+
const [rawStart, rawEnd] = parts;
|
|
3020
|
+
const start = rawStart === void 0 || rawStart === "" ? void 0 : Number.parseInt(rawStart, 10);
|
|
3021
|
+
const end = rawEnd === void 0 || rawEnd === "" ? void 0 : Number.parseInt(rawEnd, 10);
|
|
3022
|
+
if (start !== void 0 && Number.isNaN(start)) return void 0;
|
|
3023
|
+
if (end !== void 0 && Number.isNaN(end)) return void 0;
|
|
3024
|
+
if (start === void 0 && end === void 0) return void 0;
|
|
3025
|
+
if (size === 0) return "unsatisfiable";
|
|
3026
|
+
if (start === void 0) {
|
|
3027
|
+
const length = end ?? 0;
|
|
3028
|
+
if (length <= 0) return "unsatisfiable";
|
|
3029
|
+
return {
|
|
3030
|
+
start: Math.max(0, size - length),
|
|
3031
|
+
end: size - 1
|
|
3032
|
+
};
|
|
3033
|
+
}
|
|
3034
|
+
if (start >= size) return "unsatisfiable";
|
|
3035
|
+
const last = end === void 0 ? size - 1 : Math.min(end, size - 1);
|
|
3036
|
+
if (last < start) return "unsatisfiable";
|
|
3037
|
+
return {
|
|
3038
|
+
start,
|
|
3039
|
+
end: last
|
|
3040
|
+
};
|
|
3041
|
+
}
|
|
3042
|
+
/**
|
|
3043
|
+
* Answer one request with a short, uncached body.
|
|
3044
|
+
* @param res - the response.
|
|
3045
|
+
* @param status - the HTTP status.
|
|
3046
|
+
* @param contentType - the MIME type.
|
|
3047
|
+
* @param body - the text to send.
|
|
3048
|
+
*/
|
|
3049
|
+
function send(res, status, contentType, body) {
|
|
3050
|
+
if (res.headersSent) {
|
|
3051
|
+
res.end();
|
|
3052
|
+
return;
|
|
3053
|
+
}
|
|
3054
|
+
res.setHeader("content-type", contentType);
|
|
3055
|
+
res.setHeader("cache-control", "no-store");
|
|
3056
|
+
res.setHeader("content-encoding", "identity");
|
|
3057
|
+
res.setHeader("content-length", String(Buffer.byteLength(body)));
|
|
3058
|
+
res.writeHead(status);
|
|
3059
|
+
res.end(body);
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
//#endregion
|
|
3063
|
+
//#region tsbuild/host/terminals.js
|
|
3064
|
+
/** Compose one classified failure. */
|
|
3065
|
+
function fail$1(code, message) {
|
|
3066
|
+
return {
|
|
3067
|
+
ok: false,
|
|
3068
|
+
code,
|
|
3069
|
+
message
|
|
3070
|
+
};
|
|
3071
|
+
}
|
|
3072
|
+
/** Terminal geometry the substrate will accept, whatever the panel measured. */
|
|
3073
|
+
function clampGeometry(cols, rows) {
|
|
3074
|
+
const bound = (value, low, high) => Number.isFinite(value) ? Math.min(Math.max(Math.round(value), low), high) : low;
|
|
3075
|
+
return {
|
|
3076
|
+
cols: bound(cols, 20, 500),
|
|
3077
|
+
rows: bound(rows, 5, 200)
|
|
3078
|
+
};
|
|
3079
|
+
}
|
|
3080
|
+
/**
|
|
3081
|
+
* Owns every panel terminal in the process. One instance is created by the service and disposed
|
|
3082
|
+
* with it, which is what guarantees no shell outlives the plugin.
|
|
3083
|
+
*/
|
|
3084
|
+
var PanelTerminals = class {
|
|
3085
|
+
ctx;
|
|
3086
|
+
source;
|
|
3087
|
+
records = /* @__PURE__ */ new Map();
|
|
3088
|
+
closing = false;
|
|
3089
|
+
/**
|
|
3090
|
+
* @param ctx - Host context carrying the subprocess and filesystem capabilities.
|
|
3091
|
+
* @param source - reads the current settings section; called per request.
|
|
3092
|
+
*/
|
|
3093
|
+
constructor(ctx, source) {
|
|
3094
|
+
this.ctx = ctx;
|
|
3095
|
+
this.source = source;
|
|
3096
|
+
}
|
|
3097
|
+
/**
|
|
3098
|
+
* Report whether a panel terminal can be allocated on this Host.
|
|
3099
|
+
* @returns availability plus the shell that would answer.
|
|
3100
|
+
*/
|
|
3101
|
+
describe() {
|
|
3102
|
+
if (this.ctx.get("subprocess") === void 0) return {
|
|
3103
|
+
available: false,
|
|
3104
|
+
reason: "no subprocess capability is mounted: this deployment composes no @deepseek-ai/dsh-subprocess provider"
|
|
3105
|
+
};
|
|
3106
|
+
if (this.ctx.get("fs") === void 0) return {
|
|
3107
|
+
available: false,
|
|
3108
|
+
reason: "no filesystem capability is mounted: the terminal has no way to resolve its working directory"
|
|
3109
|
+
};
|
|
3110
|
+
return {
|
|
3111
|
+
available: true,
|
|
3112
|
+
detail: this.shell()
|
|
3113
|
+
};
|
|
3114
|
+
}
|
|
3115
|
+
/**
|
|
3116
|
+
* Allocate one terminal in a workspace.
|
|
3117
|
+
* @param request - the directory and the panel's measured geometry.
|
|
3118
|
+
* @param signal - cancellation of the allocation; a published terminal owns its later lifetime.
|
|
3119
|
+
* @returns the handle, or a classified failure.
|
|
3120
|
+
*/
|
|
3121
|
+
async open(request$2, signal) {
|
|
3122
|
+
if (this.closing) return fail$1("closed", "the plugin is unloading");
|
|
3123
|
+
const subprocess = this.ctx.get("subprocess");
|
|
3124
|
+
if (subprocess === void 0) return fail$1("no-subprocess", "no subprocess capability is mounted");
|
|
3125
|
+
const settings = this.source();
|
|
3126
|
+
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`);
|
|
3127
|
+
const workspace = await resolveWorkspace(this.ctx, request$2.workspacePath, signal);
|
|
3128
|
+
if (!workspace.ok) return fail$1(workspace.rejection.code === "no-filesystem" ? "no-filesystem" : "path-denied", workspace.rejection.message);
|
|
3129
|
+
const shell = this.shell();
|
|
3130
|
+
const { cols, rows } = clampGeometry(request$2.cols, request$2.rows);
|
|
3131
|
+
let handle;
|
|
3132
|
+
try {
|
|
3133
|
+
handle = await subprocess.spawnTerminal({
|
|
3134
|
+
argv: [shell],
|
|
3135
|
+
cwd: workspace.value.processPath,
|
|
3136
|
+
rows,
|
|
3137
|
+
cols,
|
|
3138
|
+
graceMs: settings.terminalGraceMs,
|
|
3139
|
+
signal,
|
|
3140
|
+
env: {
|
|
3141
|
+
TERM: "xterm-256color",
|
|
3142
|
+
COLUMNS: String(cols),
|
|
3143
|
+
LINES: String(rows)
|
|
3144
|
+
}
|
|
2127
3145
|
});
|
|
2128
3146
|
} catch (error) {
|
|
2129
3147
|
return fail$1("spawn-failed", error instanceof Error ? error.message : String(error));
|
|
@@ -2154,11 +3172,11 @@ var PanelTerminals = class {
|
|
|
2154
3172
|
* @param request - the handle and the offset already rendered.
|
|
2155
3173
|
* @returns the delta and the process state, or a classified failure.
|
|
2156
3174
|
*/
|
|
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}`);
|
|
3175
|
+
read(request$2) {
|
|
3176
|
+
const record = this.records.get(request$2.terminalId);
|
|
3177
|
+
if (record === void 0) return fail$1("unknown-terminal", `no panel terminal ${request$2.terminalId}`);
|
|
2160
3178
|
const total = record.base + record.buffer.length;
|
|
2161
|
-
const from = Number.isFinite(request.fromOffset) ? Math.max(0, Math.floor(request.fromOffset)) : 0;
|
|
3179
|
+
const from = Number.isFinite(request$2.fromOffset) ? Math.max(0, Math.floor(request$2.fromOffset)) : 0;
|
|
2162
3180
|
const lossy = from < record.base;
|
|
2163
3181
|
const text = lossy ? record.buffer : record.buffer.slice(Math.min(from - record.base, record.buffer.length));
|
|
2164
3182
|
return {
|
|
@@ -2179,12 +3197,12 @@ var PanelTerminals = class {
|
|
|
2179
3197
|
* @param request - the handle and the text to deliver verbatim.
|
|
2180
3198
|
* @returns settlement, or a classified failure.
|
|
2181
3199
|
*/
|
|
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}`);
|
|
3200
|
+
async write(request$2) {
|
|
3201
|
+
const record = this.records.get(request$2.terminalId);
|
|
3202
|
+
if (record === void 0) return fail$1("unknown-terminal", `no panel terminal ${request$2.terminalId}`);
|
|
2185
3203
|
if (!record.running) return fail$1("unknown-terminal", `panel terminal ${record.id} has exited`);
|
|
2186
3204
|
try {
|
|
2187
|
-
await record.handle.write(request.data);
|
|
3205
|
+
await record.handle.write(request$2.data);
|
|
2188
3206
|
} catch (error) {
|
|
2189
3207
|
return fail$1("spawn-failed", error instanceof Error ? error.message : String(error));
|
|
2190
3208
|
}
|
|
@@ -2195,12 +3213,12 @@ var PanelTerminals = class {
|
|
|
2195
3213
|
* @param request - the handle and the signal.
|
|
2196
3214
|
* @returns settlement, or a classified failure.
|
|
2197
3215
|
*/
|
|
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}`);
|
|
3216
|
+
async signal(request$2) {
|
|
3217
|
+
const record = this.records.get(request$2.terminalId);
|
|
3218
|
+
if (record === void 0) return fail$1("unknown-terminal", `no panel terminal ${request$2.terminalId}`);
|
|
2201
3219
|
if (!record.running) return fail$1("unknown-terminal", `panel terminal ${record.id} has exited`);
|
|
2202
3220
|
try {
|
|
2203
|
-
await record.handle.signalForeground(request.signal);
|
|
3221
|
+
await record.handle.signalForeground(request$2.signal);
|
|
2204
3222
|
} catch {}
|
|
2205
3223
|
return { ok: true };
|
|
2206
3224
|
}
|
|
@@ -2209,9 +3227,9 @@ var PanelTerminals = class {
|
|
|
2209
3227
|
* @param request - the handle.
|
|
2210
3228
|
* @returns settlement, or a classified failure.
|
|
2211
3229
|
*/
|
|
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}`);
|
|
3230
|
+
async close(request$2) {
|
|
3231
|
+
const record = this.records.get(request$2.terminalId);
|
|
3232
|
+
if (record === void 0) return fail$1("unknown-terminal", `no panel terminal ${request$2.terminalId}`);
|
|
2215
3233
|
this.records.delete(record.id);
|
|
2216
3234
|
await terminateQuietly(record.handle);
|
|
2217
3235
|
return { ok: true };
|
|
@@ -2346,13 +3364,13 @@ var TaskController = class {
|
|
|
2346
3364
|
* @param request - the owning session and the task id.
|
|
2347
3365
|
* @returns what the registry did, or a classified failure.
|
|
2348
3366
|
*/
|
|
2349
|
-
async kill(request) {
|
|
3367
|
+
async kill(request$2) {
|
|
2350
3368
|
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);
|
|
3369
|
+
const bound = this.bind(request$2.sessionId, request$2.taskId);
|
|
2352
3370
|
if ("failure" in bound) return bound.failure;
|
|
2353
3371
|
try {
|
|
2354
3372
|
const outcome = bound.jobs.kill(bound.id, bound.agent, "stopped from the DeepSeek Harness sidebar");
|
|
2355
|
-
await this.absorb(request.sessionId, request.taskId);
|
|
3373
|
+
await this.absorb(request$2.sessionId, request$2.taskId);
|
|
2356
3374
|
return {
|
|
2357
3375
|
ok: true,
|
|
2358
3376
|
outcome
|
|
@@ -2366,9 +3384,9 @@ var TaskController = class {
|
|
|
2366
3384
|
* @param request - the owning session and the task id.
|
|
2367
3385
|
* @returns the accumulated output, or a classified failure.
|
|
2368
3386
|
*/
|
|
2369
|
-
async output(request) {
|
|
3387
|
+
async output(request$2) {
|
|
2370
3388
|
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);
|
|
3389
|
+
const bound = this.bind(request$2.sessionId, request$2.taskId);
|
|
2372
3390
|
if ("failure" in bound) return bound.failure;
|
|
2373
3391
|
let snapshot;
|
|
2374
3392
|
try {
|
|
@@ -2376,20 +3394,20 @@ var TaskController = class {
|
|
|
2376
3394
|
} catch (error) {
|
|
2377
3395
|
return fail("unknown-task", error instanceof Error ? error.message : String(error));
|
|
2378
3396
|
}
|
|
2379
|
-
const retained = this.collected.get(request.taskId) ?? "";
|
|
3397
|
+
const retained = this.collected.get(request$2.taskId) ?? "";
|
|
2380
3398
|
if (isLive(snapshot) || !snapshot.reported) return {
|
|
2381
3399
|
ok: true,
|
|
2382
|
-
taskId: request.taskId,
|
|
3400
|
+
taskId: request$2.taskId,
|
|
2383
3401
|
readable: retained !== "",
|
|
2384
3402
|
text: retained,
|
|
2385
3403
|
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
3404
|
};
|
|
2387
|
-
await this.absorb(request.sessionId, request.taskId);
|
|
3405
|
+
await this.absorb(request$2.sessionId, request$2.taskId);
|
|
2388
3406
|
return {
|
|
2389
3407
|
ok: true,
|
|
2390
|
-
taskId: request.taskId,
|
|
3408
|
+
taskId: request$2.taskId,
|
|
2391
3409
|
readable: true,
|
|
2392
|
-
text: this.collected.get(request.taskId) ?? ""
|
|
3410
|
+
text: this.collected.get(request$2.taskId) ?? ""
|
|
2393
3411
|
};
|
|
2394
3412
|
}
|
|
2395
3413
|
/** Drop retained output. Called from the service's teardown effect. */
|
|
@@ -2432,6 +3450,388 @@ var TaskController = class {
|
|
|
2432
3450
|
}
|
|
2433
3451
|
};
|
|
2434
3452
|
|
|
3453
|
+
//#endregion
|
|
3454
|
+
//#region tsbuild/host/ui-bridge.js
|
|
3455
|
+
/**
|
|
3456
|
+
* How many commands one panel may have queued without polling.
|
|
3457
|
+
*
|
|
3458
|
+
* A person presses one button at a time and a model issues one tool call at a time, so a backlog
|
|
3459
|
+
* past a handful is a panel that left; the cap is what turns that into a refusal instead of growth.
|
|
3460
|
+
*/
|
|
3461
|
+
const QUEUE_CAP = 32;
|
|
3462
|
+
/**
|
|
3463
|
+
* How many console entries are retained per panel.
|
|
3464
|
+
*
|
|
3465
|
+
* A dev server's client bundle can log per animation frame; the retained window is what a model
|
|
3466
|
+
* reads back, and the cursor makes the loss visible rather than silent.
|
|
3467
|
+
*/
|
|
3468
|
+
const CONSOLE_CAP = 1e3;
|
|
3469
|
+
/**
|
|
3470
|
+
* How many characters of one console line are kept. */
|
|
3471
|
+
const CONSOLE_LINE_CAP = 8192;
|
|
3472
|
+
/**
|
|
3473
|
+
* The result kind each command kind must answer with.
|
|
3474
|
+
*
|
|
3475
|
+
* A panel that answered a `dom` command with an `ack` would leave the model reading a sentence where
|
|
3476
|
+
* it asked for markup, and nothing else in the chain would notice. The table is what turns that into
|
|
3477
|
+
* a reported failure instead of a confusing one, and it is exhaustive over the command union so a
|
|
3478
|
+
* new kind cannot be added without deciding what it returns.
|
|
3479
|
+
*/
|
|
3480
|
+
const EXPECTED_RESULT = {
|
|
3481
|
+
open: "ack",
|
|
3482
|
+
dom: "dom",
|
|
3483
|
+
eval: "eval",
|
|
3484
|
+
console: "console",
|
|
3485
|
+
click: "ack",
|
|
3486
|
+
input: "ack",
|
|
3487
|
+
reload: "ack",
|
|
3488
|
+
resize: "ack",
|
|
3489
|
+
close: "ack"
|
|
3490
|
+
};
|
|
3491
|
+
/**
|
|
3492
|
+
* The command queue, the panel registry, and the console buffer.
|
|
3493
|
+
*
|
|
3494
|
+
* Held by the Host service and disposed with it, so no timer survives an unload.
|
|
3495
|
+
*/
|
|
3496
|
+
var PreviewBindings = class {
|
|
3497
|
+
options;
|
|
3498
|
+
panels = /* @__PURE__ */ new Map();
|
|
3499
|
+
/**
|
|
3500
|
+
* Panels that said they were done, so a later bind cannot resurrect them.
|
|
3501
|
+
*
|
|
3502
|
+
* A closed dock's queued work must fail, and "closed" is a fact only the browser knows: without
|
|
3503
|
+
* this, a bind request the Host never asked for would put a dead tab back on the roster and the
|
|
3504
|
+
* model would wait on it. A fresh mount generates a fresh client id, so a reload is unaffected.
|
|
3505
|
+
*/
|
|
3506
|
+
retired = /* @__PURE__ */ new Set();
|
|
3507
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
3508
|
+
closed = false;
|
|
3509
|
+
/**
|
|
3510
|
+
* @param options - reads the current deadlines; called per operation so an edited settings
|
|
3511
|
+
* section reaches the next command with no registration to rebuild.
|
|
3512
|
+
*/
|
|
3513
|
+
constructor(options) {
|
|
3514
|
+
this.options = options;
|
|
3515
|
+
}
|
|
3516
|
+
/** How many panels have polled recently enough to be considered present. */
|
|
3517
|
+
get livePanels() {
|
|
3518
|
+
return [...this.panels.values()].filter((panel) => this.isLive(panel)).length;
|
|
3519
|
+
}
|
|
3520
|
+
/**
|
|
3521
|
+
* Record a panel's state. Called by every poll, so it doubles as the liveness heartbeat.
|
|
3522
|
+
* @param bind - what the panel reported.
|
|
3523
|
+
* @returns the panel's id, for a caller that wants to address it later.
|
|
3524
|
+
*/
|
|
3525
|
+
bind(bind) {
|
|
3526
|
+
const existing = this.panels.get(bind.clientId);
|
|
3527
|
+
if (existing === void 0) {
|
|
3528
|
+
this.panels.set(bind.clientId, {
|
|
3529
|
+
clientId: bind.clientId,
|
|
3530
|
+
sessionId: bind.sessionId,
|
|
3531
|
+
bind,
|
|
3532
|
+
polledAt: this.now(),
|
|
3533
|
+
queue: [],
|
|
3534
|
+
console: [],
|
|
3535
|
+
consoleBase: 0,
|
|
3536
|
+
consoleTotal: 0,
|
|
3537
|
+
controls: []
|
|
3538
|
+
});
|
|
3539
|
+
return bind.clientId;
|
|
3540
|
+
}
|
|
3541
|
+
existing.bind = bind;
|
|
3542
|
+
existing.polledAt = this.now();
|
|
3543
|
+
return bind.clientId;
|
|
3544
|
+
}
|
|
3545
|
+
/**
|
|
3546
|
+
* Take everything queued for one panel.
|
|
3547
|
+
*
|
|
3548
|
+
* The poll is also the heartbeat, so a stale panel is refreshed even when it has no work: what the
|
|
3549
|
+
* tool needs to know is that the tab is alive, not that it is busy.
|
|
3550
|
+
* @param clientId - the panel asking.
|
|
3551
|
+
* @param mounted - whether a preview surface is actually rendered; false leaves the queue alone.
|
|
3552
|
+
* @param console - entries the panel observed since its last report.
|
|
3553
|
+
* @returns the work to execute and the interval after which this poll is stale.
|
|
3554
|
+
*/
|
|
3555
|
+
poll(clientId, mounted, console = []) {
|
|
3556
|
+
const options = this.options();
|
|
3557
|
+
const panel = this.panels.get(clientId);
|
|
3558
|
+
if (panel === void 0) return {
|
|
3559
|
+
message: {
|
|
3560
|
+
commands: [],
|
|
3561
|
+
controls: []
|
|
3562
|
+
},
|
|
3563
|
+
bindTtlMs: options.bindTtlMs
|
|
3564
|
+
};
|
|
3565
|
+
panel.polledAt = this.now();
|
|
3566
|
+
this.appendConsole(panel, console);
|
|
3567
|
+
if (!mounted) {
|
|
3568
|
+
this.dropPanelWork(panel.clientId, "the preview panel is open but shows no preview surface");
|
|
3569
|
+
return {
|
|
3570
|
+
message: {
|
|
3571
|
+
commands: [],
|
|
3572
|
+
controls: []
|
|
3573
|
+
},
|
|
3574
|
+
bindTtlMs: options.bindTtlMs
|
|
3575
|
+
};
|
|
3576
|
+
}
|
|
3577
|
+
const commands = panel.queue.splice(0, panel.queue.length);
|
|
3578
|
+
for (const command of commands) {
|
|
3579
|
+
const record = this.inFlight.get(command.id);
|
|
3580
|
+
if (record !== void 0) record.delivered = true;
|
|
3581
|
+
}
|
|
3582
|
+
return {
|
|
3583
|
+
message: {
|
|
3584
|
+
commands,
|
|
3585
|
+
controls: panel.controls.splice(0, panel.controls.length)
|
|
3586
|
+
},
|
|
3587
|
+
bindTtlMs: options.bindTtlMs
|
|
3588
|
+
};
|
|
3589
|
+
}
|
|
3590
|
+
/**
|
|
3591
|
+
* A panel's state, or the panel itself when it has never reported in.
|
|
3592
|
+
*
|
|
3593
|
+
* This is what lets a tool call wake a dock that has not polled yet — the operator opens the
|
|
3594
|
+
* Preview panel and a model asks for a DOM reading in the same second. Only the FIRST bind is
|
|
3595
|
+
* stored without a poll behind it: once a panel is known, its liveness rules, so a closed tab
|
|
3596
|
+
* cannot be resurrected by a bind request it never sent.
|
|
3597
|
+
* @param bind - what the panel reported.
|
|
3598
|
+
* @returns nothing; the caller polls afterwards.
|
|
3599
|
+
*/
|
|
3600
|
+
bindAt(bind) {
|
|
3601
|
+
if (this.panels.has(bind.clientId) || this.retired.has(bind.clientId)) return;
|
|
3602
|
+
this.bind(bind);
|
|
3603
|
+
}
|
|
3604
|
+
/**
|
|
3605
|
+
* Record what one command did, and append any console lines it carried.
|
|
3606
|
+
*
|
|
3607
|
+
* A result whose command already timed out is accepted and dropped: the deadline fired, the tool
|
|
3608
|
+
* has its answer, and an operator's panel must not be told its report was invalid. A result of the
|
|
3609
|
+
* WRONG KIND is a different matter — it means the two halves disagree about the command, which is
|
|
3610
|
+
* a defect worth reporting rather than a late answer worth ignoring.
|
|
3611
|
+
* @param clientId - the panel reporting.
|
|
3612
|
+
* @param id - the command id.
|
|
3613
|
+
* @param outcome - success with a result, or the reason it failed.
|
|
3614
|
+
* @param console - entries the panel observed alongside the result.
|
|
3615
|
+
* @returns true when the id matched an in-flight command.
|
|
3616
|
+
*/
|
|
3617
|
+
post(clientId, id, outcome, console = []) {
|
|
3618
|
+
const panel = this.panels.get(clientId);
|
|
3619
|
+
if (panel !== void 0) this.appendConsole(panel, console);
|
|
3620
|
+
const record = this.inFlight.get(id);
|
|
3621
|
+
if (record === void 0) return false;
|
|
3622
|
+
if (outcome.ok) {
|
|
3623
|
+
const expected = EXPECTED_RESULT[record.kind];
|
|
3624
|
+
if (outcome.result.kind !== expected) {
|
|
3625
|
+
this.finish(record, {
|
|
3626
|
+
ok: false,
|
|
3627
|
+
error: `the Preview panel answered a ${record.kind} command with a ${outcome.result.kind} result, which this Host cannot read`
|
|
3628
|
+
});
|
|
3629
|
+
return true;
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
this.finish(record, outcome);
|
|
3633
|
+
return true;
|
|
3634
|
+
}
|
|
3635
|
+
/**
|
|
3636
|
+
* Forget a panel and fail everything it was holding.
|
|
3637
|
+
* @param clientId - the panel that closed.
|
|
3638
|
+
*/
|
|
3639
|
+
release(clientId) {
|
|
3640
|
+
this.dropPanelWork(clientId, "the preview panel closed before the command finished");
|
|
3641
|
+
if (this.panels.delete(clientId)) this.retired.add(clientId);
|
|
3642
|
+
}
|
|
3643
|
+
/**
|
|
3644
|
+
* Whether one panel is present and able to take a command.
|
|
3645
|
+
* @param clientId - the panel id, or undefined to ask about any panel in a session.
|
|
3646
|
+
* @param sessionId - the session the panel must belong to.
|
|
3647
|
+
* @returns the live panel's id, or undefined.
|
|
3648
|
+
*/
|
|
3649
|
+
active(sessionId, clientId) {
|
|
3650
|
+
return [...this.panels.values()].filter((panel) => this.isLive(panel)).filter((panel) => sessionId === void 0 || panel.sessionId === sessionId).filter((panel) => clientId === void 0 || panel.clientId === clientId).sort((left, right) => right.polledAt - left.polledAt)[0]?.clientId;
|
|
3651
|
+
}
|
|
3652
|
+
/**
|
|
3653
|
+
* The last thing one panel reported about itself.
|
|
3654
|
+
* @param clientId - the panel id.
|
|
3655
|
+
* @returns the bind, or undefined for a panel this Host has not heard from.
|
|
3656
|
+
*/
|
|
3657
|
+
bindOf(clientId) {
|
|
3658
|
+
return this.panels.get(clientId)?.bind;
|
|
3659
|
+
}
|
|
3660
|
+
/**
|
|
3661
|
+
* Queue one command against a live panel and wait for its answer.
|
|
3662
|
+
*
|
|
3663
|
+
* Every path out of here is bounded: a missing panel refuses immediately, a queue that is full
|
|
3664
|
+
* refuses immediately, and a delivered command that is never answered fails on its own deadline.
|
|
3665
|
+
* The one thing this must never do is wait for a browser that is not going to answer.
|
|
3666
|
+
* @param sessionId - the session whose panel should execute it.
|
|
3667
|
+
* @param command - the command body, without its id or its panel.
|
|
3668
|
+
* @returns the result, or the reason there is none.
|
|
3669
|
+
*/
|
|
3670
|
+
async queue(sessionId, command) {
|
|
3671
|
+
if (this.closed) return {
|
|
3672
|
+
ok: false,
|
|
3673
|
+
code: "no-surface",
|
|
3674
|
+
message: "the plugin is unloading"
|
|
3675
|
+
};
|
|
3676
|
+
const clientId = this.active(sessionId);
|
|
3677
|
+
if (clientId === void 0) return {
|
|
3678
|
+
ok: false,
|
|
3679
|
+
code: "no-surface",
|
|
3680
|
+
message: "no Preview panel is open in this session, so there is nothing to inspect. Open the Preview panel first (the session header's Preview entry), then call this tool again."
|
|
3681
|
+
};
|
|
3682
|
+
return this.send(clientId, command);
|
|
3683
|
+
}
|
|
3684
|
+
/**
|
|
3685
|
+
* Queue one command against one known panel, without requiring it to be live.
|
|
3686
|
+
*
|
|
3687
|
+
* Used by `open`, which must be able to hand a mode change to a panel before that panel has had a
|
|
3688
|
+
* chance to poll — the very first call against a freshly opened dock.
|
|
3689
|
+
* @param clientId - the panel id.
|
|
3690
|
+
* @param command - the command body.
|
|
3691
|
+
* @returns the result, or the reason there is none.
|
|
3692
|
+
*/
|
|
3693
|
+
async send(clientId, command) {
|
|
3694
|
+
if (this.closed) return {
|
|
3695
|
+
ok: false,
|
|
3696
|
+
code: "no-surface",
|
|
3697
|
+
message: "the plugin is unloading"
|
|
3698
|
+
};
|
|
3699
|
+
const panel = this.panels.get(clientId);
|
|
3700
|
+
if (panel === void 0) return {
|
|
3701
|
+
ok: false,
|
|
3702
|
+
code: "no-surface",
|
|
3703
|
+
message: "no Preview panel has reported in yet, so there is nothing to inspect"
|
|
3704
|
+
};
|
|
3705
|
+
if (panel.queue.length >= QUEUE_CAP) return {
|
|
3706
|
+
ok: false,
|
|
3707
|
+
code: "no-surface",
|
|
3708
|
+
message: `the Preview panel is not keeping up: ${String(QUEUE_CAP)} commands are already queued and unanswered`
|
|
3709
|
+
};
|
|
3710
|
+
const options = this.options();
|
|
3711
|
+
const timeoutMs = command.timeoutMs ?? options.commandTimeoutMs;
|
|
3712
|
+
const id = randomUUID();
|
|
3713
|
+
const full = {
|
|
3714
|
+
...command,
|
|
3715
|
+
id,
|
|
3716
|
+
clientId,
|
|
3717
|
+
timeoutMs
|
|
3718
|
+
};
|
|
3719
|
+
const outcome = new Promise((resolve$1) => {
|
|
3720
|
+
const timer = setTimeout(() => {
|
|
3721
|
+
const record = this.inFlight.get(id);
|
|
3722
|
+
if (record === void 0) return;
|
|
3723
|
+
this.inFlight.delete(id);
|
|
3724
|
+
resolve$1({
|
|
3725
|
+
ok: false,
|
|
3726
|
+
code: "timeout",
|
|
3727
|
+
message: record.delivered ? `the Preview panel did not answer the ${command.kind} command within ${String(timeoutMs)}ms` : `the Preview panel has not polled for work within ${String(timeoutMs)}ms, so the ${command.kind} command was never delivered`
|
|
3728
|
+
});
|
|
3729
|
+
}, timeoutMs);
|
|
3730
|
+
timer.unref?.();
|
|
3731
|
+
this.inFlight.set(id, {
|
|
3732
|
+
id,
|
|
3733
|
+
kind: command.kind,
|
|
3734
|
+
clientId,
|
|
3735
|
+
sessionId: panel.sessionId,
|
|
3736
|
+
mode: "direct",
|
|
3737
|
+
delivered: false,
|
|
3738
|
+
timer,
|
|
3739
|
+
settle: (result) => {
|
|
3740
|
+
resolve$1(result.ok ? {
|
|
3741
|
+
ok: true,
|
|
3742
|
+
result: result.result
|
|
3743
|
+
} : {
|
|
3744
|
+
ok: false,
|
|
3745
|
+
code: "timeout",
|
|
3746
|
+
message: result.error
|
|
3747
|
+
});
|
|
3748
|
+
}
|
|
3749
|
+
});
|
|
3750
|
+
});
|
|
3751
|
+
panel.queue.push(full);
|
|
3752
|
+
return outcome;
|
|
3753
|
+
}
|
|
3754
|
+
/**
|
|
3755
|
+
* Queue a mode change that needs no answer.
|
|
3756
|
+
* @param clientId - the panel to change.
|
|
3757
|
+
* @param control - what to change.
|
|
3758
|
+
* @returns false when the panel is unknown.
|
|
3759
|
+
*/
|
|
3760
|
+
control(clientId, control) {
|
|
3761
|
+
const panel = this.panels.get(clientId);
|
|
3762
|
+
if (panel === void 0) return false;
|
|
3763
|
+
panel.controls.push(control);
|
|
3764
|
+
return true;
|
|
3765
|
+
}
|
|
3766
|
+
/** Forget every panel and fail everything in flight. Called from the plugin's teardown. */
|
|
3767
|
+
dispose() {
|
|
3768
|
+
this.closed = true;
|
|
3769
|
+
for (const clientId of [...this.panels.keys()]) this.dropPanelWork(clientId, "the plugin is unloading");
|
|
3770
|
+
this.panels.clear();
|
|
3771
|
+
this.retired.clear();
|
|
3772
|
+
}
|
|
3773
|
+
/**
|
|
3774
|
+
* One panel's liveness.
|
|
3775
|
+
* @param panel - the panel.
|
|
3776
|
+
* @returns true when its last poll is inside the trust window.
|
|
3777
|
+
*/
|
|
3778
|
+
isLive(panel) {
|
|
3779
|
+
return this.now() - panel.polledAt <= this.options().bindTtlMs;
|
|
3780
|
+
}
|
|
3781
|
+
/** The configured clock. */
|
|
3782
|
+
now() {
|
|
3783
|
+
return (this.options().now ?? Date.now)();
|
|
3784
|
+
}
|
|
3785
|
+
/**
|
|
3786
|
+
* Append console entries to a panel's window, dropping the oldest past the cap.
|
|
3787
|
+
* @param panel - the panel.
|
|
3788
|
+
* @param entries - entries observed since the last report.
|
|
3789
|
+
*/
|
|
3790
|
+
appendConsole(panel, entries) {
|
|
3791
|
+
for (const entry of entries.slice(0, CONSOLE_CAP)) {
|
|
3792
|
+
panel.console.push({
|
|
3793
|
+
level: entry.level,
|
|
3794
|
+
text: entry.text.length > CONSOLE_LINE_CAP ? `${entry.text.slice(0, CONSOLE_LINE_CAP)}…` : entry.text,
|
|
3795
|
+
at: Number.isFinite(entry.at) ? entry.at : this.now()
|
|
3796
|
+
});
|
|
3797
|
+
panel.consoleTotal += 1;
|
|
3798
|
+
}
|
|
3799
|
+
while (panel.console.length > CONSOLE_CAP) {
|
|
3800
|
+
panel.console.shift();
|
|
3801
|
+
panel.consoleBase += 1;
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
/**
|
|
3805
|
+
* Fail every command one panel is holding and drop its queue.
|
|
3806
|
+
* @param clientId - the panel.
|
|
3807
|
+
* @param reason - the sentence the tool reports.
|
|
3808
|
+
*/
|
|
3809
|
+
dropPanelWork(clientId, reason) {
|
|
3810
|
+
const panel = this.panels.get(clientId);
|
|
3811
|
+
if (panel !== void 0) {
|
|
3812
|
+
panel.queue.length = 0;
|
|
3813
|
+
panel.controls.length = 0;
|
|
3814
|
+
}
|
|
3815
|
+
for (const record of [...this.inFlight.values()]) {
|
|
3816
|
+
if (record.clientId !== clientId) continue;
|
|
3817
|
+
this.finish(record, {
|
|
3818
|
+
ok: false,
|
|
3819
|
+
error: reason
|
|
3820
|
+
});
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
/**
|
|
3824
|
+
* Resolve one in-flight command and forget it.
|
|
3825
|
+
* @param record - the record.
|
|
3826
|
+
* @param outcome - what to resolve with.
|
|
3827
|
+
*/
|
|
3828
|
+
finish(record, outcome) {
|
|
3829
|
+
this.inFlight.delete(record.id);
|
|
3830
|
+
clearTimeout(record.timer);
|
|
3831
|
+
record.settle(outcome);
|
|
3832
|
+
}
|
|
3833
|
+
};
|
|
3834
|
+
|
|
2435
3835
|
//#endregion
|
|
2436
3836
|
//#region tsbuild/host/index.js
|
|
2437
3837
|
/**
|
|
@@ -2561,6 +3961,10 @@ let AdvancedSidebarService = (() => {
|
|
|
2561
3961
|
let _previewStart_decorators;
|
|
2562
3962
|
let _previewStop_decorators;
|
|
2563
3963
|
let _previewLogs_decorators;
|
|
3964
|
+
let _previewFileInfo_decorators;
|
|
3965
|
+
let _previewPoll_decorators;
|
|
3966
|
+
let _previewResult_decorators;
|
|
3967
|
+
let _previewRelease_decorators;
|
|
2564
3968
|
let _readFile_decorators;
|
|
2565
3969
|
let _openIn_decorators;
|
|
2566
3970
|
let _taskKill_decorators;
|
|
@@ -2587,6 +3991,10 @@ let AdvancedSidebarService = (() => {
|
|
|
2587
3991
|
_previewStart_decorators = [Remote("previewStart")];
|
|
2588
3992
|
_previewStop_decorators = [Remote("previewStop")];
|
|
2589
3993
|
_previewLogs_decorators = [Remote("previewLogs")];
|
|
3994
|
+
_previewFileInfo_decorators = [Remote("previewFileInfo")];
|
|
3995
|
+
_previewPoll_decorators = [Remote("previewPoll")];
|
|
3996
|
+
_previewResult_decorators = [Remote("previewResult")];
|
|
3997
|
+
_previewRelease_decorators = [Remote("previewRelease")];
|
|
2590
3998
|
_readFile_decorators = [Remote("readFile")];
|
|
2591
3999
|
_openIn_decorators = [Remote("openIn")];
|
|
2592
4000
|
_taskKill_decorators = [Remote("taskKill")];
|
|
@@ -2790,6 +4198,50 @@ let AdvancedSidebarService = (() => {
|
|
|
2790
4198
|
},
|
|
2791
4199
|
metadata: _metadata
|
|
2792
4200
|
}, null, _instanceExtraInitializers);
|
|
4201
|
+
__esDecorate(this, null, _previewFileInfo_decorators, {
|
|
4202
|
+
kind: "method",
|
|
4203
|
+
name: "previewFileInfo",
|
|
4204
|
+
static: false,
|
|
4205
|
+
private: false,
|
|
4206
|
+
access: {
|
|
4207
|
+
has: (obj) => "previewFileInfo" in obj,
|
|
4208
|
+
get: (obj) => obj.previewFileInfo
|
|
4209
|
+
},
|
|
4210
|
+
metadata: _metadata
|
|
4211
|
+
}, null, _instanceExtraInitializers);
|
|
4212
|
+
__esDecorate(this, null, _previewPoll_decorators, {
|
|
4213
|
+
kind: "method",
|
|
4214
|
+
name: "previewPoll",
|
|
4215
|
+
static: false,
|
|
4216
|
+
private: false,
|
|
4217
|
+
access: {
|
|
4218
|
+
has: (obj) => "previewPoll" in obj,
|
|
4219
|
+
get: (obj) => obj.previewPoll
|
|
4220
|
+
},
|
|
4221
|
+
metadata: _metadata
|
|
4222
|
+
}, null, _instanceExtraInitializers);
|
|
4223
|
+
__esDecorate(this, null, _previewResult_decorators, {
|
|
4224
|
+
kind: "method",
|
|
4225
|
+
name: "previewResult",
|
|
4226
|
+
static: false,
|
|
4227
|
+
private: false,
|
|
4228
|
+
access: {
|
|
4229
|
+
has: (obj) => "previewResult" in obj,
|
|
4230
|
+
get: (obj) => obj.previewResult
|
|
4231
|
+
},
|
|
4232
|
+
metadata: _metadata
|
|
4233
|
+
}, null, _instanceExtraInitializers);
|
|
4234
|
+
__esDecorate(this, null, _previewRelease_decorators, {
|
|
4235
|
+
kind: "method",
|
|
4236
|
+
name: "previewRelease",
|
|
4237
|
+
static: false,
|
|
4238
|
+
private: false,
|
|
4239
|
+
access: {
|
|
4240
|
+
has: (obj) => "previewRelease" in obj,
|
|
4241
|
+
get: (obj) => obj.previewRelease
|
|
4242
|
+
},
|
|
4243
|
+
metadata: _metadata
|
|
4244
|
+
}, null, _instanceExtraInitializers);
|
|
2793
4245
|
__esDecorate(this, null, _readFile_decorators, {
|
|
2794
4246
|
kind: "method",
|
|
2795
4247
|
name: "readFile",
|
|
@@ -2863,7 +4315,7 @@ let AdvancedSidebarService = (() => {
|
|
|
2863
4315
|
showArchive: z.boolean().required(),
|
|
2864
4316
|
showDelete: z.boolean().required(),
|
|
2865
4317
|
showPreview: z.boolean().required(),
|
|
2866
|
-
panelWidth: z.number().step(1).min(280).max(
|
|
4318
|
+
panelWidth: z.number().step(1).min(280).max(960).required(),
|
|
2867
4319
|
confirmDelete: z.boolean().required(),
|
|
2868
4320
|
deleteMode: z.union(["archive", "purge"]).required(),
|
|
2869
4321
|
allowTaskKill: z.boolean().required(),
|
|
@@ -2892,7 +4344,11 @@ let AdvancedSidebarService = (() => {
|
|
|
2892
4344
|
maxPreviews: z.number().step(1).min(1).max(16).required(),
|
|
2893
4345
|
previewReadyTimeoutMs: z.number().step(1).min(1e3).max(6e5).required(),
|
|
2894
4346
|
previewScrollback: z.number().step(1).min(1024).max(4 * 1024 * 1024).required(),
|
|
2895
|
-
previewGraceMs: z.number().step(1).min(100).max(6e4).required()
|
|
4347
|
+
previewGraceMs: z.number().step(1).min(100).max(6e4).required(),
|
|
4348
|
+
previewMaxFileBytes: z.number().step(1).min(1024).max(512 * 1024 * 1024).required(),
|
|
4349
|
+
previewProxyTimeoutMs: z.number().step(1).min(1e3).max(6e5).required(),
|
|
4350
|
+
previewCommandTimeoutMs: z.number().step(1).min(1e3).max(6e5).required(),
|
|
4351
|
+
previewBindTtlMs: z.number().step(1).min(1e3).max(12e4).required()
|
|
2896
4352
|
});
|
|
2897
4353
|
source = __runInitializers(this, _instanceExtraInitializers);
|
|
2898
4354
|
git;
|
|
@@ -2902,6 +4358,8 @@ let AdvancedSidebarService = (() => {
|
|
|
2902
4358
|
tasks;
|
|
2903
4359
|
deleter;
|
|
2904
4360
|
preview;
|
|
4361
|
+
surface;
|
|
4362
|
+
bindings;
|
|
2905
4363
|
/**
|
|
2906
4364
|
* @param ctx - Host context; every capability this service uses is resolved optionally, so a
|
|
2907
4365
|
* deployment missing one still serves a view that explains which panel is dark and why.
|
|
@@ -2919,6 +4377,15 @@ let AdvancedSidebarService = (() => {
|
|
|
2919
4377
|
this.tasks = new TaskController(ctx, read);
|
|
2920
4378
|
this.deleter = new SessionDeleter(ctx, read);
|
|
2921
4379
|
this.preview = new PreviewServers(ctx, read);
|
|
4380
|
+
this.surface = new PreviewSurface(ctx, read);
|
|
4381
|
+
this.bindings = new PreviewBindings(() => {
|
|
4382
|
+
const settings = this.source();
|
|
4383
|
+
return {
|
|
4384
|
+
commandTimeoutMs: settings.previewCommandTimeoutMs,
|
|
4385
|
+
bindTtlMs: settings.previewBindTtlMs
|
|
4386
|
+
};
|
|
4387
|
+
});
|
|
4388
|
+
this.surface.install();
|
|
2922
4389
|
installSettingsSection(ctx, ADVANCED_SIDEBAR_SETTINGS_NAMESPACE, AdvancedSidebarService$1.Config, config, {
|
|
2923
4390
|
setSource: (current) => {
|
|
2924
4391
|
this.source = current;
|
|
@@ -2930,6 +4397,8 @@ let AdvancedSidebarService = (() => {
|
|
|
2930
4397
|
});
|
|
2931
4398
|
ctx.effect(() => async () => {
|
|
2932
4399
|
this.tasks.dispose();
|
|
4400
|
+
this.bindings.dispose();
|
|
4401
|
+
this.surface.dispose();
|
|
2933
4402
|
await Promise.all([this.terminals.disposeAll(), this.preview.disposeAll()]);
|
|
2934
4403
|
}, "advanced-sidebar: panel terminals, preview servers, retained task output");
|
|
2935
4404
|
}
|
|
@@ -2946,7 +4415,10 @@ let AdvancedSidebarService = (() => {
|
|
|
2946
4415
|
git: await this.git.describe(signal),
|
|
2947
4416
|
terminal: this.terminals.describe(),
|
|
2948
4417
|
files: this.files.describe(),
|
|
2949
|
-
preview:
|
|
4418
|
+
preview: {
|
|
4419
|
+
...this.preview.describe(),
|
|
4420
|
+
surface: this.surface.info()
|
|
4421
|
+
},
|
|
2950
4422
|
tasks: this.tasks.describe(),
|
|
2951
4423
|
openIn: await this.launcher.describe(signal),
|
|
2952
4424
|
settings,
|
|
@@ -2964,8 +4436,8 @@ let AdvancedSidebarService = (() => {
|
|
|
2964
4436
|
* @param signal - gateway-supplied cancellation for the caller's abandoned request.
|
|
2965
4437
|
* @returns the reading, or a classified failure.
|
|
2966
4438
|
*/
|
|
2967
|
-
gitStatus(request, signal) {
|
|
2968
|
-
return this.git.status(request, signal);
|
|
4439
|
+
gitStatus(request$2, signal) {
|
|
4440
|
+
return this.git.status(request$2, signal);
|
|
2969
4441
|
}
|
|
2970
4442
|
/**
|
|
2971
4443
|
* Read one path's patch.
|
|
@@ -2973,8 +4445,8 @@ let AdvancedSidebarService = (() => {
|
|
|
2973
4445
|
* @param signal - gateway-supplied cancellation for the caller's abandoned request.
|
|
2974
4446
|
* @returns the patch, or a classified failure.
|
|
2975
4447
|
*/
|
|
2976
|
-
gitDiff(request, signal) {
|
|
2977
|
-
return this.git.diff(request, signal);
|
|
4448
|
+
gitDiff(request$2, signal) {
|
|
4449
|
+
return this.git.diff(request$2, signal);
|
|
2978
4450
|
}
|
|
2979
4451
|
/**
|
|
2980
4452
|
* Stage paths into the index.
|
|
@@ -2982,8 +4454,8 @@ let AdvancedSidebarService = (() => {
|
|
|
2982
4454
|
* @param signal - gateway-supplied cancellation for the caller's abandoned request.
|
|
2983
4455
|
* @returns the reading after the write, or a classified failure.
|
|
2984
4456
|
*/
|
|
2985
|
-
gitStage(request, signal) {
|
|
2986
|
-
return this.git.stage(request, signal);
|
|
4457
|
+
gitStage(request$2, signal) {
|
|
4458
|
+
return this.git.stage(request$2, signal);
|
|
2987
4459
|
}
|
|
2988
4460
|
/**
|
|
2989
4461
|
* Take paths back out of the index, leaving the working tree alone.
|
|
@@ -2991,8 +4463,8 @@ let AdvancedSidebarService = (() => {
|
|
|
2991
4463
|
* @param signal - gateway-supplied cancellation for the caller's abandoned request.
|
|
2992
4464
|
* @returns the reading after the write, or a classified failure.
|
|
2993
4465
|
*/
|
|
2994
|
-
gitUnstage(request, signal) {
|
|
2995
|
-
return this.git.unstage(request, signal);
|
|
4466
|
+
gitUnstage(request$2, signal) {
|
|
4467
|
+
return this.git.unstage(request$2, signal);
|
|
2996
4468
|
}
|
|
2997
4469
|
/**
|
|
2998
4470
|
* Record the staged changes.
|
|
@@ -3000,8 +4472,8 @@ let AdvancedSidebarService = (() => {
|
|
|
3000
4472
|
* @param signal - gateway-supplied cancellation; hooks run under `gitCommitTimeoutMs`.
|
|
3001
4473
|
* @returns the new commit and the reading after it, or a classified failure.
|
|
3002
4474
|
*/
|
|
3003
|
-
gitCommit(request, signal) {
|
|
3004
|
-
return this.git.commit(request, signal);
|
|
4475
|
+
gitCommit(request$2, signal) {
|
|
4476
|
+
return this.git.commit(request$2, signal);
|
|
3005
4477
|
}
|
|
3006
4478
|
/**
|
|
3007
4479
|
* Send the current branch's commits to its remote.
|
|
@@ -3009,8 +4481,8 @@ let AdvancedSidebarService = (() => {
|
|
|
3009
4481
|
* @param signal - gateway-supplied cancellation; the network wait runs under `gitPushTimeoutMs`.
|
|
3010
4482
|
* @returns the push and the reading after it, or a classified failure.
|
|
3011
4483
|
*/
|
|
3012
|
-
gitPush(request, signal) {
|
|
3013
|
-
return this.git.push(request, signal);
|
|
4484
|
+
gitPush(request$2, signal) {
|
|
4485
|
+
return this.git.push(request$2, signal);
|
|
3014
4486
|
}
|
|
3015
4487
|
/**
|
|
3016
4488
|
* Ask the deployment's own model to write a commit message for what is staged.
|
|
@@ -3018,8 +4490,8 @@ let AdvancedSidebarService = (() => {
|
|
|
3018
4490
|
* @param signal - gateway-supplied cancellation for the readings and the model call.
|
|
3019
4491
|
* @returns the drafted message, or a classified failure.
|
|
3020
4492
|
*/
|
|
3021
|
-
gitCommitMessage(request, signal) {
|
|
3022
|
-
return this.git.draftCommitMessage(request, signal);
|
|
4493
|
+
gitCommitMessage(request$2, signal) {
|
|
4494
|
+
return this.git.draftCommitMessage(request$2, signal);
|
|
3023
4495
|
}
|
|
3024
4496
|
/**
|
|
3025
4497
|
* Allocate a panel terminal.
|
|
@@ -3027,40 +4499,40 @@ let AdvancedSidebarService = (() => {
|
|
|
3027
4499
|
* @param signal - gateway-supplied cancellation of the allocation.
|
|
3028
4500
|
* @returns the handle, or a classified failure.
|
|
3029
4501
|
*/
|
|
3030
|
-
terminalOpen(request, signal) {
|
|
3031
|
-
return this.terminals.open(request, signal);
|
|
4502
|
+
terminalOpen(request$2, signal) {
|
|
4503
|
+
return this.terminals.open(request$2, signal);
|
|
3032
4504
|
}
|
|
3033
4505
|
/**
|
|
3034
4506
|
* Read a panel terminal's output from a caller-owned offset.
|
|
3035
4507
|
* @param request - the handle and the offset already rendered.
|
|
3036
4508
|
* @returns the delta and the process state, or a classified failure.
|
|
3037
4509
|
*/
|
|
3038
|
-
terminalRead(request) {
|
|
3039
|
-
return Promise.resolve(this.terminals.read(request));
|
|
4510
|
+
terminalRead(request$2) {
|
|
4511
|
+
return Promise.resolve(this.terminals.read(request$2));
|
|
3040
4512
|
}
|
|
3041
4513
|
/**
|
|
3042
4514
|
* Send keystrokes to a panel terminal.
|
|
3043
4515
|
* @param request - the handle and the text to deliver verbatim.
|
|
3044
4516
|
* @returns settlement, or a classified failure.
|
|
3045
4517
|
*/
|
|
3046
|
-
terminalWrite(request) {
|
|
3047
|
-
return this.terminals.write(request);
|
|
4518
|
+
terminalWrite(request$2) {
|
|
4519
|
+
return this.terminals.write(request$2);
|
|
3048
4520
|
}
|
|
3049
4521
|
/**
|
|
3050
4522
|
* Deliver a signal to a panel terminal's foreground process group.
|
|
3051
4523
|
* @param request - the handle and the signal.
|
|
3052
4524
|
* @returns settlement, or a classified failure.
|
|
3053
4525
|
*/
|
|
3054
|
-
terminalSignal(request) {
|
|
3055
|
-
return this.terminals.signal(request);
|
|
4526
|
+
terminalSignal(request$2) {
|
|
4527
|
+
return this.terminals.signal(request$2);
|
|
3056
4528
|
}
|
|
3057
4529
|
/**
|
|
3058
4530
|
* Close a panel terminal.
|
|
3059
4531
|
* @param request - the handle.
|
|
3060
4532
|
* @returns settlement, or a classified failure.
|
|
3061
4533
|
*/
|
|
3062
|
-
terminalClose(request) {
|
|
3063
|
-
return this.terminals.close(request);
|
|
4534
|
+
terminalClose(request$2) {
|
|
4535
|
+
return this.terminals.close(request$2);
|
|
3064
4536
|
}
|
|
3065
4537
|
/**
|
|
3066
4538
|
* List one directory level for the Files panel.
|
|
@@ -3068,8 +4540,8 @@ let AdvancedSidebarService = (() => {
|
|
|
3068
4540
|
* @param signal - gateway-supplied cancellation for the caller's abandoned request.
|
|
3069
4541
|
* @returns the level, or a classified failure.
|
|
3070
4542
|
*/
|
|
3071
|
-
listEntries(request, signal) {
|
|
3072
|
-
return this.files.list(request, signal);
|
|
4543
|
+
listEntries(request$2, signal) {
|
|
4544
|
+
return this.files.list(request$2, signal);
|
|
3073
4545
|
}
|
|
3074
4546
|
/**
|
|
3075
4547
|
* List one workspace's preview launch configurations, each with its current state.
|
|
@@ -3077,8 +4549,8 @@ let AdvancedSidebarService = (() => {
|
|
|
3077
4549
|
* @param signal - gateway-supplied cancellation for the caller's abandoned request.
|
|
3078
4550
|
* @returns the list, or a classified failure.
|
|
3079
4551
|
*/
|
|
3080
|
-
previewList(request, signal) {
|
|
3081
|
-
return this.preview.list(request, signal);
|
|
4552
|
+
previewList(request$2, signal) {
|
|
4553
|
+
return this.preview.list(request$2, signal);
|
|
3082
4554
|
}
|
|
3083
4555
|
/**
|
|
3084
4556
|
* Start one preview configuration.
|
|
@@ -3086,24 +4558,84 @@ let AdvancedSidebarService = (() => {
|
|
|
3086
4558
|
* @param signal - gateway-supplied cancellation of the start.
|
|
3087
4559
|
* @returns the started row, or a classified failure.
|
|
3088
4560
|
*/
|
|
3089
|
-
previewStart(request, signal) {
|
|
3090
|
-
return this.preview.start(request, signal);
|
|
4561
|
+
previewStart(request$2, signal) {
|
|
4562
|
+
return this.preview.start(request$2, signal);
|
|
3091
4563
|
}
|
|
3092
4564
|
/**
|
|
3093
4565
|
* Stop one running preview server.
|
|
3094
4566
|
* @param request - the handle.
|
|
3095
4567
|
* @returns settlement, or a classified failure.
|
|
3096
4568
|
*/
|
|
3097
|
-
previewStop(request) {
|
|
3098
|
-
return this.preview.stop(request);
|
|
4569
|
+
previewStop(request$2) {
|
|
4570
|
+
return this.preview.stop(request$2);
|
|
3099
4571
|
}
|
|
3100
4572
|
/**
|
|
3101
4573
|
* Read one preview server's output from a caller-owned offset, with its state at read time.
|
|
3102
4574
|
* @param request - the handle and the offset already rendered.
|
|
3103
4575
|
* @returns the delta and the state, or a classified failure.
|
|
3104
4576
|
*/
|
|
3105
|
-
previewLogs(request) {
|
|
3106
|
-
return Promise.resolve(this.preview.logs(request));
|
|
4577
|
+
previewLogs(request$2) {
|
|
4578
|
+
return Promise.resolve(this.preview.logs(request$2));
|
|
4579
|
+
}
|
|
4580
|
+
/**
|
|
4581
|
+
* Describe one workspace file for the Preview panel's Files mode.
|
|
4582
|
+
*
|
|
4583
|
+
* Separate from `readFile`, which returns text for the Files panel's reader: a preview needs the
|
|
4584
|
+
* kind, the size, the same-origin URL, and a change token, and it must not pull a 200 MB video
|
|
4585
|
+
* through the wire to find out what it is.
|
|
4586
|
+
* @param request - the workspace and the file inside it.
|
|
4587
|
+
* @param signal - gateway-supplied cancellation for the resolution and metadata reads.
|
|
4588
|
+
* @returns the file's kind and frame URL, or a classified failure.
|
|
4589
|
+
*/
|
|
4590
|
+
previewFileInfo(request$2, signal) {
|
|
4591
|
+
return this.surface.info_(request$2.workspacePath, request$2.path, signal);
|
|
4592
|
+
}
|
|
4593
|
+
/**
|
|
4594
|
+
* Register one Preview panel and take whatever the agent queued for it.
|
|
4595
|
+
*
|
|
4596
|
+
* This is the polling half of the agent channel: the browser calls it while a preview is mounted,
|
|
4597
|
+
* the call is the panel's liveness heartbeat, and its answer carries the commands to execute.
|
|
4598
|
+
* @param request - which panel, where it is, and whether a preview is actually rendered.
|
|
4599
|
+
* @returns the work to do, or a classified failure.
|
|
4600
|
+
*/
|
|
4601
|
+
previewPoll(request$2) {
|
|
4602
|
+
if (request$2.mounted) this.bindings.bind(request$2.bind);
|
|
4603
|
+
else this.bindings.bindAt(request$2.bind);
|
|
4604
|
+
this.surface.rememberTarget(request$2.clientId, request$2.bind.inspectable ? request$2.bind.url : void 0);
|
|
4605
|
+
const polled = this.bindings.poll(request$2.clientId, request$2.mounted);
|
|
4606
|
+
return Promise.resolve({
|
|
4607
|
+
ok: true,
|
|
4608
|
+
message: polled.message,
|
|
4609
|
+
bindTtlMs: polled.bindTtlMs
|
|
4610
|
+
});
|
|
4611
|
+
}
|
|
4612
|
+
/**
|
|
4613
|
+
* Record what one command did.
|
|
4614
|
+
* @param request - the panel, the command id, and the outcome.
|
|
4615
|
+
* @returns settlement.
|
|
4616
|
+
*/
|
|
4617
|
+
previewResult(request$2) {
|
|
4618
|
+
this.bindings.post(request$2.clientId, request$2.id, request$2.ok ? request$2.result === void 0 ? {
|
|
4619
|
+
ok: false,
|
|
4620
|
+
error: "the panel reported success without a result"
|
|
4621
|
+
} : {
|
|
4622
|
+
ok: true,
|
|
4623
|
+
result: request$2.result
|
|
4624
|
+
} : {
|
|
4625
|
+
ok: false,
|
|
4626
|
+
error: request$2.error ?? "the panel reported a failure with no reason"
|
|
4627
|
+
}, request$2.console ?? []);
|
|
4628
|
+
return Promise.resolve({ ok: true });
|
|
4629
|
+
}
|
|
4630
|
+
/**
|
|
4631
|
+
* Say that one panel is gone, so its queued work is dropped and its waits fail now.
|
|
4632
|
+
* @param request - the panel that closed.
|
|
4633
|
+
* @returns settlement.
|
|
4634
|
+
*/
|
|
4635
|
+
previewRelease(request$2) {
|
|
4636
|
+
this.bindings.release(request$2.clientId);
|
|
4637
|
+
this.surface.rememberTarget(request$2.clientId, void 0);
|
|
4638
|
+
return Promise.resolve({ ok: true });
|
|
3107
4639
|
}
|
|
3108
4640
|
/**
|
|
3109
4641
|
* Read one file for the Files panel preview.
|
|
@@ -3111,8 +4643,8 @@ let AdvancedSidebarService = (() => {
|
|
|
3111
4643
|
* @param signal - gateway-supplied cancellation for the caller's abandoned request.
|
|
3112
4644
|
* @returns the preview, or a classified failure.
|
|
3113
4645
|
*/
|
|
3114
|
-
readFile(request, signal) {
|
|
3115
|
-
return this.files.read(request, signal);
|
|
4646
|
+
readFile(request$2, signal) {
|
|
4647
|
+
return this.files.read(request$2, signal);
|
|
3116
4648
|
}
|
|
3117
4649
|
/**
|
|
3118
4650
|
* Hand one path to an external application or to the operating system's file manager.
|
|
@@ -3120,36 +4652,157 @@ let AdvancedSidebarService = (() => {
|
|
|
3120
4652
|
* @param signal - gateway-supplied cancellation for the launch.
|
|
3121
4653
|
* @returns settlement, or a classified failure.
|
|
3122
4654
|
*/
|
|
3123
|
-
openIn(request, signal) {
|
|
3124
|
-
return this.launcher.open(request, signal);
|
|
4655
|
+
openIn(request$2, signal) {
|
|
4656
|
+
return this.launcher.open(request$2, signal);
|
|
3125
4657
|
}
|
|
3126
4658
|
/**
|
|
3127
4659
|
* Stop one live background task.
|
|
3128
4660
|
* @param request - the owning session and the task id.
|
|
3129
4661
|
* @returns what the registry did, or a classified failure.
|
|
3130
4662
|
*/
|
|
3131
|
-
taskKill(request) {
|
|
3132
|
-
return this.tasks.kill(request);
|
|
4663
|
+
taskKill(request$2) {
|
|
4664
|
+
return this.tasks.kill(request$2);
|
|
3133
4665
|
}
|
|
3134
4666
|
/**
|
|
3135
4667
|
* Read one settled background task's output.
|
|
3136
4668
|
* @param request - the owning session and the task id.
|
|
3137
4669
|
* @returns the accumulated output, or a classified failure.
|
|
3138
4670
|
*/
|
|
3139
|
-
taskOutput(request) {
|
|
3140
|
-
return this.tasks.output(request);
|
|
4671
|
+
taskOutput(request$2) {
|
|
4672
|
+
return this.tasks.output(request$2);
|
|
3141
4673
|
}
|
|
3142
4674
|
/**
|
|
3143
|
-
* Delete one session: archive it, and
|
|
4675
|
+
* Delete one session: archive it, and report plainly that a configured purge did not happen.
|
|
3144
4676
|
* @param request - the session to delete.
|
|
3145
|
-
* @param signal - gateway-supplied cancellation for the persistence listing.
|
|
3146
4677
|
* @returns what was actually done, or a classified failure.
|
|
3147
4678
|
*/
|
|
3148
|
-
deleteSession(request
|
|
3149
|
-
return this.deleter.delete(request
|
|
4679
|
+
deleteSession(request$2) {
|
|
4680
|
+
return this.deleter.delete(request$2);
|
|
4681
|
+
}
|
|
4682
|
+
/**
|
|
4683
|
+
* Queue one command against the session's Preview panel and wait for its answer.
|
|
4684
|
+
* @param sessionId - the session whose panel should execute it.
|
|
4685
|
+
* @param body - the command, without its id, panel, or deadline.
|
|
4686
|
+
* @returns the result, or a sentence explaining why there is none.
|
|
4687
|
+
*/
|
|
4688
|
+
queueCommand(sessionId, body) {
|
|
4689
|
+
return this.bindings.queue(sessionId, {
|
|
4690
|
+
...body,
|
|
4691
|
+
timeoutMs: this.source().previewCommandTimeoutMs
|
|
4692
|
+
});
|
|
4693
|
+
}
|
|
4694
|
+
/**
|
|
4695
|
+
* Point the session's panel at a URL.
|
|
4696
|
+
*
|
|
4697
|
+
* The panel decides for itself how to frame it — a loopback URL goes through this Host's proxy and
|
|
4698
|
+
* becomes inspectable, anything else is framed cross-origin and is not — so the answer says which
|
|
4699
|
+
* happened rather than the tool guessing.
|
|
4700
|
+
* @param sessionId - the session whose panel should show it.
|
|
4701
|
+
* @param url - the absolute `http(s)` URL, already validated by the caller.
|
|
4702
|
+
* @param waitMs - how long the panel may take to mount and load.
|
|
4703
|
+
* @returns the outcome and what the panel is now framing.
|
|
4704
|
+
*/
|
|
4705
|
+
async openUrl(sessionId, url, waitMs) {
|
|
4706
|
+
const clientId = this.bindings.active(sessionId);
|
|
4707
|
+
if (clientId === void 0) return {
|
|
4708
|
+
ok: false,
|
|
4709
|
+
message: NO_SURFACE
|
|
4710
|
+
};
|
|
4711
|
+
if (!this.bindings.control(clientId, {
|
|
4712
|
+
control: "open",
|
|
4713
|
+
open: {
|
|
4714
|
+
clientId,
|
|
4715
|
+
mode: "url",
|
|
4716
|
+
url
|
|
4717
|
+
}
|
|
4718
|
+
})) return {
|
|
4719
|
+
ok: false,
|
|
4720
|
+
message: NO_SURFACE
|
|
4721
|
+
};
|
|
4722
|
+
const sameOrigin = validateProxyTarget(url).ok;
|
|
4723
|
+
const outcome = await this.bindings.send(clientId, {
|
|
4724
|
+
kind: "open",
|
|
4725
|
+
timeoutMs: waitMs
|
|
4726
|
+
});
|
|
4727
|
+
if (!outcome.ok) return {
|
|
4728
|
+
ok: false,
|
|
4729
|
+
message: outcome.message
|
|
4730
|
+
};
|
|
4731
|
+
return {
|
|
4732
|
+
ok: true,
|
|
4733
|
+
message: sameOrigin ? `The Preview panel is loading ${url} through this Host's same-origin proxy, so its DOM, console and events are all inspectable.` : `The Preview panel is showing ${url}. It is not a loopback URL, so the frame stays cross-origin and cannot be inspected: dom, eval, click and type will refuse it.`,
|
|
4734
|
+
detail: {
|
|
4735
|
+
url,
|
|
4736
|
+
inspectable: sameOrigin,
|
|
4737
|
+
...sameOrigin ? { proxyRoute: PROXY_ROUTE } : {}
|
|
4738
|
+
}
|
|
4739
|
+
};
|
|
4740
|
+
}
|
|
4741
|
+
/**
|
|
4742
|
+
* Point the session's panel at one workspace file.
|
|
4743
|
+
* @param request - the session, the workspace, the file, its kind, and the wait budget.
|
|
4744
|
+
* @returns the outcome and where the panel is now framed from.
|
|
4745
|
+
*/
|
|
4746
|
+
async openFile(request$2) {
|
|
4747
|
+
const clientId = this.bindings.active(request$2.sessionId);
|
|
4748
|
+
if (clientId === void 0) return {
|
|
4749
|
+
ok: false,
|
|
4750
|
+
message: NO_SURFACE
|
|
4751
|
+
};
|
|
4752
|
+
if (!this.bindings.control(clientId, {
|
|
4753
|
+
control: "open",
|
|
4754
|
+
open: {
|
|
4755
|
+
clientId,
|
|
4756
|
+
mode: "file",
|
|
4757
|
+
filePath: request$2.filePath,
|
|
4758
|
+
workspacePath: request$2.workspacePath
|
|
4759
|
+
}
|
|
4760
|
+
})) return {
|
|
4761
|
+
ok: false,
|
|
4762
|
+
message: NO_SURFACE
|
|
4763
|
+
};
|
|
4764
|
+
const framed = this.surface.info().available;
|
|
4765
|
+
if (request$2.kind !== "iframe" && request$2.kind !== "markdown" && !framed) return {
|
|
4766
|
+
ok: false,
|
|
4767
|
+
message: `${request$2.filePath} is a ${request$2.kind} file, and this Host composes no web server, so there is no same-origin URL to frame it from`
|
|
4768
|
+
};
|
|
4769
|
+
const outcome = await this.bindings.send(clientId, {
|
|
4770
|
+
kind: "open",
|
|
4771
|
+
timeoutMs: request$2.waitMs
|
|
4772
|
+
});
|
|
4773
|
+
if (!outcome.ok) return {
|
|
4774
|
+
ok: false,
|
|
4775
|
+
message: outcome.message
|
|
4776
|
+
};
|
|
4777
|
+
const url = this.surface.fileUrl(request$2.workspacePath, request$2.filePath);
|
|
4778
|
+
return {
|
|
4779
|
+
ok: true,
|
|
4780
|
+
message: `The Preview panel is showing ${request$2.filePath} (${request$2.kind})${request$2.kind === "iframe" ? " as a live document" : ""}; its DOM is same-origin and inspectable.`,
|
|
4781
|
+
detail: {
|
|
4782
|
+
path: request$2.filePath,
|
|
4783
|
+
kind: request$2.kind,
|
|
4784
|
+
...url === void 0 ? {} : { url }
|
|
4785
|
+
}
|
|
4786
|
+
};
|
|
4787
|
+
}
|
|
4788
|
+
/**
|
|
4789
|
+
* Read one workspace file's preview description, for the tool's own `open` validation.
|
|
4790
|
+
* @param request - the workspace and the file.
|
|
4791
|
+
* @param signal - cancellation for the reads.
|
|
4792
|
+
* @returns the description, or a classified failure.
|
|
4793
|
+
*/
|
|
4794
|
+
describeFile(request$2, signal) {
|
|
4795
|
+
return this.surface.info_(request$2.workspacePath, request$2.path, signal);
|
|
3150
4796
|
}
|
|
3151
4797
|
};
|
|
3152
4798
|
})();
|
|
4799
|
+
/**
|
|
4800
|
+
* The refusal a tool call gets when no Preview panel is open in its session.
|
|
4801
|
+
*
|
|
4802
|
+
* A constant rather than a method: it is the same sentence for every action, and a model that reads
|
|
4803
|
+
* it twice should read the same thing twice.
|
|
4804
|
+
*/
|
|
4805
|
+
const NO_SURFACE = "no Preview panel is open in this session, so there is nothing to inspect. Open the Preview panel first (the session header's Preview entry), then call this tool again.";
|
|
3153
4806
|
var host_default = AdvancedSidebarService;
|
|
3154
4807
|
|
|
3155
4808
|
//#endregion
|