dsh-vscode-workbench 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +82 -0
- package/README.md +81 -0
- package/lib/client.js +6039 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +832 -0
- package/lib/index.js.map +1 -0
- package/lib/types/client/DshConversationPanel.d.ts +14 -0
- package/lib/types/client/DshMark.d.ts +4 -0
- package/lib/types/client/Icon.d.ts +8 -0
- package/lib/types/client/MarkdownPreview.d.ts +14 -0
- package/lib/types/client/MonacoEditor.d.ts +28 -0
- package/lib/types/client/Workbench.d.ts +34 -0
- package/lib/types/client/api.d.ts +1 -0
- package/lib/types/client/index.d.ts +20 -0
- package/lib/types/client/service.d.ts +60 -0
- package/lib/types/client/styles.d.ts +1 -0
- package/lib/types/context.d.ts +30 -0
- package/lib/types/host.d.ts +41 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/types.d.ts +72 -0
- package/lib/types/wire.d.ts +11 -0
- package/package.json +97 -0
- package/src/client/DshConversationPanel.tsx +56 -0
- package/src/client/DshMark.tsx +16 -0
- package/src/client/Icon.tsx +23 -0
- package/src/client/MarkdownPreview.tsx +18 -0
- package/src/client/MonacoEditor.tsx +56 -0
- package/src/client/Workbench.tsx +185 -0
- package/src/client/api.ts +8 -0
- package/src/client/index.tsx +39 -0
- package/src/client/service.ts +58 -0
- package/src/client/styles.ts +49 -0
- package/src/context.ts +16 -0
- package/src/host.ts +306 -0
- package/src/index.ts +96 -0
- package/src/types.ts +18 -0
- package/src/wire.ts +49 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { access, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import chokidar from "chokidar";
|
|
4
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
8
|
+
import { constants } from "node:fs";
|
|
9
|
+
//#region src/wire.ts
|
|
10
|
+
var WorkbenchError = class extends Error {
|
|
11
|
+
code;
|
|
12
|
+
status;
|
|
13
|
+
constructor(code, message, status = 400) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.status = status;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
async function readBody(req) {
|
|
20
|
+
const chunks = [];
|
|
21
|
+
let size = 0;
|
|
22
|
+
for await (const chunk of req) {
|
|
23
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
24
|
+
size += value.length;
|
|
25
|
+
if (size > 2097152) throw new WorkbenchError("too-large", "request body is too large", 413);
|
|
26
|
+
chunks.push(value);
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const value = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
30
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("object expected");
|
|
31
|
+
return value;
|
|
32
|
+
} catch {
|
|
33
|
+
throw new WorkbenchError("bad-json", "request body must be a JSON object");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function stringField(body, key) {
|
|
37
|
+
const value = body[key];
|
|
38
|
+
if (typeof value !== "string") throw new WorkbenchError("bad-request", `${key} must be a string`);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function send(res, status, value) {
|
|
42
|
+
res.writeHead(status, {
|
|
43
|
+
"content-type": "application/json; charset=utf-8",
|
|
44
|
+
"cache-control": "no-store"
|
|
45
|
+
});
|
|
46
|
+
res.end(JSON.stringify(value));
|
|
47
|
+
}
|
|
48
|
+
function sendError(res, error) {
|
|
49
|
+
const known = error instanceof WorkbenchError ? error : new WorkbenchError("internal", error instanceof Error ? error.message : String(error), 500);
|
|
50
|
+
send(res, known.status, {
|
|
51
|
+
ok: false,
|
|
52
|
+
error: {
|
|
53
|
+
code: known.code,
|
|
54
|
+
message: known.message
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function trusted(req, allowed) {
|
|
59
|
+
const host = req.headers.host;
|
|
60
|
+
if (host === void 0 || req.headers["sec-fetch-site"] === "cross-site") return false;
|
|
61
|
+
let hostUrl;
|
|
62
|
+
try {
|
|
63
|
+
hostUrl = new URL(`http://${host}`);
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (!(hostUrl.hostname === "localhost" || hostUrl.hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(hostUrl.hostname)) && !allowed.some((candidate) => candidate === host || candidate === hostUrl.hostname)) return false;
|
|
68
|
+
const origin = req.headers.origin;
|
|
69
|
+
if (origin === void 0) return true;
|
|
70
|
+
try {
|
|
71
|
+
return new URL(origin).host === hostUrl.host;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
//#endregion
|
|
77
|
+
//#region src/host.ts
|
|
78
|
+
const require = createRequire(import.meta.url);
|
|
79
|
+
const TEXT_LIMIT = 5242880;
|
|
80
|
+
const PROCESS_TIMEOUT = 3e4;
|
|
81
|
+
function cwdFor(ctx, sessionId) {
|
|
82
|
+
const cwd = ctx.sessions.get(sessionId)?.header.cwd;
|
|
83
|
+
if (cwd === void 0 || cwd === "") return process.cwd();
|
|
84
|
+
if (!isAbsolute(cwd)) throw new WorkbenchError("cwd-invalid", "session working directory is not absolute");
|
|
85
|
+
return normalize(cwd);
|
|
86
|
+
}
|
|
87
|
+
function inside(root, target) {
|
|
88
|
+
const rel = relative(root, target);
|
|
89
|
+
return rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
|
|
90
|
+
}
|
|
91
|
+
async function canonicalRoot(cwd) {
|
|
92
|
+
return realpath(cwd).catch(() => {
|
|
93
|
+
throw new WorkbenchError("cwd-missing", "session working directory does not exist", 404);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** Resolve a caller-relative path and reject traversal and symlink escape. */
|
|
97
|
+
async function workspacePath(cwd, raw, create = false) {
|
|
98
|
+
if (isAbsolute(raw)) throw new WorkbenchError("path-invalid", "absolute paths are not accepted");
|
|
99
|
+
const root = await canonicalRoot(cwd);
|
|
100
|
+
const absolute = resolve(root, raw === "" ? "." : raw);
|
|
101
|
+
if (!inside(root, absolute)) throw new WorkbenchError("path-outside", "path is outside the workspace", 403);
|
|
102
|
+
if (create) {
|
|
103
|
+
if (!inside(root, await realpath(dirname(absolute)).catch(() => {
|
|
104
|
+
throw new WorkbenchError("parent-missing", "parent directory does not exist", 404);
|
|
105
|
+
}))) throw new WorkbenchError("path-outside", "path follows a symlink outside the workspace", 403);
|
|
106
|
+
} else if (!inside(root, await realpath(absolute).catch(() => {
|
|
107
|
+
throw new WorkbenchError("not-found", "path does not exist", 404);
|
|
108
|
+
}))) throw new WorkbenchError("path-outside", "path follows a symlink outside the workspace", 403);
|
|
109
|
+
return {
|
|
110
|
+
root,
|
|
111
|
+
absolute
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function relativePath(root, absolute) {
|
|
115
|
+
return relative(root, absolute).split(sep).join("/");
|
|
116
|
+
}
|
|
117
|
+
function revision(content, mtimeMs) {
|
|
118
|
+
return createHash("sha256").update(content).update(String(mtimeMs)).digest("hex");
|
|
119
|
+
}
|
|
120
|
+
async function listFiles(cwd, path) {
|
|
121
|
+
const { root, absolute } = await workspacePath(cwd, path);
|
|
122
|
+
const values = await readdir(absolute, { withFileTypes: true });
|
|
123
|
+
return (await Promise.all(values.filter((item) => item.name !== ".git").map(async (item) => {
|
|
124
|
+
const full = join(absolute, item.name);
|
|
125
|
+
const info = await lstat(full);
|
|
126
|
+
const kind = item.isDirectory() ? "directory" : "file";
|
|
127
|
+
return {
|
|
128
|
+
name: item.name,
|
|
129
|
+
path: relativePath(root, full),
|
|
130
|
+
kind,
|
|
131
|
+
size: info.size,
|
|
132
|
+
mtimeMs: info.mtimeMs
|
|
133
|
+
};
|
|
134
|
+
}))).sort((a, b) => a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "directory" ? -1 : 1);
|
|
135
|
+
}
|
|
136
|
+
async function readDocument(cwd, path) {
|
|
137
|
+
const { root, absolute } = await workspacePath(cwd, path);
|
|
138
|
+
const info = await stat(absolute);
|
|
139
|
+
if (!info.isFile()) throw new WorkbenchError("not-file", "path is not a file");
|
|
140
|
+
const tooLarge = info.size > TEXT_LIMIT;
|
|
141
|
+
const handle = await open(absolute, "r");
|
|
142
|
+
try {
|
|
143
|
+
const head = Buffer.alloc(Math.min(info.size, tooLarge ? 4096 : TEXT_LIMIT));
|
|
144
|
+
const { bytesRead } = await handle.read(head, 0, head.length, 0);
|
|
145
|
+
const bytes = head.subarray(0, bytesRead);
|
|
146
|
+
const binary = bytes.includes(0);
|
|
147
|
+
return {
|
|
148
|
+
path: relativePath(root, absolute),
|
|
149
|
+
content: binary || tooLarge ? "" : bytes.toString("utf8"),
|
|
150
|
+
revision: revision(bytes, info.mtimeMs),
|
|
151
|
+
size: info.size,
|
|
152
|
+
binary,
|
|
153
|
+
tooLarge
|
|
154
|
+
};
|
|
155
|
+
} finally {
|
|
156
|
+
await handle.close();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function saveDocument(cwd, path, content, expected) {
|
|
160
|
+
const { absolute } = await workspacePath(cwd, path);
|
|
161
|
+
if ((await readDocument(cwd, path)).revision !== expected) throw new WorkbenchError("revision-conflict", "file changed on disk; reload before saving", 409);
|
|
162
|
+
const temp = join(dirname(absolute), `.${randomUUID()}.dsh-vscode.tmp`);
|
|
163
|
+
await writeFile(temp, content, {
|
|
164
|
+
encoding: "utf8",
|
|
165
|
+
flag: "wx"
|
|
166
|
+
});
|
|
167
|
+
try {
|
|
168
|
+
await rename(temp, absolute);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
await rm(temp, { force: true });
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
return readDocument(cwd, path);
|
|
174
|
+
}
|
|
175
|
+
async function createEntry(cwd, path, kind) {
|
|
176
|
+
const { absolute } = await workspacePath(cwd, path, true);
|
|
177
|
+
if (kind === "directory") await mkdir(absolute);
|
|
178
|
+
else await (await open(absolute, "wx")).close();
|
|
179
|
+
}
|
|
180
|
+
async function renameEntry(cwd, path, nextPath) {
|
|
181
|
+
const source = await workspacePath(cwd, path);
|
|
182
|
+
const target = await workspacePath(cwd, nextPath, true);
|
|
183
|
+
await access(target.absolute, constants.F_OK).then(() => {
|
|
184
|
+
throw new WorkbenchError("target-exists", "destination already exists", 409);
|
|
185
|
+
}, () => void 0);
|
|
186
|
+
await rename(source.absolute, target.absolute);
|
|
187
|
+
}
|
|
188
|
+
async function deleteEntry(cwd, path) {
|
|
189
|
+
if (path === "") throw new WorkbenchError("root-delete", "cannot delete workspace root", 403);
|
|
190
|
+
const { absolute } = await workspacePath(cwd, path);
|
|
191
|
+
await rm(absolute, {
|
|
192
|
+
recursive: true,
|
|
193
|
+
force: false
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const IMAGE_TYPES = {
|
|
197
|
+
".avif": "image/avif",
|
|
198
|
+
".bmp": "image/bmp",
|
|
199
|
+
".gif": "image/gif",
|
|
200
|
+
".ico": "image/x-icon",
|
|
201
|
+
".jpeg": "image/jpeg",
|
|
202
|
+
".jpg": "image/jpeg",
|
|
203
|
+
".png": "image/png",
|
|
204
|
+
".svg": "image/svg+xml",
|
|
205
|
+
".webp": "image/webp"
|
|
206
|
+
};
|
|
207
|
+
/** Resolve an image inside the workspace for protected browser preview. */
|
|
208
|
+
async function workspaceImage(cwd, path) {
|
|
209
|
+
const resolved = await workspacePath(cwd, path);
|
|
210
|
+
const type = IMAGE_TYPES[extname(resolved.absolute).toLowerCase()];
|
|
211
|
+
if (type === void 0) throw new WorkbenchError("unsupported-media", "file is not a supported image", 415);
|
|
212
|
+
return {
|
|
213
|
+
path: resolved.absolute,
|
|
214
|
+
type
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function run(command, args, cwd, timeout = PROCESS_TIMEOUT, signal) {
|
|
218
|
+
return new Promise((resolvePromise, reject) => {
|
|
219
|
+
const child = spawn(command, args, {
|
|
220
|
+
cwd,
|
|
221
|
+
shell: false,
|
|
222
|
+
windowsHide: true
|
|
223
|
+
});
|
|
224
|
+
let stdout = "";
|
|
225
|
+
let stderr = "";
|
|
226
|
+
let settled = false;
|
|
227
|
+
const timer = setTimeout(() => {
|
|
228
|
+
child.kill();
|
|
229
|
+
if (!settled) reject(new WorkbenchError("timeout", `${command} timed out`, 504));
|
|
230
|
+
}, timeout);
|
|
231
|
+
const abort = () => {
|
|
232
|
+
child.kill();
|
|
233
|
+
if (!settled) {
|
|
234
|
+
settled = true;
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
reject(new WorkbenchError("cancelled", `${command} cancelled`, 499));
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
240
|
+
child.stdout.on("data", (chunk) => {
|
|
241
|
+
stdout += String(chunk);
|
|
242
|
+
});
|
|
243
|
+
child.stderr.on("data", (chunk) => {
|
|
244
|
+
stderr += String(chunk);
|
|
245
|
+
});
|
|
246
|
+
child.on("error", (error) => {
|
|
247
|
+
clearTimeout(timer);
|
|
248
|
+
signal?.removeEventListener("abort", abort);
|
|
249
|
+
if (!settled) {
|
|
250
|
+
settled = true;
|
|
251
|
+
reject(error);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
child.on("close", (code) => {
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
signal?.removeEventListener("abort", abort);
|
|
257
|
+
if (!settled) {
|
|
258
|
+
settled = true;
|
|
259
|
+
resolvePromise({
|
|
260
|
+
stdout,
|
|
261
|
+
stderr,
|
|
262
|
+
code: code ?? 1
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
async function searchWorkspace(cwd, request, signal) {
|
|
269
|
+
if (request.query === "") return {
|
|
270
|
+
matches: [],
|
|
271
|
+
limited: false
|
|
272
|
+
};
|
|
273
|
+
const root = await canonicalRoot(cwd);
|
|
274
|
+
const rg = require("@vscode/ripgrep").rgPath;
|
|
275
|
+
const args = [
|
|
276
|
+
"--json",
|
|
277
|
+
"--line-number",
|
|
278
|
+
"--column",
|
|
279
|
+
"--hidden",
|
|
280
|
+
"--glob",
|
|
281
|
+
"!.git/**",
|
|
282
|
+
"--max-count",
|
|
283
|
+
"2000"
|
|
284
|
+
];
|
|
285
|
+
if (!request.regex) args.push("--fixed-strings");
|
|
286
|
+
if (!request.caseSensitive) args.push("--ignore-case");
|
|
287
|
+
if (request.wholeWord) args.push("--word-regexp");
|
|
288
|
+
if (request.include) args.push("--glob", request.include);
|
|
289
|
+
if (request.exclude) args.push("--glob", `!${request.exclude}`);
|
|
290
|
+
args.push("--", request.query, ".");
|
|
291
|
+
const result = await run(rg, args, root, PROCESS_TIMEOUT, signal);
|
|
292
|
+
if (result.code > 1) throw new WorkbenchError("search-failed", result.stderr || "ripgrep failed", 500);
|
|
293
|
+
const matches = [];
|
|
294
|
+
for (const line of result.stdout.split("\n")) {
|
|
295
|
+
if (line === "") continue;
|
|
296
|
+
const event = JSON.parse(line);
|
|
297
|
+
if (event.type !== "match" || event.data?.path?.text === void 0 || event.data.lines?.text === void 0) continue;
|
|
298
|
+
for (const hit of event.data.submatches ?? []) {
|
|
299
|
+
matches.push({
|
|
300
|
+
path: event.data.path.text.replace(/^\.\//, ""),
|
|
301
|
+
line: event.data.line_number ?? 1,
|
|
302
|
+
column: hit.start + 1,
|
|
303
|
+
preview: event.data.lines.text.trimEnd(),
|
|
304
|
+
match: hit.match?.text ?? ""
|
|
305
|
+
});
|
|
306
|
+
if (matches.length >= 2e3) return {
|
|
307
|
+
matches,
|
|
308
|
+
limited: true
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
matches,
|
|
314
|
+
limited: false
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
async function gitRoot(cwd) {
|
|
318
|
+
const result = await run("git", ["rev-parse", "--show-toplevel"], cwd);
|
|
319
|
+
if (result.code !== 0) throw new WorkbenchError("not-repository", "current workspace is not inside a Git repository", 404);
|
|
320
|
+
return result.stdout.trim();
|
|
321
|
+
}
|
|
322
|
+
function validateCommitHash(value) {
|
|
323
|
+
if (!/^[0-9a-f]{7,64}$/i.test(value ?? "")) throw new WorkbenchError("bad-request", "commit hash is invalid");
|
|
324
|
+
return value;
|
|
325
|
+
}
|
|
326
|
+
function validateGitPath(repository, path) {
|
|
327
|
+
if (path === void 0 || path === "" || isAbsolute(path) || !inside(repository, resolve(repository, path))) throw new WorkbenchError("bad-request", "repository-relative path is required");
|
|
328
|
+
return path;
|
|
329
|
+
}
|
|
330
|
+
async function gitObject(repository, spec) {
|
|
331
|
+
const result = await run("git", [
|
|
332
|
+
"show",
|
|
333
|
+
"--no-textconv",
|
|
334
|
+
spec
|
|
335
|
+
], repository);
|
|
336
|
+
return result.code === 0 ? result.stdout : "";
|
|
337
|
+
}
|
|
338
|
+
async function commitFiles(repository, hash) {
|
|
339
|
+
const parent = await run("git", [
|
|
340
|
+
"rev-parse",
|
|
341
|
+
"--verify",
|
|
342
|
+
`${hash}^1`
|
|
343
|
+
], repository);
|
|
344
|
+
const result = parent.code === 0 ? await run("git", [
|
|
345
|
+
"diff",
|
|
346
|
+
"--name-status",
|
|
347
|
+
"-z",
|
|
348
|
+
"-M",
|
|
349
|
+
parent.stdout.trim(),
|
|
350
|
+
hash,
|
|
351
|
+
"--"
|
|
352
|
+
], repository) : await run("git", [
|
|
353
|
+
"diff-tree",
|
|
354
|
+
"--root",
|
|
355
|
+
"--no-commit-id",
|
|
356
|
+
"--name-status",
|
|
357
|
+
"-r",
|
|
358
|
+
"-z",
|
|
359
|
+
"-M",
|
|
360
|
+
hash
|
|
361
|
+
], repository);
|
|
362
|
+
if (result.code !== 0) throw new WorkbenchError("git-failed", result.stderr || result.stdout, 409);
|
|
363
|
+
const tokens = result.stdout.split("\0").filter(Boolean);
|
|
364
|
+
const files = [];
|
|
365
|
+
for (let index = 0; index < tokens.length;) {
|
|
366
|
+
const code = (tokens[index++] ?? "")[0] ?? "";
|
|
367
|
+
if (code === "R" || code === "C") {
|
|
368
|
+
const previousPath = tokens[index++] ?? "";
|
|
369
|
+
const path = tokens[index++] ?? "";
|
|
370
|
+
files.push({
|
|
371
|
+
path,
|
|
372
|
+
previousPath,
|
|
373
|
+
status: code
|
|
374
|
+
});
|
|
375
|
+
} else {
|
|
376
|
+
const path = tokens[index++] ?? "";
|
|
377
|
+
files.push({
|
|
378
|
+
path,
|
|
379
|
+
status: code
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return files;
|
|
384
|
+
}
|
|
385
|
+
async function gitStatus(cwd) {
|
|
386
|
+
const repository = await gitRoot(cwd);
|
|
387
|
+
const result = await run("git", [
|
|
388
|
+
"status",
|
|
389
|
+
"--porcelain=v2",
|
|
390
|
+
"-z",
|
|
391
|
+
"--branch",
|
|
392
|
+
"--untracked-files=all"
|
|
393
|
+
], repository);
|
|
394
|
+
if (result.code !== 0) throw new WorkbenchError("git-failed", result.stderr, 500);
|
|
395
|
+
let branch = "HEAD";
|
|
396
|
+
let ahead = 0;
|
|
397
|
+
let behind = 0;
|
|
398
|
+
const changes = [];
|
|
399
|
+
for (const record of result.stdout.split("\0")) if (record.startsWith("# branch.head ")) branch = record.slice(14);
|
|
400
|
+
else if (record.startsWith("# branch.ab ")) {
|
|
401
|
+
const match = /\+(\d+) -(\d+)/.exec(record);
|
|
402
|
+
ahead = Number(match?.[1] ?? 0);
|
|
403
|
+
behind = Number(match?.[2] ?? 0);
|
|
404
|
+
} else if (record.startsWith("? ")) changes.push({
|
|
405
|
+
path: record.slice(2),
|
|
406
|
+
index: "?",
|
|
407
|
+
worktree: "?",
|
|
408
|
+
staged: false,
|
|
409
|
+
untracked: true
|
|
410
|
+
});
|
|
411
|
+
else if (record.startsWith("1 ") || record.startsWith("2 ")) {
|
|
412
|
+
const parts = record.split(" ");
|
|
413
|
+
const xy = parts[1] ?? "..";
|
|
414
|
+
const path = parts.slice(record.startsWith("2 ") ? 9 : 8).join(" ");
|
|
415
|
+
changes.push({
|
|
416
|
+
path,
|
|
417
|
+
index: xy[0] ?? ".",
|
|
418
|
+
worktree: xy[1] ?? ".",
|
|
419
|
+
staged: xy[0] !== ".",
|
|
420
|
+
untracked: false
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
repository,
|
|
425
|
+
branch,
|
|
426
|
+
ahead,
|
|
427
|
+
behind,
|
|
428
|
+
changes
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
async function gitAction(cwd, action, path, value) {
|
|
432
|
+
const repository = await gitRoot(cwd);
|
|
433
|
+
let args;
|
|
434
|
+
switch (action) {
|
|
435
|
+
case "stage":
|
|
436
|
+
args = [
|
|
437
|
+
"add",
|
|
438
|
+
"--",
|
|
439
|
+
path ?? ""
|
|
440
|
+
];
|
|
441
|
+
break;
|
|
442
|
+
case "stage.all":
|
|
443
|
+
args = ["add", "-A"];
|
|
444
|
+
break;
|
|
445
|
+
case "unstage":
|
|
446
|
+
args = [
|
|
447
|
+
"restore",
|
|
448
|
+
"--staged",
|
|
449
|
+
"--",
|
|
450
|
+
path ?? ""
|
|
451
|
+
];
|
|
452
|
+
break;
|
|
453
|
+
case "unstage.all":
|
|
454
|
+
args = [
|
|
455
|
+
"restore",
|
|
456
|
+
"--staged",
|
|
457
|
+
"--",
|
|
458
|
+
"."
|
|
459
|
+
];
|
|
460
|
+
break;
|
|
461
|
+
case "discard":
|
|
462
|
+
args = value === "untracked" ? [
|
|
463
|
+
"clean",
|
|
464
|
+
"-f",
|
|
465
|
+
"--",
|
|
466
|
+
path ?? ""
|
|
467
|
+
] : [
|
|
468
|
+
"restore",
|
|
469
|
+
"--worktree",
|
|
470
|
+
"--",
|
|
471
|
+
path ?? ""
|
|
472
|
+
];
|
|
473
|
+
break;
|
|
474
|
+
case "discard.all": {
|
|
475
|
+
if ((await run("git", [
|
|
476
|
+
"rev-parse",
|
|
477
|
+
"--verify",
|
|
478
|
+
"HEAD"
|
|
479
|
+
], repository)).code === 0) {
|
|
480
|
+
const restore = await run("git", [
|
|
481
|
+
"restore",
|
|
482
|
+
"--source=HEAD",
|
|
483
|
+
"--staged",
|
|
484
|
+
"--worktree",
|
|
485
|
+
"--",
|
|
486
|
+
"."
|
|
487
|
+
], repository);
|
|
488
|
+
if (restore.code !== 0) throw new WorkbenchError("git-failed", restore.stderr || restore.stdout, 409);
|
|
489
|
+
}
|
|
490
|
+
const clean = await run("git", ["clean", "-fd"], repository);
|
|
491
|
+
if (clean.code !== 0) throw new WorkbenchError("git-failed", clean.stderr || clean.stdout, 409);
|
|
492
|
+
return gitStatus(cwd);
|
|
493
|
+
}
|
|
494
|
+
case "commit":
|
|
495
|
+
args = [
|
|
496
|
+
"commit",
|
|
497
|
+
"-m",
|
|
498
|
+
value ?? ""
|
|
499
|
+
];
|
|
500
|
+
break;
|
|
501
|
+
case "branch.create":
|
|
502
|
+
args = [
|
|
503
|
+
"switch",
|
|
504
|
+
"-c",
|
|
505
|
+
value ?? ""
|
|
506
|
+
];
|
|
507
|
+
break;
|
|
508
|
+
case "branch.switch":
|
|
509
|
+
args = ["switch", value ?? ""];
|
|
510
|
+
break;
|
|
511
|
+
case "branches": {
|
|
512
|
+
const result = await run("git", [
|
|
513
|
+
"for-each-ref",
|
|
514
|
+
"--format=%(refname:short)",
|
|
515
|
+
"refs/heads"
|
|
516
|
+
], repository);
|
|
517
|
+
if (result.code !== 0) throw new WorkbenchError("git-failed", result.stderr, 500);
|
|
518
|
+
return result.stdout.split("\n").filter(Boolean);
|
|
519
|
+
}
|
|
520
|
+
case "log": {
|
|
521
|
+
if ((await run("git", [
|
|
522
|
+
"rev-parse",
|
|
523
|
+
"--verify",
|
|
524
|
+
"HEAD"
|
|
525
|
+
], repository)).code !== 0) return [];
|
|
526
|
+
const result = await run("git", [
|
|
527
|
+
"log",
|
|
528
|
+
"--topo-order",
|
|
529
|
+
"--date=iso-strict",
|
|
530
|
+
"--decorate=short",
|
|
531
|
+
"--pretty=format:%H%x1f%P%x1f%an%x1f%aI%x1f%s%x1f%D%x00",
|
|
532
|
+
"-n",
|
|
533
|
+
"100"
|
|
534
|
+
], repository);
|
|
535
|
+
if (result.code !== 0) throw new WorkbenchError("git-failed", result.stderr || result.stdout, 500);
|
|
536
|
+
return result.stdout.split("\0").filter(Boolean).map((record) => {
|
|
537
|
+
const [rawHash = "", parents = "", author = "", date = "", subject = "", refs = ""] = record.split("");
|
|
538
|
+
return {
|
|
539
|
+
hash: rawHash.trim(),
|
|
540
|
+
parents: parents.split(" ").filter(Boolean),
|
|
541
|
+
author,
|
|
542
|
+
date,
|
|
543
|
+
subject,
|
|
544
|
+
refs: refs.split(",").map((item) => item.trim()).filter(Boolean)
|
|
545
|
+
};
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
case "commit.show": {
|
|
549
|
+
const result = await run("git", [
|
|
550
|
+
"show",
|
|
551
|
+
"--no-ext-diff",
|
|
552
|
+
"--find-renames",
|
|
553
|
+
"--format=fuller",
|
|
554
|
+
"--stat",
|
|
555
|
+
"--patch",
|
|
556
|
+
"--no-color",
|
|
557
|
+
validateCommitHash(value)
|
|
558
|
+
], repository);
|
|
559
|
+
if (result.code !== 0) throw new WorkbenchError("git-failed", result.stderr || result.stdout, 409);
|
|
560
|
+
return {
|
|
561
|
+
diff: result.stdout,
|
|
562
|
+
original: "",
|
|
563
|
+
modified: result.stdout
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
case "commit.files": return commitFiles(repository, validateCommitHash(value));
|
|
567
|
+
case "commit.file.diff": {
|
|
568
|
+
const hash = validateCommitHash(value);
|
|
569
|
+
const target = validateGitPath(repository, path);
|
|
570
|
+
const file = (await commitFiles(repository, hash)).find((item) => item.path === target);
|
|
571
|
+
if (file === void 0) throw new WorkbenchError("not-found", "file is not part of the commit", 404);
|
|
572
|
+
const parent = await run("git", [
|
|
573
|
+
"rev-parse",
|
|
574
|
+
"--verify",
|
|
575
|
+
`${hash}^1`
|
|
576
|
+
], repository);
|
|
577
|
+
const shown = parent.code === 0 ? await run("git", [
|
|
578
|
+
"diff",
|
|
579
|
+
"--no-ext-diff",
|
|
580
|
+
"--find-renames",
|
|
581
|
+
"--patch",
|
|
582
|
+
"--no-color",
|
|
583
|
+
parent.stdout.trim(),
|
|
584
|
+
hash,
|
|
585
|
+
"--",
|
|
586
|
+
file.previousPath ?? target,
|
|
587
|
+
target
|
|
588
|
+
], repository) : await run("git", [
|
|
589
|
+
"show",
|
|
590
|
+
"--no-ext-diff",
|
|
591
|
+
"--find-renames",
|
|
592
|
+
"--format=",
|
|
593
|
+
"--patch",
|
|
594
|
+
"--no-color",
|
|
595
|
+
hash,
|
|
596
|
+
"--",
|
|
597
|
+
file.previousPath ?? target,
|
|
598
|
+
target
|
|
599
|
+
], repository);
|
|
600
|
+
if (shown.code !== 0) throw new WorkbenchError("git-failed", shown.stderr || shown.stdout, 409);
|
|
601
|
+
const original = file.status === "A" || parent.code !== 0 ? "" : await gitObject(repository, `${parent.stdout.trim()}:${file.previousPath ?? target}`);
|
|
602
|
+
const modified = file.status === "D" ? "" : await gitObject(repository, `${hash}:${target}`);
|
|
603
|
+
return {
|
|
604
|
+
diff: shown.stdout,
|
|
605
|
+
original,
|
|
606
|
+
modified
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
case "diff":
|
|
610
|
+
args = value === "untracked" ? [
|
|
611
|
+
"diff",
|
|
612
|
+
"--no-index",
|
|
613
|
+
"--",
|
|
614
|
+
process.platform === "win32" ? "NUL" : "/dev/null",
|
|
615
|
+
path ?? ""
|
|
616
|
+
] : [
|
|
617
|
+
"diff",
|
|
618
|
+
...value === "staged" ? ["--cached"] : [],
|
|
619
|
+
"--",
|
|
620
|
+
path ?? ""
|
|
621
|
+
];
|
|
622
|
+
break;
|
|
623
|
+
default: throw new WorkbenchError("bad-action", `unknown Git action ${action}`);
|
|
624
|
+
}
|
|
625
|
+
if (args.some((arg) => arg === "") && action !== "commit") throw new WorkbenchError("bad-request", "path or value is required");
|
|
626
|
+
const result = await run("git", args, repository);
|
|
627
|
+
const expectedUntrackedDifference = action === "diff" && value === "untracked" && result.code === 1;
|
|
628
|
+
if (result.code !== 0 && !expectedUntrackedDifference) throw new WorkbenchError("git-failed", result.stderr || result.stdout, 409);
|
|
629
|
+
if (action !== "diff") return gitStatus(cwd);
|
|
630
|
+
const target = validateGitPath(repository, path);
|
|
631
|
+
const original = value === "untracked" ? "" : await gitObject(repository, `${value === "staged" ? "HEAD:" : ":"}${target}`);
|
|
632
|
+
const modified = value === "staged" ? await gitObject(repository, `:${target}`) : await readFile(resolve(repository, target), "utf8").catch(() => "");
|
|
633
|
+
return {
|
|
634
|
+
diff: result.stdout,
|
|
635
|
+
original,
|
|
636
|
+
modified
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
/** Resolve the official VS Code Codicon font shipped by @vscode/codicons. */
|
|
640
|
+
function codiconAsset() {
|
|
641
|
+
const packageRoot = dirname(require.resolve("@vscode/codicons/package.json"));
|
|
642
|
+
return {
|
|
643
|
+
path: resolve(packageRoot, "dist", "codicon.ttf"),
|
|
644
|
+
type: "font/ttf"
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
function monacoAsset(raw) {
|
|
648
|
+
const packageRoot = dirname(require.resolve("monaco-editor/package.json"));
|
|
649
|
+
const path = resolve(packageRoot, "min", raw);
|
|
650
|
+
if (!inside(resolve(packageRoot, "min"), path)) throw new WorkbenchError("asset-invalid", "invalid Monaco asset path", 403);
|
|
651
|
+
return {
|
|
652
|
+
path,
|
|
653
|
+
type: extname(path) === ".css" ? "text/css" : extname(path) === ".js" ? "text/javascript" : extname(path) === ".ttf" ? "font/ttf" : "application/octet-stream"
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
//#endregion
|
|
657
|
+
//#region src/index.ts
|
|
658
|
+
/** DSH host plugin for the VS Code workbench. */
|
|
659
|
+
const name = "dsh-vscode-workbench";
|
|
660
|
+
const inject = [
|
|
661
|
+
"webServer",
|
|
662
|
+
"sessions",
|
|
663
|
+
"webRuntime"
|
|
664
|
+
];
|
|
665
|
+
async function dispatch(ctx, method, body, signal) {
|
|
666
|
+
const cwd = cwdFor(ctx, stringField(body, "sessionId"));
|
|
667
|
+
switch (method) {
|
|
668
|
+
case "workspace": return {
|
|
669
|
+
cwd,
|
|
670
|
+
name: cwd.split(/[\\/]/).at(-1) || cwd
|
|
671
|
+
};
|
|
672
|
+
case "fs.list": return listFiles(cwd, stringField(body, "path"));
|
|
673
|
+
case "fs.read": return readDocument(cwd, stringField(body, "path"));
|
|
674
|
+
case "fs.save": return saveDocument(cwd, stringField(body, "path"), stringField(body, "content"), stringField(body, "revision"));
|
|
675
|
+
case "fs.create": return createEntry(cwd, stringField(body, "path"), body.kind === "directory" ? "directory" : "file").then(() => ({ ok: true }));
|
|
676
|
+
case "fs.rename": return renameEntry(cwd, stringField(body, "path"), stringField(body, "nextPath")).then(() => ({ ok: true }));
|
|
677
|
+
case "fs.delete": return deleteEntry(cwd, stringField(body, "path")).then(() => ({ ok: true }));
|
|
678
|
+
case "search": return searchWorkspace(cwd, body, signal);
|
|
679
|
+
case "git.status": return gitStatus(cwd);
|
|
680
|
+
case "git.action": return gitAction(cwd, stringField(body, "action"), typeof body.path === "string" ? body.path : void 0, typeof body.value === "string" ? body.value : void 0);
|
|
681
|
+
default: throw new WorkbenchError("not-found", `unknown workbench API method ${method}`, 404);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
function apply(ctx) {
|
|
685
|
+
ctx.effect(() => ctx.webServer.register({
|
|
686
|
+
kind: "exact",
|
|
687
|
+
path: "/dsh-vscode/codicons/codicon.ttf",
|
|
688
|
+
handler: async (req, res) => {
|
|
689
|
+
if (!trusted(req, ctx.webRuntime.trustedHosts)) {
|
|
690
|
+
res.writeHead(403);
|
|
691
|
+
res.end();
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
try {
|
|
695
|
+
const asset = codiconAsset();
|
|
696
|
+
res.writeHead(200, {
|
|
697
|
+
"content-type": asset.type,
|
|
698
|
+
"cache-control": "public, max-age=31536000, immutable"
|
|
699
|
+
});
|
|
700
|
+
res.end(await readFile(asset.path));
|
|
701
|
+
} catch (error) {
|
|
702
|
+
sendError(res, error);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}), "dsh-vscode-workbench: Codicon font");
|
|
706
|
+
ctx.effect(() => ctx.webServer.register({
|
|
707
|
+
kind: "prefix",
|
|
708
|
+
path: "/dsh-vscode/api",
|
|
709
|
+
handler: async (req, res) => {
|
|
710
|
+
if (!trusted(req, ctx.webRuntime.trustedHosts)) return send(res, 403, {
|
|
711
|
+
ok: false,
|
|
712
|
+
error: {
|
|
713
|
+
code: "forbidden",
|
|
714
|
+
message: "forbidden"
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
if (req.method !== "POST") return send(res, 405, {
|
|
718
|
+
ok: false,
|
|
719
|
+
error: {
|
|
720
|
+
code: "method",
|
|
721
|
+
message: "POST required"
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
try {
|
|
725
|
+
const controller = new AbortController();
|
|
726
|
+
req.once("aborted", () => controller.abort());
|
|
727
|
+
send(res, 200, {
|
|
728
|
+
ok: true,
|
|
729
|
+
value: await dispatch(ctx, new URL(req.url ?? "/", "http://dsh.local").pathname.slice(16), await readBody(req), controller.signal)
|
|
730
|
+
});
|
|
731
|
+
} catch (error) {
|
|
732
|
+
sendError(res, error);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}), "dsh-vscode-workbench: API");
|
|
736
|
+
ctx.effect(() => ctx.webServer.register({
|
|
737
|
+
kind: "prefix",
|
|
738
|
+
path: "/dsh-vscode/file",
|
|
739
|
+
handler: async (req, res) => {
|
|
740
|
+
if (!trusted(req, ctx.webRuntime.trustedHosts)) {
|
|
741
|
+
res.writeHead(403);
|
|
742
|
+
res.end();
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
try {
|
|
746
|
+
const url = new URL(req.url ?? "/", "http://dsh.local");
|
|
747
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
748
|
+
const path = url.searchParams.get("path");
|
|
749
|
+
if (sessionId === null || path === null) throw new WorkbenchError("bad-request", "sessionId and path are required");
|
|
750
|
+
const asset = await workspaceImage(cwdFor(ctx, sessionId), path);
|
|
751
|
+
res.writeHead(200, {
|
|
752
|
+
"content-type": asset.type,
|
|
753
|
+
"x-content-type-options": "nosniff",
|
|
754
|
+
"cache-control": "no-store"
|
|
755
|
+
});
|
|
756
|
+
res.end(await readFile(asset.path));
|
|
757
|
+
} catch (error) {
|
|
758
|
+
sendError(res, error);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}), "dsh-vscode-workbench: image preview");
|
|
762
|
+
ctx.effect(() => ctx.webServer.register({
|
|
763
|
+
kind: "prefix",
|
|
764
|
+
path: "/dsh-vscode/monaco",
|
|
765
|
+
handler: async (req, res) => {
|
|
766
|
+
if (!trusted(req, ctx.webRuntime.trustedHosts)) {
|
|
767
|
+
res.writeHead(403);
|
|
768
|
+
res.end();
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
try {
|
|
772
|
+
const asset = monacoAsset(decodeURIComponent(new URL(req.url ?? "/", "http://dsh.local").pathname.slice(19)));
|
|
773
|
+
res.writeHead(200, {
|
|
774
|
+
"content-type": asset.type,
|
|
775
|
+
"cache-control": "public, max-age=31536000, immutable"
|
|
776
|
+
});
|
|
777
|
+
res.end(await readFile(asset.path));
|
|
778
|
+
} catch (error) {
|
|
779
|
+
sendError(res, error);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}), "dsh-vscode-workbench: Monaco assets");
|
|
783
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
784
|
+
ctx.effect(() => ctx.webServer.registerUpgrade({
|
|
785
|
+
path: "/dsh-vscode/ws/files",
|
|
786
|
+
handler(req, socket, head) {
|
|
787
|
+
if (!trusted(req, ctx.webRuntime.trustedHosts)) {
|
|
788
|
+
socket.destroy();
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
792
|
+
const sessionId = new URL(req.url ?? "/", "http://dsh.local").searchParams.get("sessionId");
|
|
793
|
+
if (sessionId === null) {
|
|
794
|
+
ws.close(1008, "sessionId required");
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
const watcher = chokidar.watch(cwdFor(ctx, sessionId), {
|
|
798
|
+
ignoreInitial: true,
|
|
799
|
+
ignored: /(^|[/\\])\.git([/\\]|$)/,
|
|
800
|
+
awaitWriteFinish: {
|
|
801
|
+
stabilityThreshold: 100,
|
|
802
|
+
pollInterval: 25
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
let paths = /* @__PURE__ */ new Set();
|
|
806
|
+
let timer;
|
|
807
|
+
watcher.on("all", (_event, path) => {
|
|
808
|
+
paths.add(path);
|
|
809
|
+
clearTimeout(timer);
|
|
810
|
+
timer = setTimeout(() => {
|
|
811
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({
|
|
812
|
+
type: "files.changed",
|
|
813
|
+
paths: [...paths]
|
|
814
|
+
}));
|
|
815
|
+
paths = /* @__PURE__ */ new Set();
|
|
816
|
+
}, 150);
|
|
817
|
+
});
|
|
818
|
+
ws.on("close", () => {
|
|
819
|
+
clearTimeout(timer);
|
|
820
|
+
watcher.close();
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
}), "dsh-vscode-workbench: file watcher");
|
|
825
|
+
ctx.effect(() => () => {
|
|
826
|
+
wss.close();
|
|
827
|
+
}, "dsh-vscode-workbench: watcher teardown");
|
|
828
|
+
}
|
|
829
|
+
//#endregion
|
|
830
|
+
export { WorkbenchError, apply, inject, name };
|
|
831
|
+
|
|
832
|
+
//# sourceMappingURL=index.js.map
|