@youtyan/code-viewer 0.1.15 → 0.1.17
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 +34 -8
- package/dist/code-viewer.js +2235 -0
- package/package.json +23 -18
- package/web/app.js +514 -5
- package/web/style.css +44 -3
- package/web-src/routes.ts +0 -148
- package/web-src/server/cache.ts +0 -64
- package/web-src/server/dev-assets.ts +0 -37
- package/web-src/server/dev.ts +0 -100
- package/web-src/server/git.ts +0 -483
- package/web-src/server/preview.ts +0 -985
- package/web-src/server/range.ts +0 -8
- package/web-src/server/runtime.d.ts +0 -51
- package/web-src/server/search.ts +0 -104
- package/web-src/types.ts +0 -136
|
@@ -0,0 +1,2235 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// web-src/server/preview.ts
|
|
4
|
+
import { closeSync, constants, existsSync as existsSync3, lstatSync as lstatSync3, openSync, readFileSync as readFileSync2, realpathSync, statSync, unlinkSync, watch, writeFileSync } from "node:fs";
|
|
5
|
+
import { basename as basename2, dirname as dirname2, extname, join as join4, relative } from "node:path";
|
|
6
|
+
|
|
7
|
+
// web-src/routes.ts
|
|
8
|
+
var SPA_PATHS = ["/todif", "/todiff", "/file", "/help"];
|
|
9
|
+
var APP_ENTRY_PATHS = ["/", "/index.html"];
|
|
10
|
+
|
|
11
|
+
// web-src/server/cache.ts
|
|
12
|
+
import { lstatSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
var CACHE_TTL_MS = 1500;
|
|
15
|
+
var MAX_TIMED_CACHE_ENTRIES = 200;
|
|
16
|
+
function cacheFresh(cached, now = Date.now(), ttlMs = CACHE_TTL_MS) {
|
|
17
|
+
return !!cached && now - cached.storedAt <= ttlMs;
|
|
18
|
+
}
|
|
19
|
+
function setTimedCacheEntry(cache, key, value, now = Date.now(), maxEntries = MAX_TIMED_CACHE_ENTRIES) {
|
|
20
|
+
cache.set(key, { ...value, storedAt: now });
|
|
21
|
+
while (cache.size > maxEntries) {
|
|
22
|
+
const oldest = cache.keys().next().value;
|
|
23
|
+
if (oldest === undefined)
|
|
24
|
+
break;
|
|
25
|
+
cache.delete(oldest);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function worktreeFileSignature(path, cwd) {
|
|
29
|
+
try {
|
|
30
|
+
const stats = lstatSync(join(cwd, path));
|
|
31
|
+
const inode = "ino" in stats ? stats.ino : 0;
|
|
32
|
+
return `state:file|size:${stats.size}|mtime:${stats.mtimeMs}|ctime:${stats.ctimeMs}|ino:${inode}`;
|
|
33
|
+
} catch {
|
|
34
|
+
return "state:missing";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function fileDiffCacheKey(options) {
|
|
38
|
+
const worktreeTarget = options.range.from === "worktree" || !options.range.to || options.range.to === "worktree";
|
|
39
|
+
if (options.isUntracked && !worktreeTarget) {
|
|
40
|
+
throw new Error("untracked file diffs require a worktree range");
|
|
41
|
+
}
|
|
42
|
+
const signature = worktreeTarget ? `\x00${worktreeFileSignature(options.path, options.cwd)}` : "";
|
|
43
|
+
if (options.isUntracked) {
|
|
44
|
+
return `u\x00${options.path}${signature}\x00${options.extras.join("\x00")}`;
|
|
45
|
+
}
|
|
46
|
+
return `t\x00${options.path}\x00${options.oldPath || ""}${signature}\x00${[...options.extras, ...options.args].join("\x00")}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// web-src/server/dev-assets.ts
|
|
50
|
+
import { basename } from "node:path";
|
|
51
|
+
function startDevAssetReload(options) {
|
|
52
|
+
if (!options.enabled)
|
|
53
|
+
return false;
|
|
54
|
+
const watched = new Set(options.watchedFiles);
|
|
55
|
+
const setTimer = options.setTimeoutFn || setTimeout;
|
|
56
|
+
const clearTimer = options.clearTimeoutFn || clearTimeout;
|
|
57
|
+
const debounceMs = options.debounceMs ?? 150;
|
|
58
|
+
let timer = null;
|
|
59
|
+
options.watch(options.webRoot, { persistent: false }, (_event, filename) => {
|
|
60
|
+
if (!filename || !watched.has(basename(filename.toString())))
|
|
61
|
+
return;
|
|
62
|
+
if (timer)
|
|
63
|
+
clearTimer(timer);
|
|
64
|
+
timer = setTimer(() => {
|
|
65
|
+
timer = null;
|
|
66
|
+
options.sendReload();
|
|
67
|
+
}, debounceMs);
|
|
68
|
+
});
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// web-src/server/git.ts
|
|
73
|
+
import { existsSync, lstatSync as lstatSync2, readdirSync, readFileSync } from "node:fs";
|
|
74
|
+
|
|
75
|
+
// web-src/server/runtime.ts
|
|
76
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
77
|
+
import { createReadStream, promises as fs } from "node:fs";
|
|
78
|
+
import {
|
|
79
|
+
createServer
|
|
80
|
+
} from "node:http";
|
|
81
|
+
import { Readable } from "node:stream";
|
|
82
|
+
function runSync(args, cwd, options = {}) {
|
|
83
|
+
const proc = spawnSync(args[0], args.slice(1), {
|
|
84
|
+
cwd,
|
|
85
|
+
encoding: "buffer",
|
|
86
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
87
|
+
timeout: options.timeout,
|
|
88
|
+
killSignal: "SIGKILL"
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
code: proc.status ?? (proc.error ? 1 : 0),
|
|
92
|
+
stdout: new TextDecoder().decode(proc.stdout || new Uint8Array),
|
|
93
|
+
stderr: new TextDecoder().decode(proc.stderr || new Uint8Array)
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function runBytesSync(args, cwd, options = {}) {
|
|
97
|
+
const proc = spawnSync(args[0], args.slice(1), {
|
|
98
|
+
cwd,
|
|
99
|
+
encoding: "buffer",
|
|
100
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
101
|
+
timeout: options.timeout,
|
|
102
|
+
killSignal: "SIGKILL"
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
code: proc.status ?? (proc.error ? 1 : 0),
|
|
106
|
+
stdout: new Uint8Array(proc.stdout || new Uint8Array),
|
|
107
|
+
stderr: new TextDecoder().decode(proc.stderr || new Uint8Array)
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function spawnDetached(args) {
|
|
111
|
+
const child = spawn(args[0], args.slice(1), {
|
|
112
|
+
detached: true,
|
|
113
|
+
stdio: "ignore"
|
|
114
|
+
});
|
|
115
|
+
child.unref();
|
|
116
|
+
}
|
|
117
|
+
function spawnStream(args, cwd) {
|
|
118
|
+
const proc = spawn(args[0], args.slice(1), {
|
|
119
|
+
cwd,
|
|
120
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
121
|
+
});
|
|
122
|
+
return {
|
|
123
|
+
stream: Readable.toWeb(proc.stdout),
|
|
124
|
+
exited: new Promise((resolve) => proc.on("close", (code) => resolve(code ?? 1))),
|
|
125
|
+
kill: (signal) => proc.kill(signal)
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function fileReadableStream(path) {
|
|
129
|
+
return Readable.toWeb(createReadStream(path));
|
|
130
|
+
}
|
|
131
|
+
function fileByteRangeResponseBody(path, start, endInclusive) {
|
|
132
|
+
return Readable.toWeb(createReadStream(path, { start, end: endInclusive }));
|
|
133
|
+
}
|
|
134
|
+
async function readFileTextRange(path, start, endExclusive) {
|
|
135
|
+
const length = Math.max(0, endExclusive - start);
|
|
136
|
+
if (length === 0)
|
|
137
|
+
return "";
|
|
138
|
+
const handle = await fs.open(path, "r");
|
|
139
|
+
try {
|
|
140
|
+
const buffer = Buffer.alloc(length);
|
|
141
|
+
const result = await handle.read(buffer, 0, length, start);
|
|
142
|
+
return buffer.subarray(0, result.bytesRead).toString("utf8");
|
|
143
|
+
} finally {
|
|
144
|
+
await handle.close();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function startServer(options) {
|
|
148
|
+
const server = createServer(async (req, res) => {
|
|
149
|
+
try {
|
|
150
|
+
const request = nodeRequestToWeb(req, options.hostname, server.address());
|
|
151
|
+
const response = await options.fetch(request);
|
|
152
|
+
await writeWebResponse(res, response);
|
|
153
|
+
} catch {
|
|
154
|
+
if (!res.headersSent)
|
|
155
|
+
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
156
|
+
res.end("internal server error");
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
return new Promise((resolve, reject) => {
|
|
160
|
+
server.once("error", reject);
|
|
161
|
+
server.listen(options.port, options.hostname, () => {
|
|
162
|
+
server.off("error", reject);
|
|
163
|
+
server.on("error", (error) => {
|
|
164
|
+
console.error("[code-viewer] server error:", error);
|
|
165
|
+
});
|
|
166
|
+
const address = server.address();
|
|
167
|
+
const port = typeof address === "object" && address ? address.port : options.port;
|
|
168
|
+
resolve({ port });
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function nodeRequestToWeb(req, hostname, address) {
|
|
173
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
174
|
+
const host = req.headers.host || `${hostname}:${port}`;
|
|
175
|
+
const url = new URL(req.url || "/", `http://${host}`);
|
|
176
|
+
const headers = new Headers;
|
|
177
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
178
|
+
if (Array.isArray(value)) {
|
|
179
|
+
for (const item of value)
|
|
180
|
+
headers.append(key, item);
|
|
181
|
+
} else if (value !== undefined) {
|
|
182
|
+
headers.set(key, value);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const method = req.method || "GET";
|
|
186
|
+
const hasBody = method !== "GET" && method !== "HEAD";
|
|
187
|
+
return new Request(url, {
|
|
188
|
+
method,
|
|
189
|
+
headers,
|
|
190
|
+
body: hasBody ? Readable.toWeb(req) : undefined,
|
|
191
|
+
duplex: hasBody ? "half" : undefined
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
async function writeWebResponse(res, response) {
|
|
195
|
+
res.statusCode = response.status;
|
|
196
|
+
response.headers.forEach((value, key) => {
|
|
197
|
+
res.setHeader(key, value);
|
|
198
|
+
});
|
|
199
|
+
if (!response.body) {
|
|
200
|
+
res.end();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
await new Promise((resolve, reject) => {
|
|
204
|
+
const body = Readable.fromWeb(response.body);
|
|
205
|
+
let settled = false;
|
|
206
|
+
const settle = (fn) => {
|
|
207
|
+
if (settled)
|
|
208
|
+
return;
|
|
209
|
+
settled = true;
|
|
210
|
+
fn();
|
|
211
|
+
};
|
|
212
|
+
body.on("error", (error) => settle(() => {
|
|
213
|
+
res.destroy(error);
|
|
214
|
+
reject(error);
|
|
215
|
+
}));
|
|
216
|
+
res.on("finish", () => settle(resolve));
|
|
217
|
+
res.on("close", () => settle(() => {
|
|
218
|
+
body.destroy();
|
|
219
|
+
resolve();
|
|
220
|
+
}));
|
|
221
|
+
body.pipe(res);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// web-src/server/git.ts
|
|
226
|
+
import { join as join2 } from "node:path";
|
|
227
|
+
var WORKTREE_RECURSIVE_DEPTH_LIMIT = 32;
|
|
228
|
+
var WORKTREE_RECURSIVE_ENTRY_LIMIT = 50000;
|
|
229
|
+
var DEFAULT_WORKTREE_OMIT_DIR_NAMES = [
|
|
230
|
+
"node_modules",
|
|
231
|
+
".venv",
|
|
232
|
+
"venv",
|
|
233
|
+
".next",
|
|
234
|
+
".nuxt",
|
|
235
|
+
".svelte-kit",
|
|
236
|
+
".astro",
|
|
237
|
+
".vercel",
|
|
238
|
+
"dist",
|
|
239
|
+
"build",
|
|
240
|
+
"out",
|
|
241
|
+
"target",
|
|
242
|
+
".gradle",
|
|
243
|
+
"__pycache__",
|
|
244
|
+
".pytest_cache",
|
|
245
|
+
".tox",
|
|
246
|
+
".terraform",
|
|
247
|
+
".idea",
|
|
248
|
+
".vscode",
|
|
249
|
+
"vendor",
|
|
250
|
+
".cache",
|
|
251
|
+
"coverage",
|
|
252
|
+
"DerivedData",
|
|
253
|
+
"Pods",
|
|
254
|
+
"bin",
|
|
255
|
+
"obj"
|
|
256
|
+
];
|
|
257
|
+
function run(args, cwd) {
|
|
258
|
+
return runSync(args, cwd);
|
|
259
|
+
}
|
|
260
|
+
function runBytes(args, cwd) {
|
|
261
|
+
return runBytesSync(args, cwd);
|
|
262
|
+
}
|
|
263
|
+
function repoRoot(cwd) {
|
|
264
|
+
const res = run(["git", "rev-parse", "--show-toplevel"], cwd);
|
|
265
|
+
return res.code === 0 ? res.stdout.trimEnd() : null;
|
|
266
|
+
}
|
|
267
|
+
function currentBranch(cwd) {
|
|
268
|
+
const res = run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd);
|
|
269
|
+
return res.code === 0 ? res.stdout.trimEnd() : null;
|
|
270
|
+
}
|
|
271
|
+
function show(ref, path, cwd) {
|
|
272
|
+
return run(["git", "show", `${ref}:${path}`], cwd);
|
|
273
|
+
}
|
|
274
|
+
function showBytes(ref, path, cwd) {
|
|
275
|
+
return runBytes(["git", "show", `${ref}:${path}`], cwd);
|
|
276
|
+
}
|
|
277
|
+
function catFileBlobStream(oid, cwd) {
|
|
278
|
+
return spawnStream(["git", "cat-file", "blob", oid], cwd);
|
|
279
|
+
}
|
|
280
|
+
function objectSize(ref, path, cwd) {
|
|
281
|
+
const res = run(["git", "cat-file", "-s", `${ref}:${path}`], cwd);
|
|
282
|
+
return { code: res.code, size: Number(res.stdout.trim()) || 0, stderr: res.stderr };
|
|
283
|
+
}
|
|
284
|
+
function objectByteSize(oid, cwd) {
|
|
285
|
+
const res = run(["git", "cat-file", "-s", oid], cwd);
|
|
286
|
+
return { code: res.code, size: Number(res.stdout.trim()) || 0, stderr: res.stderr };
|
|
287
|
+
}
|
|
288
|
+
function objectId(ref, path, cwd) {
|
|
289
|
+
const res = run(["git", "rev-parse", "--verify", `${ref}:${path}`], cwd);
|
|
290
|
+
const oid = res.stdout.trim();
|
|
291
|
+
if (res.code !== 0 || !oid)
|
|
292
|
+
return { code: res.code || 1, oid: "", stderr: res.stderr };
|
|
293
|
+
const type = run(["git", "cat-file", "-t", oid], cwd);
|
|
294
|
+
if (type.code !== 0 || type.stdout.trim() !== "blob")
|
|
295
|
+
return { code: 1, oid: "", stderr: type.stderr };
|
|
296
|
+
return { code: 0, oid, stderr: "" };
|
|
297
|
+
}
|
|
298
|
+
function verifyTreeRef(ref, cwd) {
|
|
299
|
+
if (!ref || ref === "worktree")
|
|
300
|
+
return false;
|
|
301
|
+
if (ref.startsWith("-"))
|
|
302
|
+
return false;
|
|
303
|
+
const res = run(["git", "rev-parse", "--verify", `${ref}^{tree}`], cwd);
|
|
304
|
+
return res.code === 0;
|
|
305
|
+
}
|
|
306
|
+
function refs(cwd) {
|
|
307
|
+
const out = { branches: [], tags: [], commits: [], current: "" };
|
|
308
|
+
const branches = run([
|
|
309
|
+
"git",
|
|
310
|
+
"for-each-ref",
|
|
311
|
+
"--sort=-committerdate",
|
|
312
|
+
"--format=%(refname:short)",
|
|
313
|
+
"refs/heads",
|
|
314
|
+
"refs/remotes"
|
|
315
|
+
], cwd);
|
|
316
|
+
if (branches.code === 0) {
|
|
317
|
+
out.branches = branches.stdout.split(`
|
|
318
|
+
`).filter((line) => line && line !== "origin/HEAD");
|
|
319
|
+
}
|
|
320
|
+
const tags = run(["git", "for-each-ref", "--sort=-creatordate", "--format=%(refname:short)", "refs/tags"], cwd);
|
|
321
|
+
if (tags.code === 0)
|
|
322
|
+
out.tags = tags.stdout.split(`
|
|
323
|
+
`).filter(Boolean);
|
|
324
|
+
const commits = run(["git", "log", "-50", "--format=%h\t%s\t%an\t%ar"], cwd);
|
|
325
|
+
if (commits.code === 0)
|
|
326
|
+
out.commits = commits.stdout.split(`
|
|
327
|
+
`).filter(Boolean);
|
|
328
|
+
out.current = currentBranch(cwd) || "";
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
function nameStatus(args, cwd) {
|
|
332
|
+
const res = run([
|
|
333
|
+
"git",
|
|
334
|
+
"-c",
|
|
335
|
+
"core.quotepath=false",
|
|
336
|
+
"diff",
|
|
337
|
+
"--no-color",
|
|
338
|
+
"--no-ext-diff",
|
|
339
|
+
"--find-renames",
|
|
340
|
+
"--name-status",
|
|
341
|
+
"-z",
|
|
342
|
+
...args
|
|
343
|
+
], cwd);
|
|
344
|
+
if (res.code !== 0)
|
|
345
|
+
return [];
|
|
346
|
+
const parts = res.stdout.split("\x00");
|
|
347
|
+
const files = [];
|
|
348
|
+
for (let i = 0;i < parts.length; ) {
|
|
349
|
+
const status = parts[i++];
|
|
350
|
+
if (!status)
|
|
351
|
+
break;
|
|
352
|
+
const kind = status[0];
|
|
353
|
+
if (kind === "R" || kind === "C") {
|
|
354
|
+
const oldPath = parts[i++] || "";
|
|
355
|
+
const path = parts[i++] || "";
|
|
356
|
+
if (path)
|
|
357
|
+
files.push({ status: kind, old_path: oldPath, path, similarity: Number(status.slice(1)) || undefined });
|
|
358
|
+
} else {
|
|
359
|
+
const path = parts[i++] || "";
|
|
360
|
+
if (path)
|
|
361
|
+
files.push({ status: kind, path });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return files;
|
|
365
|
+
}
|
|
366
|
+
function numstatZ(args, cwd) {
|
|
367
|
+
const res = run([
|
|
368
|
+
"git",
|
|
369
|
+
"-c",
|
|
370
|
+
"core.quotepath=false",
|
|
371
|
+
"diff",
|
|
372
|
+
"--no-color",
|
|
373
|
+
"--no-ext-diff",
|
|
374
|
+
"--find-renames",
|
|
375
|
+
"--numstat",
|
|
376
|
+
"-z",
|
|
377
|
+
...args
|
|
378
|
+
], cwd);
|
|
379
|
+
if (res.code !== 0)
|
|
380
|
+
return [];
|
|
381
|
+
const parts = res.stdout.split("\x00");
|
|
382
|
+
const files = [];
|
|
383
|
+
for (let i = 0;i < parts.length; ) {
|
|
384
|
+
const rec = parts[i++];
|
|
385
|
+
if (!rec)
|
|
386
|
+
break;
|
|
387
|
+
const match = rec.match(/^(\S+)\t(\S+)\t(.*)$/);
|
|
388
|
+
if (!match)
|
|
389
|
+
break;
|
|
390
|
+
const [, add, del, rest] = match;
|
|
391
|
+
const binary = add === "-" && del === "-";
|
|
392
|
+
const additions = binary ? 0 : Number(add) || 0;
|
|
393
|
+
const deletions = binary ? 0 : Number(del) || 0;
|
|
394
|
+
if (rest === "") {
|
|
395
|
+
const oldPath = parts[i++] || "";
|
|
396
|
+
const path = parts[i++] || "";
|
|
397
|
+
if (path)
|
|
398
|
+
files.push({ old_path: oldPath, path, additions, deletions, binary });
|
|
399
|
+
} else {
|
|
400
|
+
files.push({ path: rest, additions, deletions, binary });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return files;
|
|
404
|
+
}
|
|
405
|
+
function untracked(cwd, path = "") {
|
|
406
|
+
const args = ["git", "ls-files", "--others", "--exclude-standard"];
|
|
407
|
+
if (path)
|
|
408
|
+
args.push("--", `${path}/`);
|
|
409
|
+
const res = run(args, cwd);
|
|
410
|
+
return res.code === 0 ? res.stdout.split(`
|
|
411
|
+
`).filter(Boolean) : [];
|
|
412
|
+
}
|
|
413
|
+
function normalizeTreePath(path) {
|
|
414
|
+
return path.replace(/^\/+|\/+$/g, "");
|
|
415
|
+
}
|
|
416
|
+
function sortTreeEntries(entries) {
|
|
417
|
+
return [...entries].sort((a, b) => {
|
|
418
|
+
if (a.type !== b.type)
|
|
419
|
+
return a.type === "tree" ? -1 : 1;
|
|
420
|
+
return a.name.localeCompare(b.name);
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
function omittedWorktreeDirectoryReason(name, omitDirNames) {
|
|
424
|
+
if (name === ".git")
|
|
425
|
+
return "internal";
|
|
426
|
+
return omitDirNames.has(name) ? "heavy" : undefined;
|
|
427
|
+
}
|
|
428
|
+
function worktreeEntryFromDirent(base, dir, name, isDirectory, omitDirNames) {
|
|
429
|
+
const entryPath = base ? `${base}/${name}` : name;
|
|
430
|
+
const type = isDirectory ? hasDotGitEntry(join2(dir, name)) ? "commit" : "tree" : "blob";
|
|
431
|
+
const omittedReason = type === "tree" ? omittedWorktreeDirectoryReason(name, omitDirNames) : undefined;
|
|
432
|
+
return omittedReason ? {
|
|
433
|
+
name,
|
|
434
|
+
path: entryPath,
|
|
435
|
+
type,
|
|
436
|
+
children_omitted: true,
|
|
437
|
+
children_omitted_reason: omittedReason
|
|
438
|
+
} : { name, path: entryPath, type };
|
|
439
|
+
}
|
|
440
|
+
function worktreeFilesystemEntries(cwd, path, recursive, omitDirNames = DEFAULT_WORKTREE_OMIT_DIR_NAMES) {
|
|
441
|
+
const base = normalizeTreePath(path);
|
|
442
|
+
const root = join2(cwd, base);
|
|
443
|
+
const omitDirNameSet = new Set(omitDirNames);
|
|
444
|
+
let directEntries;
|
|
445
|
+
try {
|
|
446
|
+
const dirents = readdirSync(root, { withFileTypes: true });
|
|
447
|
+
directEntries = sortTreeEntries(dirents.map((entry) => worktreeEntryFromDirent(base, root, entry.name, entry.isDirectory(), omitDirNameSet)));
|
|
448
|
+
} catch {
|
|
449
|
+
return [];
|
|
450
|
+
}
|
|
451
|
+
if (!recursive)
|
|
452
|
+
return directEntries;
|
|
453
|
+
const fileEntries = [];
|
|
454
|
+
let truncated = false;
|
|
455
|
+
const pushRecursiveEntry = (entry) => {
|
|
456
|
+
if (fileEntries.length >= WORKTREE_RECURSIVE_ENTRY_LIMIT) {
|
|
457
|
+
if (!truncated) {
|
|
458
|
+
fileEntries.push({
|
|
459
|
+
name: "more...",
|
|
460
|
+
path: "__code_viewer_truncated__",
|
|
461
|
+
type: "tree",
|
|
462
|
+
children_omitted: true,
|
|
463
|
+
children_omitted_reason: "truncated"
|
|
464
|
+
});
|
|
465
|
+
truncated = true;
|
|
466
|
+
}
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
fileEntries.push(entry);
|
|
470
|
+
return true;
|
|
471
|
+
};
|
|
472
|
+
const walk = (dir, prefix, depth) => {
|
|
473
|
+
if (truncated)
|
|
474
|
+
return;
|
|
475
|
+
if (depth >= WORKTREE_RECURSIVE_DEPTH_LIMIT)
|
|
476
|
+
return;
|
|
477
|
+
let entries;
|
|
478
|
+
try {
|
|
479
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
480
|
+
} catch {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
for (const entry of entries) {
|
|
484
|
+
const entryPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
485
|
+
const full = join2(dir, entry.name);
|
|
486
|
+
if (entry.isDirectory()) {
|
|
487
|
+
const omittedReason = omittedWorktreeDirectoryReason(entry.name, omitDirNameSet);
|
|
488
|
+
if (omittedReason) {
|
|
489
|
+
if (!pushRecursiveEntry({
|
|
490
|
+
name: entry.name,
|
|
491
|
+
path: entryPath,
|
|
492
|
+
type: "tree",
|
|
493
|
+
children_omitted: true,
|
|
494
|
+
children_omitted_reason: omittedReason
|
|
495
|
+
}))
|
|
496
|
+
return;
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (hasDotGitEntry(full))
|
|
500
|
+
continue;
|
|
501
|
+
walk(full, entryPath, depth + 1);
|
|
502
|
+
} else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
503
|
+
if (!pushRecursiveEntry({ name: entry.name, path: entryPath, type: "blob" }))
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
walk(root, base, 0);
|
|
509
|
+
return combineDirectAndRecursiveFiles(directEntries, fileEntries.sort((a, b) => a.path.localeCompare(b.path)));
|
|
510
|
+
}
|
|
511
|
+
function hasDotGitEntry(dir) {
|
|
512
|
+
try {
|
|
513
|
+
lstatSync2(join2(dir, ".git"));
|
|
514
|
+
return true;
|
|
515
|
+
} catch (err) {
|
|
516
|
+
return !!err && typeof err === "object" && "code" in err && err.code !== "ENOENT";
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
function gitTreeEntries(ref, path, cwd, recursive) {
|
|
520
|
+
const base = normalizeTreePath(path);
|
|
521
|
+
const args = ["git", "-c", "core.quotepath=false", "ls-tree"];
|
|
522
|
+
if (recursive)
|
|
523
|
+
args.push("-r");
|
|
524
|
+
args.push("-z", "--full-tree", ref, "--");
|
|
525
|
+
if (base)
|
|
526
|
+
args.push(`${base}/`);
|
|
527
|
+
const res = run(args, cwd);
|
|
528
|
+
if (res.code !== 0)
|
|
529
|
+
return { code: res.code, entries: [], stderr: res.stderr };
|
|
530
|
+
const allowedTypes = recursive ? "blob|commit" : "tree|blob|commit";
|
|
531
|
+
let entries = res.stdout.split("\x00").filter(Boolean).map((rec) => {
|
|
532
|
+
const match = rec.match(new RegExp(`^\\d+\\s+(${allowedTypes})\\s+[0-9a-fA-F]+\\t(.+)$`));
|
|
533
|
+
if (!match)
|
|
534
|
+
return null;
|
|
535
|
+
const entryPath = match[2];
|
|
536
|
+
return { name: entryPath.split("/").pop() || entryPath, path: entryPath, type: match[1] };
|
|
537
|
+
}).filter((entry) => !!entry);
|
|
538
|
+
if (recursive)
|
|
539
|
+
entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
540
|
+
else
|
|
541
|
+
entries = sortTreeEntries(entries);
|
|
542
|
+
return { code: 0, entries, stderr: "" };
|
|
543
|
+
}
|
|
544
|
+
function combineDirectAndRecursiveFiles(directEntries, fileEntries) {
|
|
545
|
+
const seen = new Set(directEntries.map((entry) => entry.path));
|
|
546
|
+
return [
|
|
547
|
+
...directEntries,
|
|
548
|
+
...fileEntries.filter((entry) => !seen.has(entry.path))
|
|
549
|
+
];
|
|
550
|
+
}
|
|
551
|
+
function worktreeFiles(cwd) {
|
|
552
|
+
return listTree("worktree", "", cwd, { recursive: true }).entries;
|
|
553
|
+
}
|
|
554
|
+
function listTree(ref, path, cwd, options = {}) {
|
|
555
|
+
const base = normalizeTreePath(path);
|
|
556
|
+
if (ref === "worktree") {
|
|
557
|
+
return { code: 0, entries: worktreeFilesystemEntries(cwd, base, !!options.recursive, options.omitDirNames), stderr: "" };
|
|
558
|
+
}
|
|
559
|
+
const direct = gitTreeEntries(ref, base, cwd, false);
|
|
560
|
+
if (direct.code !== 0 || !options.recursive)
|
|
561
|
+
return direct;
|
|
562
|
+
const recursive = gitTreeEntries(ref, base, cwd, true);
|
|
563
|
+
if (recursive.code !== 0)
|
|
564
|
+
return recursive;
|
|
565
|
+
return { code: 0, entries: combineDirectAndRecursiveFiles(direct.entries, recursive.entries), stderr: "" };
|
|
566
|
+
}
|
|
567
|
+
function untrackedMeta(cwd) {
|
|
568
|
+
return untracked(cwd).map((path) => {
|
|
569
|
+
const full = join2(cwd, path);
|
|
570
|
+
let binary = false;
|
|
571
|
+
let lines = 0;
|
|
572
|
+
if (existsSync(full)) {
|
|
573
|
+
const data = readFileSync(full);
|
|
574
|
+
const probe = data.subarray(0, 8192);
|
|
575
|
+
binary = probe.includes(0);
|
|
576
|
+
if (!binary)
|
|
577
|
+
lines = data.toString("utf8").split(`
|
|
578
|
+
`).length - 1;
|
|
579
|
+
}
|
|
580
|
+
return { path, status: "A", additions: binary ? 0 : lines, deletions: 0, binary, untracked: true };
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
function fileMeta(args, cwd, includeUntracked = false) {
|
|
584
|
+
const ns = nameStatus(args, cwd);
|
|
585
|
+
const nm = numstatZ(args, cwd);
|
|
586
|
+
const byPath = new Map(nm.map((file) => [file.path, file]));
|
|
587
|
+
const files = ns.map((file) => {
|
|
588
|
+
const stats = byPath.get(file.path);
|
|
589
|
+
return {
|
|
590
|
+
...file,
|
|
591
|
+
additions: stats?.additions || 0,
|
|
592
|
+
deletions: stats?.deletions || 0,
|
|
593
|
+
binary: stats?.binary || false
|
|
594
|
+
};
|
|
595
|
+
});
|
|
596
|
+
return includeUntracked ? files.concat(untrackedMeta(cwd)) : files;
|
|
597
|
+
}
|
|
598
|
+
function fileDiffText(args, path, cwd) {
|
|
599
|
+
const paths = Array.isArray(path) ? path : [path];
|
|
600
|
+
return run([
|
|
601
|
+
"git",
|
|
602
|
+
"-c",
|
|
603
|
+
"core.quotepath=false",
|
|
604
|
+
"diff",
|
|
605
|
+
"--no-color",
|
|
606
|
+
"--no-ext-diff",
|
|
607
|
+
"--find-renames",
|
|
608
|
+
...args,
|
|
609
|
+
"--",
|
|
610
|
+
...paths
|
|
611
|
+
], cwd);
|
|
612
|
+
}
|
|
613
|
+
function untrackedFileDiff(extras, path, cwd) {
|
|
614
|
+
return run([
|
|
615
|
+
"git",
|
|
616
|
+
"-c",
|
|
617
|
+
"core.quotepath=false",
|
|
618
|
+
"diff",
|
|
619
|
+
"--no-color",
|
|
620
|
+
"--no-ext-diff",
|
|
621
|
+
"--no-index",
|
|
622
|
+
...extras,
|
|
623
|
+
"/dev/null",
|
|
624
|
+
path
|
|
625
|
+
], cwd);
|
|
626
|
+
}
|
|
627
|
+
function splitHunks(diffText) {
|
|
628
|
+
if (!diffText)
|
|
629
|
+
return { header: "", hunks: [] };
|
|
630
|
+
const first = diffText.startsWith("@@") ? 0 : diffText.indexOf(`
|
|
631
|
+
@@`) + 1;
|
|
632
|
+
if (first <= 0)
|
|
633
|
+
return { header: diffText, hunks: [] };
|
|
634
|
+
const header = diffText.slice(0, first);
|
|
635
|
+
const hunks = [];
|
|
636
|
+
let cur = first;
|
|
637
|
+
while (cur < diffText.length) {
|
|
638
|
+
const next = diffText.indexOf(`
|
|
639
|
+
@@`, cur + 1);
|
|
640
|
+
const end = next >= 0 ? next : diffText.length;
|
|
641
|
+
hunks.push(diffText.slice(cur, end));
|
|
642
|
+
if (next < 0)
|
|
643
|
+
break;
|
|
644
|
+
cur = next + 1;
|
|
645
|
+
}
|
|
646
|
+
return { header, hunks };
|
|
647
|
+
}
|
|
648
|
+
function truncateToNHunks(diffText, n, maxLines = Number.POSITIVE_INFINITY) {
|
|
649
|
+
const { header, hunks } = splitHunks(diffText);
|
|
650
|
+
if (hunks.length === 0) {
|
|
651
|
+
const lines = diffText.split(`
|
|
652
|
+
`);
|
|
653
|
+
const lineTruncated2 = Number.isFinite(maxLines) && lines.length > maxLines;
|
|
654
|
+
const text2 = lineTruncated2 ? lines.slice(0, maxLines).join(`
|
|
655
|
+
`) : diffText;
|
|
656
|
+
return {
|
|
657
|
+
text: text2,
|
|
658
|
+
totalHunks: 0,
|
|
659
|
+
renderedHunks: 0,
|
|
660
|
+
lineCount: (text2.match(/\n/g) || []).length,
|
|
661
|
+
lineTruncated: lineTruncated2
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
const maxHunks = Math.min(n, hunks.length);
|
|
665
|
+
const rendered = [];
|
|
666
|
+
let renderedHunks = 0;
|
|
667
|
+
let usedLines = (header.match(/\n/g) || []).length;
|
|
668
|
+
let lineTruncated = false;
|
|
669
|
+
for (let index = 0;index < maxHunks; index++) {
|
|
670
|
+
const hunk = hunks[index];
|
|
671
|
+
const lines = hunk.split(`
|
|
672
|
+
`);
|
|
673
|
+
const separatorLines = rendered.length > 0 ? 1 : 0;
|
|
674
|
+
const remaining = maxLines - usedLines - separatorLines;
|
|
675
|
+
if (remaining <= 0) {
|
|
676
|
+
lineTruncated = true;
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
if (Number.isFinite(maxLines) && lines.length > remaining) {
|
|
680
|
+
rendered.push(lines.slice(0, remaining).join(`
|
|
681
|
+
`));
|
|
682
|
+
renderedHunks++;
|
|
683
|
+
lineTruncated = true;
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
rendered.push(hunk);
|
|
687
|
+
renderedHunks++;
|
|
688
|
+
usedLines += separatorLines + lines.length;
|
|
689
|
+
}
|
|
690
|
+
const text = header + rendered.join(`
|
|
691
|
+
`);
|
|
692
|
+
return {
|
|
693
|
+
text,
|
|
694
|
+
totalHunks: hunks.length,
|
|
695
|
+
renderedHunks,
|
|
696
|
+
lineCount: (text.match(/\n/g) || []).length,
|
|
697
|
+
lineTruncated
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// web-src/server/range.ts
|
|
702
|
+
function isSameWorktreeRange(range) {
|
|
703
|
+
return range.from === "worktree" && range.to === "worktree";
|
|
704
|
+
}
|
|
705
|
+
function parseHttpByteRange(header, size) {
|
|
706
|
+
if (!header)
|
|
707
|
+
return { kind: "invalid" };
|
|
708
|
+
if (size < 1)
|
|
709
|
+
return { kind: "unsatisfiable" };
|
|
710
|
+
const match = header.match(/^bytes=(\d*)-(\d*)$/);
|
|
711
|
+
if (!match)
|
|
712
|
+
return { kind: "invalid" };
|
|
713
|
+
const [, rawStart, rawEnd] = match;
|
|
714
|
+
if (!rawStart && !rawEnd)
|
|
715
|
+
return { kind: "invalid" };
|
|
716
|
+
let start;
|
|
717
|
+
let end;
|
|
718
|
+
if (!rawStart) {
|
|
719
|
+
const suffixLength = Number(rawEnd);
|
|
720
|
+
if (!Number.isSafeInteger(suffixLength) || suffixLength < 1)
|
|
721
|
+
return { kind: "unsatisfiable" };
|
|
722
|
+
start = Math.max(0, size - suffixLength);
|
|
723
|
+
end = size - 1;
|
|
724
|
+
} else {
|
|
725
|
+
start = Number(rawStart);
|
|
726
|
+
end = rawEnd ? Number(rawEnd) : size - 1;
|
|
727
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end))
|
|
728
|
+
return { kind: "invalid" };
|
|
729
|
+
if (end >= size)
|
|
730
|
+
end = size - 1;
|
|
731
|
+
}
|
|
732
|
+
if (start < 0 || end < start || start >= size)
|
|
733
|
+
return { kind: "unsatisfiable" };
|
|
734
|
+
return { kind: "range", range: { start, end } };
|
|
735
|
+
}
|
|
736
|
+
async function collectLineRangeFromStream(stream, start, end) {
|
|
737
|
+
const reader = stream.getReader();
|
|
738
|
+
const decoder = new TextDecoder;
|
|
739
|
+
const lines = [];
|
|
740
|
+
let lineNo = 1;
|
|
741
|
+
let pending = "";
|
|
742
|
+
let hasMore = false;
|
|
743
|
+
const pushLine = (line) => {
|
|
744
|
+
if (line.endsWith("\r"))
|
|
745
|
+
line = line.slice(0, -1);
|
|
746
|
+
if (lineNo >= start && lineNo <= end)
|
|
747
|
+
lines.push(line);
|
|
748
|
+
else if (lineNo > end)
|
|
749
|
+
hasMore = true;
|
|
750
|
+
lineNo++;
|
|
751
|
+
};
|
|
752
|
+
while (!hasMore) {
|
|
753
|
+
const chunk = await reader.read();
|
|
754
|
+
if (chunk.done)
|
|
755
|
+
break;
|
|
756
|
+
pending += decoder.decode(chunk.value, { stream: true });
|
|
757
|
+
let newline = pending.indexOf(`
|
|
758
|
+
`);
|
|
759
|
+
while (newline !== -1) {
|
|
760
|
+
pushLine(pending.slice(0, newline));
|
|
761
|
+
pending = pending.slice(newline + 1);
|
|
762
|
+
if (hasMore)
|
|
763
|
+
break;
|
|
764
|
+
newline = pending.indexOf(`
|
|
765
|
+
`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
if (hasMore) {
|
|
769
|
+
try {
|
|
770
|
+
await reader.cancel();
|
|
771
|
+
} catch {}
|
|
772
|
+
return { lines, total: lineNo - 1, complete: false };
|
|
773
|
+
}
|
|
774
|
+
pending += decoder.decode();
|
|
775
|
+
if (pending.length > 0)
|
|
776
|
+
pushLine(pending);
|
|
777
|
+
if (hasMore)
|
|
778
|
+
return { lines, total: lineNo - 1, complete: false };
|
|
779
|
+
return { lines, total: Math.max(0, lineNo - 1), complete: true };
|
|
780
|
+
}
|
|
781
|
+
async function buildLineOffsetIndexFromStream(stream, size) {
|
|
782
|
+
const reader = stream.getReader();
|
|
783
|
+
const builder = createLineOffsetIndexBuilder(size);
|
|
784
|
+
let offset = 0;
|
|
785
|
+
let lastByte = -1;
|
|
786
|
+
while (true) {
|
|
787
|
+
const chunk = await reader.read();
|
|
788
|
+
if (chunk.done)
|
|
789
|
+
break;
|
|
790
|
+
const bytes = chunk.value;
|
|
791
|
+
for (let index = 0;index < bytes.length; index++) {
|
|
792
|
+
const byte = bytes[index];
|
|
793
|
+
if (byte === 10)
|
|
794
|
+
builder.push(offset + index);
|
|
795
|
+
lastByte = byte;
|
|
796
|
+
}
|
|
797
|
+
offset += bytes.length;
|
|
798
|
+
}
|
|
799
|
+
return builder.finish(offset, offset > 0 && lastByte !== 10);
|
|
800
|
+
}
|
|
801
|
+
async function collectByteRangeFromStream(stream, start, endExclusive) {
|
|
802
|
+
const reader = stream.getReader();
|
|
803
|
+
const chunks = [];
|
|
804
|
+
let offset = 0;
|
|
805
|
+
let total = 0;
|
|
806
|
+
while (offset < endExclusive) {
|
|
807
|
+
const chunk = await reader.read();
|
|
808
|
+
if (chunk.done)
|
|
809
|
+
break;
|
|
810
|
+
const chunkStart = offset;
|
|
811
|
+
const chunkEnd = offset + chunk.value.byteLength;
|
|
812
|
+
if (chunkEnd > start && chunkStart < endExclusive) {
|
|
813
|
+
const sliceStart = Math.max(0, start - chunkStart);
|
|
814
|
+
const sliceEnd = Math.min(chunk.value.byteLength, endExclusive - chunkStart);
|
|
815
|
+
const slice = chunk.value.subarray(sliceStart, sliceEnd);
|
|
816
|
+
chunks.push(slice);
|
|
817
|
+
total += slice.byteLength;
|
|
818
|
+
}
|
|
819
|
+
offset = chunkEnd;
|
|
820
|
+
}
|
|
821
|
+
try {
|
|
822
|
+
await reader.cancel();
|
|
823
|
+
} catch {}
|
|
824
|
+
if (chunks.length === 1)
|
|
825
|
+
return chunks[0];
|
|
826
|
+
const bytes = new Uint8Array(total);
|
|
827
|
+
let writeOffset = 0;
|
|
828
|
+
for (const chunk of chunks) {
|
|
829
|
+
bytes.set(chunk, writeOffset);
|
|
830
|
+
writeOffset += chunk.byteLength;
|
|
831
|
+
}
|
|
832
|
+
return bytes;
|
|
833
|
+
}
|
|
834
|
+
async function collectBytesWithLineOffsetIndexFromStream(stream, sizeHint) {
|
|
835
|
+
const reader = stream.getReader();
|
|
836
|
+
const builder = createLineOffsetIndexBuilder(sizeHint);
|
|
837
|
+
const chunks = [];
|
|
838
|
+
let offset = 0;
|
|
839
|
+
let lastByte = -1;
|
|
840
|
+
while (true) {
|
|
841
|
+
const chunk = await reader.read();
|
|
842
|
+
if (chunk.done)
|
|
843
|
+
break;
|
|
844
|
+
const bytes = chunk.value;
|
|
845
|
+
chunks.push(bytes);
|
|
846
|
+
for (let index = 0;index < bytes.length; index++) {
|
|
847
|
+
const byte = bytes[index];
|
|
848
|
+
if (byte === 10)
|
|
849
|
+
builder.push(offset + index);
|
|
850
|
+
lastByte = byte;
|
|
851
|
+
}
|
|
852
|
+
offset += bytes.length;
|
|
853
|
+
}
|
|
854
|
+
const collected = new Uint8Array(offset);
|
|
855
|
+
let writeOffset = 0;
|
|
856
|
+
for (const chunk of chunks) {
|
|
857
|
+
collected.set(chunk, writeOffset);
|
|
858
|
+
writeOffset += chunk.byteLength;
|
|
859
|
+
}
|
|
860
|
+
return {
|
|
861
|
+
bytes: collected,
|
|
862
|
+
index: builder.finish(offset, offset > 0 && lastByte !== 10)
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
function createLineOffsetIndexBuilder(size) {
|
|
866
|
+
const useFloat64 = size > 4294967295;
|
|
867
|
+
let capacity = 1024;
|
|
868
|
+
let length = 0;
|
|
869
|
+
let offsets = useFloat64 ? new Float64Array(capacity) : new Uint32Array(capacity);
|
|
870
|
+
const grow = () => {
|
|
871
|
+
capacity *= 2;
|
|
872
|
+
const next = useFloat64 ? new Float64Array(capacity) : new Uint32Array(capacity);
|
|
873
|
+
next.set(offsets);
|
|
874
|
+
offsets = next;
|
|
875
|
+
};
|
|
876
|
+
return {
|
|
877
|
+
push(offset) {
|
|
878
|
+
if (length >= capacity)
|
|
879
|
+
grow();
|
|
880
|
+
offsets[length++] = offset;
|
|
881
|
+
},
|
|
882
|
+
finish(totalSize, hasTrailingLine) {
|
|
883
|
+
return {
|
|
884
|
+
size: totalSize,
|
|
885
|
+
total: length + (hasTrailingLine ? 1 : 0),
|
|
886
|
+
newlines: offsets.slice(0, length)
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
function lineByteRangeForIndex(index, start, end) {
|
|
892
|
+
const normalizedStart = Math.max(1, Math.floor(start));
|
|
893
|
+
const normalizedEnd = Math.max(normalizedStart, Math.floor(end));
|
|
894
|
+
if (normalizedStart > index.total)
|
|
895
|
+
return null;
|
|
896
|
+
const lastLine = Math.min(normalizedEnd, index.total);
|
|
897
|
+
const byteStart = normalizedStart <= 1 ? 0 : index.newlines[normalizedStart - 2] + 1;
|
|
898
|
+
const byteEnd = lastLine <= index.newlines.length ? index.newlines[lastLine - 1] : index.size;
|
|
899
|
+
return { start: byteStart, endExclusive: byteEnd };
|
|
900
|
+
}
|
|
901
|
+
function collectLineRangeFromIndexedText(text, index, start, end) {
|
|
902
|
+
const normalizedStart = Math.max(1, Math.floor(start));
|
|
903
|
+
const normalizedEnd = Math.max(normalizedStart, Math.floor(end));
|
|
904
|
+
if (normalizedStart > index.total)
|
|
905
|
+
return { lines: [], total: index.total, complete: true };
|
|
906
|
+
const expectedLines = Math.min(normalizedEnd, index.total) - normalizedStart + 1;
|
|
907
|
+
const lines = text.length ? text.split(`
|
|
908
|
+
`).map((line) => line.endsWith("\r") ? line.slice(0, -1) : line) : Array.from({ length: expectedLines }, () => "");
|
|
909
|
+
return { lines, total: index.total, complete: end >= index.total };
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// web-src/server/root.ts
|
|
913
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
914
|
+
import { dirname, join as join3, normalize } from "node:path";
|
|
915
|
+
import { fileURLToPath } from "node:url";
|
|
916
|
+
function findRoot(start) {
|
|
917
|
+
let current = start;
|
|
918
|
+
for (let i = 0;i < 5; i++) {
|
|
919
|
+
if (existsSync2(join3(current, "package.json")) && existsSync2(join3(current, "web"))) {
|
|
920
|
+
return normalize(current);
|
|
921
|
+
}
|
|
922
|
+
const parent = dirname(current);
|
|
923
|
+
if (parent === current)
|
|
924
|
+
break;
|
|
925
|
+
current = parent;
|
|
926
|
+
}
|
|
927
|
+
return normalize(join3(start, "..", ".."));
|
|
928
|
+
}
|
|
929
|
+
var ROOT = findRoot(dirname(fileURLToPath(import.meta.url)));
|
|
930
|
+
|
|
931
|
+
// web-src/server/search.ts
|
|
932
|
+
var GREP_DEFAULT_MAX = 200;
|
|
933
|
+
var GREP_ABSOLUTE_MAX = 500;
|
|
934
|
+
var GREP_MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
935
|
+
var FILE_SEARCH_ABSOLUTE_MAX = 50000;
|
|
936
|
+
function normalizeGrepMax(value) {
|
|
937
|
+
const parsed = Number(value || "");
|
|
938
|
+
if (!Number.isInteger(parsed) || parsed <= 0)
|
|
939
|
+
return GREP_DEFAULT_MAX;
|
|
940
|
+
return Math.min(parsed, GREP_ABSOLUTE_MAX);
|
|
941
|
+
}
|
|
942
|
+
function isSkippableSearchPath(path, omitDirNames = []) {
|
|
943
|
+
const omitDirs = new Set(omitDirNames.map((name) => name.toLowerCase()));
|
|
944
|
+
return path.split(/[\\/]+/).some((part) => {
|
|
945
|
+
const lower = part.toLowerCase();
|
|
946
|
+
return lower === ".git" || omitDirs.has(lower);
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
function fixedStringLineMatches(path, text, query, max) {
|
|
950
|
+
const needle = query.toLowerCase();
|
|
951
|
+
if (!needle)
|
|
952
|
+
return [];
|
|
953
|
+
const matches = [];
|
|
954
|
+
const lines = text.split(`
|
|
955
|
+
`);
|
|
956
|
+
for (let i = 0;i < lines.length && matches.length < max; i++) {
|
|
957
|
+
const line = lines[i];
|
|
958
|
+
const column = line.toLowerCase().indexOf(needle);
|
|
959
|
+
if (column < 0)
|
|
960
|
+
continue;
|
|
961
|
+
matches.push({
|
|
962
|
+
path,
|
|
963
|
+
line: i + 1,
|
|
964
|
+
column: column + 1,
|
|
965
|
+
preview: line.slice(0, 500)
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
return matches;
|
|
969
|
+
}
|
|
970
|
+
function buildFileSearchList(ref, generation, entries) {
|
|
971
|
+
const files = entries.filter((entry) => entry.type === "blob" || entry.type === "commit").slice(0, FILE_SEARCH_ABSOLUTE_MAX).map((entry) => ({ path: entry.path, type: entry.type }));
|
|
972
|
+
return {
|
|
973
|
+
ref,
|
|
974
|
+
generation,
|
|
975
|
+
files,
|
|
976
|
+
truncated: entries.length > FILE_SEARCH_ABSOLUTE_MAX
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
function buildRgArgs(query, max, paths, regex = false, omitDirNames = []) {
|
|
980
|
+
const safePaths = paths.length ? paths : ["."];
|
|
981
|
+
const omitGlobs = omitDirNames.flatMap((name) => ["--glob", `!${name}/**`, "--glob", `!**/${name}/**`]);
|
|
982
|
+
const args = [
|
|
983
|
+
"rg",
|
|
984
|
+
"--no-config",
|
|
985
|
+
"--line-number",
|
|
986
|
+
"--column",
|
|
987
|
+
"--no-heading",
|
|
988
|
+
"--color",
|
|
989
|
+
"never",
|
|
990
|
+
"--smart-case",
|
|
991
|
+
"--max-count",
|
|
992
|
+
String(max),
|
|
993
|
+
"--max-filesize",
|
|
994
|
+
"2M",
|
|
995
|
+
...omitGlobs,
|
|
996
|
+
"-e",
|
|
997
|
+
query,
|
|
998
|
+
"--",
|
|
999
|
+
...safePaths
|
|
1000
|
+
];
|
|
1001
|
+
if (!regex)
|
|
1002
|
+
args.splice(8, 0, "--fixed-strings");
|
|
1003
|
+
return args;
|
|
1004
|
+
}
|
|
1005
|
+
function parseRgOutput(stdout, max, omitDirNames = []) {
|
|
1006
|
+
const matches = [];
|
|
1007
|
+
for (const line of stdout.split(`
|
|
1008
|
+
`)) {
|
|
1009
|
+
if (!line || matches.length >= max)
|
|
1010
|
+
continue;
|
|
1011
|
+
const parsed = /^(.*):(\d+):(\d+):(.*)$/.exec(line);
|
|
1012
|
+
if (!parsed)
|
|
1013
|
+
continue;
|
|
1014
|
+
const path = parsed[1];
|
|
1015
|
+
const lineNo = Number(parsed[2]);
|
|
1016
|
+
const column = Number(parsed[3]);
|
|
1017
|
+
const preview = parsed[4];
|
|
1018
|
+
if (!path || !lineNo || !column || isSkippableSearchPath(path, omitDirNames))
|
|
1019
|
+
continue;
|
|
1020
|
+
matches.push({ path, line: lineNo, column, preview: preview.slice(0, 500) });
|
|
1021
|
+
}
|
|
1022
|
+
return matches;
|
|
1023
|
+
}
|
|
1024
|
+
function parseGitGrepOutput(stdout, ref, max, omitDirNames = []) {
|
|
1025
|
+
const prefix = `${ref}:`;
|
|
1026
|
+
const normalized = stdout.split(`
|
|
1027
|
+
`).map((line) => line.startsWith(prefix) ? line.slice(prefix.length) : line).join(`
|
|
1028
|
+
`);
|
|
1029
|
+
return parseRgOutput(normalized, max, omitDirNames);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// web-src/server/preview.ts
|
|
1033
|
+
var WEB_ROOT = join4(ROOT, "web");
|
|
1034
|
+
var VERSION = JSON.parse(readFileSync2(join4(ROOT, "package.json"), "utf8")).version;
|
|
1035
|
+
var DEFAULT_ARGS = ["HEAD"];
|
|
1036
|
+
var PREVIEW_HUNKS_DEFAULT = 3;
|
|
1037
|
+
var PREVIEW_LINES_DEFAULT = 1200;
|
|
1038
|
+
var WATCHED_ASSET_FILES = ["index.html", "style.css", "app.js"];
|
|
1039
|
+
var SIZE_SMALL = 2000;
|
|
1040
|
+
var SIZE_MEDIUM = 8000;
|
|
1041
|
+
var SIZE_LARGE = 20000;
|
|
1042
|
+
var LINE_INDEX_MIN_START = 1e4;
|
|
1043
|
+
var LINE_INDEX_MAX_FILE_BYTES = 256 * 1024 * 1024;
|
|
1044
|
+
var BLOB_LINE_CACHE_MAX_BYTES = 128 * 1024 * 1024;
|
|
1045
|
+
var MAX_UPLOAD_FILE_BYTES = 10 * 1024 * 1024;
|
|
1046
|
+
var MAX_UPLOAD_TOTAL_BYTES = 50 * 1024 * 1024;
|
|
1047
|
+
var MAX_UPLOAD_BODY_BYTES = MAX_UPLOAD_TOTAL_BYTES + 1024 * 1024;
|
|
1048
|
+
var MAX_UPLOAD_FILES = 50;
|
|
1049
|
+
var SAFE_UPLOAD_EXTENSIONS = new Set([
|
|
1050
|
+
".txt",
|
|
1051
|
+
".md",
|
|
1052
|
+
".markdown",
|
|
1053
|
+
".json",
|
|
1054
|
+
".csv",
|
|
1055
|
+
".tsv",
|
|
1056
|
+
".yaml",
|
|
1057
|
+
".yml",
|
|
1058
|
+
".toml",
|
|
1059
|
+
".png",
|
|
1060
|
+
".jpg",
|
|
1061
|
+
".jpeg",
|
|
1062
|
+
".gif",
|
|
1063
|
+
".webp",
|
|
1064
|
+
".pdf",
|
|
1065
|
+
".ts",
|
|
1066
|
+
".tsx",
|
|
1067
|
+
".js",
|
|
1068
|
+
".jsx",
|
|
1069
|
+
".css",
|
|
1070
|
+
".scss",
|
|
1071
|
+
".html"
|
|
1072
|
+
]);
|
|
1073
|
+
var generation = 1;
|
|
1074
|
+
var cwd = repoRoot(process.cwd()) || process.cwd();
|
|
1075
|
+
var cliArgs = DEFAULT_ARGS;
|
|
1076
|
+
var listenPort = 0;
|
|
1077
|
+
var allowUpload = false;
|
|
1078
|
+
var uploadAllowedByCli = false;
|
|
1079
|
+
var scopeOmitDirNames = DEFAULT_WORKTREE_OMIT_DIR_NAMES;
|
|
1080
|
+
var scopeOmitDirCliOverride = null;
|
|
1081
|
+
var rgAvailableCache = null;
|
|
1082
|
+
var enc = new TextEncoder;
|
|
1083
|
+
var sseClients = new Set;
|
|
1084
|
+
var fileCache = new Map;
|
|
1085
|
+
var metaCache = new Map;
|
|
1086
|
+
var fileListCache = new Map;
|
|
1087
|
+
var lineIndexCache = new Map;
|
|
1088
|
+
var blobLineIndexCache = new Map;
|
|
1089
|
+
var blobBytesCache = new Map;
|
|
1090
|
+
var blobLineCacheBytes = 0;
|
|
1091
|
+
function parseCli() {
|
|
1092
|
+
const rest = [];
|
|
1093
|
+
for (let i = 2;i < process.argv.length; i++) {
|
|
1094
|
+
const arg = process.argv[i];
|
|
1095
|
+
if (arg === "--help" || arg === "-h") {
|
|
1096
|
+
console.log(`code-viewer ${VERSION}
|
|
1097
|
+
|
|
1098
|
+
Usage:
|
|
1099
|
+
code-viewer [--cwd <repo>] [--port <port>] [--open] [git-diff-args...]
|
|
1100
|
+
|
|
1101
|
+
Examples:
|
|
1102
|
+
code-viewer --open
|
|
1103
|
+
code-viewer --cwd /path/to/repo --open
|
|
1104
|
+
code-viewer HEAD~1 HEAD
|
|
1105
|
+
code-viewer --staged
|
|
1106
|
+
`);
|
|
1107
|
+
process.exit(0);
|
|
1108
|
+
} else if (arg === "--version" || arg === "-v") {
|
|
1109
|
+
console.log(VERSION);
|
|
1110
|
+
process.exit(0);
|
|
1111
|
+
} else if (arg === "--cwd") {
|
|
1112
|
+
const next = process.argv[++i];
|
|
1113
|
+
if (!next) {
|
|
1114
|
+
console.error("--cwd requires a value");
|
|
1115
|
+
process.exit(1);
|
|
1116
|
+
}
|
|
1117
|
+
try {
|
|
1118
|
+
cwd = repoRoot(next) || realpathSync(next);
|
|
1119
|
+
} catch {
|
|
1120
|
+
console.error("--cwd must point to an existing directory");
|
|
1121
|
+
process.exit(1);
|
|
1122
|
+
}
|
|
1123
|
+
} else if (arg === "--port") {
|
|
1124
|
+
const next = process.argv[++i];
|
|
1125
|
+
const parsed = Number(next);
|
|
1126
|
+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
|
|
1127
|
+
console.error("--port requires a TCP port number");
|
|
1128
|
+
process.exit(1);
|
|
1129
|
+
}
|
|
1130
|
+
listenPort = parsed;
|
|
1131
|
+
} else if (arg === "--open") {
|
|
1132
|
+
setTimeout(() => openBrowser(`http://127.0.0.1:${server.port}/`), 0);
|
|
1133
|
+
} else if (arg === "--allow-upload") {
|
|
1134
|
+
allowUpload = true;
|
|
1135
|
+
uploadAllowedByCli = true;
|
|
1136
|
+
} else if (arg === "--scope-omit-dir") {
|
|
1137
|
+
const next = process.argv[++i];
|
|
1138
|
+
if (!next) {
|
|
1139
|
+
console.error("--scope-omit-dir requires a directory name");
|
|
1140
|
+
process.exit(1);
|
|
1141
|
+
}
|
|
1142
|
+
scopeOmitDirCliOverride = normalizeScopeOmitDirNames([...scopeOmitDirCliOverride || [], next]);
|
|
1143
|
+
} else {
|
|
1144
|
+
rest.push(arg);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
if (rest.length)
|
|
1148
|
+
cliArgs = rest;
|
|
1149
|
+
if (!uploadAllowedByCli)
|
|
1150
|
+
allowUpload = loadProjectConfigUploadEnabled();
|
|
1151
|
+
const configScopeOmitDirs = loadProjectConfigScopeOmitDirs();
|
|
1152
|
+
if (scopeOmitDirCliOverride) {
|
|
1153
|
+
scopeOmitDirNames = scopeOmitDirCliOverride;
|
|
1154
|
+
} else if (configScopeOmitDirs) {
|
|
1155
|
+
scopeOmitDirNames = configScopeOmitDirs;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
function json(data, init = {}) {
|
|
1159
|
+
return new Response(JSON.stringify(data), {
|
|
1160
|
+
...init,
|
|
1161
|
+
headers: {
|
|
1162
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
1163
|
+
"Cache-Control": "no-store",
|
|
1164
|
+
...init.headers || {}
|
|
1165
|
+
}
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
function text(body, status = 200) {
|
|
1169
|
+
return new Response(body, {
|
|
1170
|
+
status,
|
|
1171
|
+
headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
function requestAllowed(req) {
|
|
1175
|
+
const host = req.headers.get("host") || "";
|
|
1176
|
+
const origin = req.headers.get("origin");
|
|
1177
|
+
const okHost = /^(127\.0\.0\.1|localhost|\[::1\]):\d+$/i.test(host);
|
|
1178
|
+
const okOrigin = !origin || origin === "null" || /^http:\/\/(127\.0\.0\.1|localhost|\[::1\]):\d+$/i.test(origin);
|
|
1179
|
+
return okHost && okOrigin;
|
|
1180
|
+
}
|
|
1181
|
+
function sideEffectRequestAllowed(req) {
|
|
1182
|
+
const host = req.headers.get("host") || "";
|
|
1183
|
+
const origin = req.headers.get("origin");
|
|
1184
|
+
const fetchSite = req.headers.get("sec-fetch-site");
|
|
1185
|
+
const requestedBy = req.headers.get("x-code-viewer-action");
|
|
1186
|
+
return /^(127\.0\.0\.1|localhost|\[::1\]):\d+$/i.test(host) && origin === `http://${host}` && (!fetchSite || fetchSite === "same-origin") && requestedBy === "1";
|
|
1187
|
+
}
|
|
1188
|
+
function staticFile(pathname) {
|
|
1189
|
+
const map = {
|
|
1190
|
+
"/favicon.png": ["favicon.png", "image/png"],
|
|
1191
|
+
"/style.css": ["style.css", "text/css; charset=utf-8"],
|
|
1192
|
+
"/app.js": ["app.js", "application/javascript; charset=utf-8"],
|
|
1193
|
+
"/mermaid.js": ["mermaid.js", "application/javascript; charset=utf-8"],
|
|
1194
|
+
"/shiki.js": ["shiki.js", "application/javascript; charset=utf-8"],
|
|
1195
|
+
"/vendor/diff2html/diff2html.min.css": ["vendor/diff2html/diff2html.min.css", "text/css; charset=utf-8"],
|
|
1196
|
+
"/vendor/diff2html/diff2html-ui.min.js": ["vendor/diff2html/diff2html-ui.min.js", "application/javascript; charset=utf-8"],
|
|
1197
|
+
"/vendor/highlight.js/highlight.min.js": ["vendor/highlight.js/highlight.min.js", "application/javascript; charset=utf-8"],
|
|
1198
|
+
"/vendor/highlight.js/styles/github.min.css": ["vendor/highlight.js/styles/github.min.css", "text/css; charset=utf-8"],
|
|
1199
|
+
"/vendor/highlight.js/styles/github-dark.min.css": ["vendor/highlight.js/styles/github-dark.min.css", "text/css; charset=utf-8"]
|
|
1200
|
+
};
|
|
1201
|
+
for (const spaPath of [...APP_ENTRY_PATHS, ...SPA_PATHS]) {
|
|
1202
|
+
map[spaPath] = ["index.html", "text/html; charset=utf-8"];
|
|
1203
|
+
}
|
|
1204
|
+
const spec = map[pathname];
|
|
1205
|
+
if (!spec)
|
|
1206
|
+
return null;
|
|
1207
|
+
const full = join4(WEB_ROOT, spec[0]);
|
|
1208
|
+
if (!existsSync3(full))
|
|
1209
|
+
return text("not found", 404);
|
|
1210
|
+
return new Response(readFileSync2(full), {
|
|
1211
|
+
headers: { "Content-Type": spec[1], "Cache-Control": "no-store" }
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
function buildRangeArgs(range) {
|
|
1215
|
+
const refs2 = [];
|
|
1216
|
+
if (range.from && range.from !== "worktree")
|
|
1217
|
+
refs2.push(range.from);
|
|
1218
|
+
if (range.to && range.to !== "worktree")
|
|
1219
|
+
refs2.push(range.to);
|
|
1220
|
+
return { args: refs2.length ? refs2 : cliArgs, refs: refs2 };
|
|
1221
|
+
}
|
|
1222
|
+
function includeUntracked(range, refs2) {
|
|
1223
|
+
const toWorktree = !range.to || range.to === "worktree";
|
|
1224
|
+
if (refs2.length > 0)
|
|
1225
|
+
return toWorktree && refs2.length < 2;
|
|
1226
|
+
return cliArgs.length === 0 || cliArgs.length === 1 && cliArgs[0] === "HEAD";
|
|
1227
|
+
}
|
|
1228
|
+
function guessMediaKind(path) {
|
|
1229
|
+
const ext = extname(path).slice(1).toLowerCase();
|
|
1230
|
+
if (["png", "jpg", "jpeg", "gif", "webp", "svg", "avif", "bmp", "ico"].includes(ext))
|
|
1231
|
+
return "image";
|
|
1232
|
+
if (["mp4", "webm", "mov"].includes(ext))
|
|
1233
|
+
return "video";
|
|
1234
|
+
if (["mp3", "wav", "ogg", "flac", "m4a", "aac", "opus"].includes(ext))
|
|
1235
|
+
return "audio";
|
|
1236
|
+
return null;
|
|
1237
|
+
}
|
|
1238
|
+
function classify(file) {
|
|
1239
|
+
if (file.binary)
|
|
1240
|
+
return "binary";
|
|
1241
|
+
const total = (file.additions || 0) + (file.deletions || 0);
|
|
1242
|
+
if (total <= SIZE_SMALL)
|
|
1243
|
+
return "small";
|
|
1244
|
+
if (total <= SIZE_MEDIUM)
|
|
1245
|
+
return "medium";
|
|
1246
|
+
if (total <= SIZE_LARGE)
|
|
1247
|
+
return "large";
|
|
1248
|
+
return "huge";
|
|
1249
|
+
}
|
|
1250
|
+
function estimateHeight(file, sizeClass) {
|
|
1251
|
+
if (file.binary)
|
|
1252
|
+
return 380;
|
|
1253
|
+
if (sizeClass === "small")
|
|
1254
|
+
return Math.min(800, ((file.additions || 0) + (file.deletions || 0) + 10) * 22);
|
|
1255
|
+
return 140;
|
|
1256
|
+
}
|
|
1257
|
+
function buildQuery(params) {
|
|
1258
|
+
const q = new URLSearchParams;
|
|
1259
|
+
for (const key of Object.keys(params).sort()) {
|
|
1260
|
+
const value = params[key];
|
|
1261
|
+
if (value !== undefined && value !== null && value !== "")
|
|
1262
|
+
q.set(key, String(value));
|
|
1263
|
+
}
|
|
1264
|
+
const s = q.toString();
|
|
1265
|
+
return s ? `?${s}` : "";
|
|
1266
|
+
}
|
|
1267
|
+
function fileToMeta(file, range, extraQs) {
|
|
1268
|
+
const sizeClass = classify(file);
|
|
1269
|
+
const q = { path: file.path, old_path: file.old_path, status: file.status, from: range.from, to: range.to, ...extraQs };
|
|
1270
|
+
if (file.untracked)
|
|
1271
|
+
Object.assign(q, { untracked: "1" });
|
|
1272
|
+
const previewQ = { ...q, mode: "preview", max_hunks: PREVIEW_HUNKS_DEFAULT };
|
|
1273
|
+
const previewUrl = sizeClass !== "small" ? `/file_diff${buildQuery(previewQ)}` : null;
|
|
1274
|
+
return {
|
|
1275
|
+
order: file.order,
|
|
1276
|
+
key: `${file.status || "M"}\x00${file.old_path || ""}\x00${file.path}`,
|
|
1277
|
+
path: file.path,
|
|
1278
|
+
old_path: file.old_path,
|
|
1279
|
+
display_path: file.path,
|
|
1280
|
+
status: file.status || "M",
|
|
1281
|
+
additions: file.additions || 0,
|
|
1282
|
+
deletions: file.deletions || 0,
|
|
1283
|
+
binary: file.binary || false,
|
|
1284
|
+
media_kind: guessMediaKind(file.path),
|
|
1285
|
+
size_class: sizeClass,
|
|
1286
|
+
force_layout: sizeClass === "huge" ? "line-by-line" : undefined,
|
|
1287
|
+
highlight: sizeClass === "small",
|
|
1288
|
+
load_url: `/file_diff${buildQuery(q)}`,
|
|
1289
|
+
preview_url: previewUrl,
|
|
1290
|
+
estimated_height_px: estimateHeight(file, sizeClass),
|
|
1291
|
+
untracked: file.untracked || false
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
function computePayload(extras, range) {
|
|
1295
|
+
if (isSameWorktreeRange(range)) {
|
|
1296
|
+
return {
|
|
1297
|
+
files: [],
|
|
1298
|
+
totals: { files: 0, additions: 0, deletions: 0 },
|
|
1299
|
+
range: "worktree .. worktree",
|
|
1300
|
+
project: basename2(cwd),
|
|
1301
|
+
branch: currentBranch(cwd) || undefined,
|
|
1302
|
+
generation
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
const { args, refs: refs2 } = buildRangeArgs(range);
|
|
1306
|
+
const fullArgs = [...extras, ...args];
|
|
1307
|
+
const files = fileMeta(fullArgs, cwd, false);
|
|
1308
|
+
if (includeUntracked(range, refs2))
|
|
1309
|
+
files.push(...untrackedMeta(cwd));
|
|
1310
|
+
files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
1311
|
+
files.forEach((file, i) => {
|
|
1312
|
+
file.order = i + 1;
|
|
1313
|
+
});
|
|
1314
|
+
const extraQs = {};
|
|
1315
|
+
for (const e of extras) {
|
|
1316
|
+
if (e === "-w" || e === "--ignore-all-space")
|
|
1317
|
+
extraQs.ignore_ws = "1";
|
|
1318
|
+
if (e === "--ignore-blank-lines")
|
|
1319
|
+
extraQs.ignore_blank = "1";
|
|
1320
|
+
}
|
|
1321
|
+
const meta = files.map((file) => fileToMeta(file, range, extraQs));
|
|
1322
|
+
const totals = meta.reduce((acc, file) => {
|
|
1323
|
+
acc.additions += file.additions || 0;
|
|
1324
|
+
acc.deletions += file.deletions || 0;
|
|
1325
|
+
return acc;
|
|
1326
|
+
}, { files: meta.length, additions: 0, deletions: 0 });
|
|
1327
|
+
const toWorktree = !range.to || range.to === "worktree";
|
|
1328
|
+
const label = refs2.length ? `${refs2.join(" .. ")}${toWorktree && refs2.length === 1 ? " .. worktree" : ""}` : cliArgs.join(" ");
|
|
1329
|
+
return { files: meta, totals, range: label || "HEAD", project: basename2(cwd), branch: currentBranch(cwd) || undefined, generation };
|
|
1330
|
+
}
|
|
1331
|
+
function handleDiffJson(url) {
|
|
1332
|
+
const extras = [];
|
|
1333
|
+
if (url.searchParams.get("ignore_ws") === "1")
|
|
1334
|
+
extras.push("-w");
|
|
1335
|
+
if (url.searchParams.get("ignore_blank") === "1")
|
|
1336
|
+
extras.push("--ignore-blank-lines");
|
|
1337
|
+
const range = { from: url.searchParams.get("from") || "", to: url.searchParams.get("to") || "" };
|
|
1338
|
+
const key = `${range.from}|${range.to}|${url.searchParams.get("ignore_ws") || ""}|${url.searchParams.get("ignore_blank") || ""}`;
|
|
1339
|
+
if (url.searchParams.get("nocache") === "1") {
|
|
1340
|
+
const payload2 = computePayload(extras, range);
|
|
1341
|
+
const sig = JSON.stringify({ ...payload2, generation: undefined });
|
|
1342
|
+
const cached2 = metaCache.get(key);
|
|
1343
|
+
if (!cached2 || cached2.sig !== sig) {
|
|
1344
|
+
generation++;
|
|
1345
|
+
payload2.generation = generation;
|
|
1346
|
+
metaCache.clear();
|
|
1347
|
+
fileCache.clear();
|
|
1348
|
+
}
|
|
1349
|
+
const body2 = JSON.stringify(payload2);
|
|
1350
|
+
setTimedCacheEntry(metaCache, key, { body: body2, sig });
|
|
1351
|
+
return new Response(body2, { headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" } });
|
|
1352
|
+
}
|
|
1353
|
+
const cached = metaCache.get(key);
|
|
1354
|
+
if (cacheFresh(cached))
|
|
1355
|
+
return new Response(cached.body, { headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" } });
|
|
1356
|
+
const payload = computePayload(extras, range);
|
|
1357
|
+
const body = JSON.stringify(payload);
|
|
1358
|
+
setTimedCacheEntry(metaCache, key, { body, sig: JSON.stringify({ ...payload, generation: undefined }) });
|
|
1359
|
+
return new Response(body, { headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" } });
|
|
1360
|
+
}
|
|
1361
|
+
function safePath(path) {
|
|
1362
|
+
if (!path || path.startsWith("/") || path.startsWith("\\") || path.includes("\x00"))
|
|
1363
|
+
return false;
|
|
1364
|
+
return !path.split(/[\\/]+/).includes("..");
|
|
1365
|
+
}
|
|
1366
|
+
function safeRepoPath(path) {
|
|
1367
|
+
return path === "" || safePath(path);
|
|
1368
|
+
}
|
|
1369
|
+
function normalizeScopeOmitDirNames(names) {
|
|
1370
|
+
if (!Array.isArray(names))
|
|
1371
|
+
return [];
|
|
1372
|
+
return [...new Set(names.filter((name) => typeof name === "string").map((name) => name.trim()).filter((name) => name && name.length <= 64 && !name.includes("/") && !name.includes("\\") && !name.includes("\x00") && name !== "." && name !== ".." && name !== ".git"))].sort((a, b) => a.localeCompare(b));
|
|
1373
|
+
}
|
|
1374
|
+
function parseScopeOmitDirNamesQuery(value) {
|
|
1375
|
+
const names = value ? value.split(",") : [];
|
|
1376
|
+
if (names.length > 100)
|
|
1377
|
+
return null;
|
|
1378
|
+
for (const raw of names) {
|
|
1379
|
+
const name = raw.trim();
|
|
1380
|
+
if (!name || name.length > 64 || name.includes("/") || name.includes("\\") || name.includes("\x00") || name === "." || name === ".." || name === ".git")
|
|
1381
|
+
return null;
|
|
1382
|
+
}
|
|
1383
|
+
return normalizeScopeOmitDirNames(names);
|
|
1384
|
+
}
|
|
1385
|
+
function loadProjectConfig() {
|
|
1386
|
+
const full = join4(cwd, ".code-viewer.json");
|
|
1387
|
+
if (!existsSync3(full))
|
|
1388
|
+
return null;
|
|
1389
|
+
let realCwd;
|
|
1390
|
+
let realConfig;
|
|
1391
|
+
try {
|
|
1392
|
+
realCwd = realpathSync(cwd);
|
|
1393
|
+
realConfig = realpathSync(full);
|
|
1394
|
+
} catch {
|
|
1395
|
+
return null;
|
|
1396
|
+
}
|
|
1397
|
+
if (dirname2(realConfig) !== realCwd || basename2(realConfig) !== ".code-viewer.json")
|
|
1398
|
+
return null;
|
|
1399
|
+
try {
|
|
1400
|
+
const parsed = JSON.parse(readFileSync2(realConfig, "utf8"));
|
|
1401
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && "version" in parsed && parsed.version !== 1)
|
|
1402
|
+
return null;
|
|
1403
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
1404
|
+
} catch {
|
|
1405
|
+
return null;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
function loadProjectConfigUploadEnabled() {
|
|
1409
|
+
const config = loadProjectConfig();
|
|
1410
|
+
return config?.upload?.enabled === true;
|
|
1411
|
+
}
|
|
1412
|
+
function loadProjectConfigScopeOmitDirs() {
|
|
1413
|
+
const config = loadProjectConfig();
|
|
1414
|
+
if (!config?.scope || !Array.isArray(config.scope.omitDirs))
|
|
1415
|
+
return null;
|
|
1416
|
+
return normalizeScopeOmitDirNames(config.scope.omitDirs);
|
|
1417
|
+
}
|
|
1418
|
+
function scopeOmitDirNamesFromQuery(url) {
|
|
1419
|
+
if (!url.searchParams.has("omit_dirs"))
|
|
1420
|
+
return scopeOmitDirNames;
|
|
1421
|
+
return parseScopeOmitDirNamesQuery(url.searchParams.get("omit_dirs") || "") || scopeOmitDirNames;
|
|
1422
|
+
}
|
|
1423
|
+
function invalidScopeOmitDirNamesQuery(url) {
|
|
1424
|
+
return url.searchParams.has("omit_dirs") && !parseScopeOmitDirNamesQuery(url.searchParams.get("omit_dirs") || "");
|
|
1425
|
+
}
|
|
1426
|
+
function isGitInternalPath(path) {
|
|
1427
|
+
return path.split(/[\\/]+/).some((part) => part.toLowerCase() === ".git");
|
|
1428
|
+
}
|
|
1429
|
+
function safeWorktreePath(path) {
|
|
1430
|
+
if (!safePath(path))
|
|
1431
|
+
return null;
|
|
1432
|
+
if (isGitInternalPath(path))
|
|
1433
|
+
return null;
|
|
1434
|
+
const full = join4(cwd, path);
|
|
1435
|
+
if (!existsSync3(full))
|
|
1436
|
+
return null;
|
|
1437
|
+
let realCwd;
|
|
1438
|
+
let realFull;
|
|
1439
|
+
try {
|
|
1440
|
+
realCwd = realpathSync(cwd);
|
|
1441
|
+
realFull = realpathSync(full);
|
|
1442
|
+
} catch {
|
|
1443
|
+
return null;
|
|
1444
|
+
}
|
|
1445
|
+
const rel = relative(realCwd, realFull);
|
|
1446
|
+
if (rel === "" || rel.startsWith("..") || rel.startsWith("/") || rel.startsWith("\\"))
|
|
1447
|
+
return null;
|
|
1448
|
+
if (isGitInternalPath(rel))
|
|
1449
|
+
return null;
|
|
1450
|
+
return realFull;
|
|
1451
|
+
}
|
|
1452
|
+
function safeOpenWorktreePath(path) {
|
|
1453
|
+
if (path === "") {
|
|
1454
|
+
try {
|
|
1455
|
+
const realCwd = realpathSync(cwd);
|
|
1456
|
+
if (isGitInternalPath(realCwd))
|
|
1457
|
+
return null;
|
|
1458
|
+
return realCwd;
|
|
1459
|
+
} catch {
|
|
1460
|
+
return null;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
return safeWorktreePath(path);
|
|
1464
|
+
}
|
|
1465
|
+
function parentRepoPath(path) {
|
|
1466
|
+
const parent = dirname2(path);
|
|
1467
|
+
return parent === "." ? "" : parent;
|
|
1468
|
+
}
|
|
1469
|
+
function readReadme(target, dirPath) {
|
|
1470
|
+
const candidates = ["README.md", "readme.md", "README.markdown", "README"];
|
|
1471
|
+
for (const name of candidates) {
|
|
1472
|
+
const path = dirPath ? `${dirPath}/${name}` : name;
|
|
1473
|
+
if (target === "worktree" || target === "") {
|
|
1474
|
+
const full = safeWorktreePath(path);
|
|
1475
|
+
if (!full)
|
|
1476
|
+
continue;
|
|
1477
|
+
try {
|
|
1478
|
+
return { path, text: readFileSync2(full, "utf8") };
|
|
1479
|
+
} catch {
|
|
1480
|
+
continue;
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
const res = show(target, path, cwd);
|
|
1484
|
+
if (res.code === 0)
|
|
1485
|
+
return { path, text: res.stdout };
|
|
1486
|
+
}
|
|
1487
|
+
return null;
|
|
1488
|
+
}
|
|
1489
|
+
function handleTree(url) {
|
|
1490
|
+
const target = url.searchParams.get("ref") || url.searchParams.get("target") || "worktree";
|
|
1491
|
+
const path = (url.searchParams.get("path") || "").replace(/^\/+|\/+$/g, "");
|
|
1492
|
+
if (!safeRepoPath(path))
|
|
1493
|
+
return text("invalid path", 400);
|
|
1494
|
+
if ((target === "worktree" || target === "") && isGitInternalPath(path))
|
|
1495
|
+
return text("forbidden", 403);
|
|
1496
|
+
if (target !== "worktree" && !verifyTreeRef(target, cwd))
|
|
1497
|
+
return text("invalid target", 400);
|
|
1498
|
+
const recursive = url.searchParams.get("recursive") === "1";
|
|
1499
|
+
if (invalidScopeOmitDirNamesQuery(url))
|
|
1500
|
+
return text("invalid omit dirs", 400);
|
|
1501
|
+
const entries = listTree(target, path, cwd, { recursive, omitDirNames: scopeOmitDirNamesFromQuery(url) }).entries;
|
|
1502
|
+
return json({
|
|
1503
|
+
ref: target,
|
|
1504
|
+
path,
|
|
1505
|
+
project: basename2(cwd),
|
|
1506
|
+
branch: currentBranch(cwd) || undefined,
|
|
1507
|
+
entries,
|
|
1508
|
+
readme: readReadme(target, path),
|
|
1509
|
+
upload_enabled: allowUpload && (target === "worktree" || target === "")
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
function handleSettings() {
|
|
1513
|
+
return json({
|
|
1514
|
+
project: basename2(cwd),
|
|
1515
|
+
scope: {
|
|
1516
|
+
omit_dirs_effective: scopeOmitDirNames,
|
|
1517
|
+
omit_dirs_built_in: DEFAULT_WORKTREE_OMIT_DIR_NAMES,
|
|
1518
|
+
max_entries: WORKTREE_RECURSIVE_ENTRY_LIMIT
|
|
1519
|
+
}
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
function handleFiles(url) {
|
|
1523
|
+
const target = url.searchParams.get("ref") || url.searchParams.get("target") || "worktree";
|
|
1524
|
+
if (target !== "worktree" && !verifyTreeRef(target, cwd))
|
|
1525
|
+
return text("invalid target", 400);
|
|
1526
|
+
if (invalidScopeOmitDirNamesQuery(url))
|
|
1527
|
+
return text("invalid omit dirs", 400);
|
|
1528
|
+
const omitDirNames = scopeOmitDirNamesFromQuery(url);
|
|
1529
|
+
const key = `${target || "worktree"}\x00${omitDirNames.join("\x00")}`;
|
|
1530
|
+
const cached = fileListCache.get(key);
|
|
1531
|
+
if (cached && cached.generation === generation)
|
|
1532
|
+
return json(cached.body);
|
|
1533
|
+
const ref = target || "worktree";
|
|
1534
|
+
const entries = listTree(ref, "", cwd, { recursive: true, omitDirNames }).entries;
|
|
1535
|
+
const body = buildFileSearchList(ref, generation, entries);
|
|
1536
|
+
fileListCache.set(key, { generation, body });
|
|
1537
|
+
return json(body);
|
|
1538
|
+
}
|
|
1539
|
+
function parseGrepPaths(url, omitDirNames) {
|
|
1540
|
+
return url.searchParams.getAll("path").filter((path) => safePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, omitDirNames));
|
|
1541
|
+
}
|
|
1542
|
+
function rgAvailable() {
|
|
1543
|
+
if (rgAvailableCache !== null)
|
|
1544
|
+
return rgAvailableCache;
|
|
1545
|
+
const proc = runSync(["rg", "--version"], cwd);
|
|
1546
|
+
rgAvailableCache = proc.code === 0;
|
|
1547
|
+
return rgAvailableCache;
|
|
1548
|
+
}
|
|
1549
|
+
function grepWorktreeFallback(query, max, paths, omitDirNames) {
|
|
1550
|
+
const candidates = paths.length ? paths : worktreeFiles(cwd).map((entry) => entry.path);
|
|
1551
|
+
const matches = [];
|
|
1552
|
+
for (const path of candidates) {
|
|
1553
|
+
if (matches.length >= max)
|
|
1554
|
+
break;
|
|
1555
|
+
if (!safePath(path) || isGitInternalPath(path) || isSkippableSearchPath(path, omitDirNames))
|
|
1556
|
+
continue;
|
|
1557
|
+
const full = safeWorktreePath(path);
|
|
1558
|
+
if (!full)
|
|
1559
|
+
continue;
|
|
1560
|
+
let stat;
|
|
1561
|
+
try {
|
|
1562
|
+
stat = lstatSync3(full);
|
|
1563
|
+
} catch {
|
|
1564
|
+
continue;
|
|
1565
|
+
}
|
|
1566
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > GREP_MAX_FILE_BYTES)
|
|
1567
|
+
continue;
|
|
1568
|
+
let data;
|
|
1569
|
+
try {
|
|
1570
|
+
data = readFileSync2(full);
|
|
1571
|
+
} catch {
|
|
1572
|
+
continue;
|
|
1573
|
+
}
|
|
1574
|
+
if (data.subarray(0, 8192).includes(0))
|
|
1575
|
+
continue;
|
|
1576
|
+
matches.push(...fixedStringLineMatches(path, data.toString("utf8"), query, max - matches.length));
|
|
1577
|
+
}
|
|
1578
|
+
return matches;
|
|
1579
|
+
}
|
|
1580
|
+
function grepWorktree(query, max, paths, regex, omitDirNames) {
|
|
1581
|
+
if (rgAvailable()) {
|
|
1582
|
+
const safePaths = paths.filter((path) => safePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, omitDirNames) && safeWorktreePath(path));
|
|
1583
|
+
const args = buildRgArgs(query, max, safePaths, regex, omitDirNames);
|
|
1584
|
+
const proc = runSync(args, cwd, { timeout: 5000 });
|
|
1585
|
+
const stdout = proc.stdout;
|
|
1586
|
+
const matches2 = parseRgOutput(stdout, max, omitDirNames).filter((match) => safePath(match.path) && !isGitInternalPath(match.path) && !isSkippableSearchPath(match.path, omitDirNames) && !!safeWorktreePath(match.path));
|
|
1587
|
+
return { ref: "worktree", engine: "rg", truncated: matches2.length >= max, matches: matches2 };
|
|
1588
|
+
}
|
|
1589
|
+
if (regex)
|
|
1590
|
+
return { ref: "worktree", engine: "fallback", truncated: false, matches: [] };
|
|
1591
|
+
const matches = grepWorktreeFallback(query, max, paths, omitDirNames);
|
|
1592
|
+
return { ref: "worktree", engine: "fallback", truncated: matches.length >= max, matches };
|
|
1593
|
+
}
|
|
1594
|
+
function grepTreeRef(ref, query, max, paths, regex, omitDirNames) {
|
|
1595
|
+
const safePaths = paths.filter((path) => safePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, omitDirNames));
|
|
1596
|
+
const args = [
|
|
1597
|
+
"git",
|
|
1598
|
+
"-c",
|
|
1599
|
+
"core.quotepath=false",
|
|
1600
|
+
"grep",
|
|
1601
|
+
"-n",
|
|
1602
|
+
"--column",
|
|
1603
|
+
"-i",
|
|
1604
|
+
regex ? "-E" : "-F",
|
|
1605
|
+
"--no-color",
|
|
1606
|
+
"-e",
|
|
1607
|
+
query,
|
|
1608
|
+
ref,
|
|
1609
|
+
"--",
|
|
1610
|
+
...safePaths
|
|
1611
|
+
];
|
|
1612
|
+
const proc = runSync(args, cwd, { timeout: 5000 });
|
|
1613
|
+
const stdout = proc.stdout;
|
|
1614
|
+
const matches = parseGitGrepOutput(stdout, ref, max, omitDirNames).slice(0, max);
|
|
1615
|
+
return { ref, engine: "git", truncated: matches.length >= max, matches };
|
|
1616
|
+
}
|
|
1617
|
+
function handleGrep(url) {
|
|
1618
|
+
const query = url.searchParams.get("q") || "";
|
|
1619
|
+
const ref = url.searchParams.get("ref") || "worktree";
|
|
1620
|
+
const max = normalizeGrepMax(url.searchParams.get("max"));
|
|
1621
|
+
if (invalidScopeOmitDirNamesQuery(url))
|
|
1622
|
+
return text("invalid omit dirs", 400);
|
|
1623
|
+
const omitDirNames = scopeOmitDirNamesFromQuery(url);
|
|
1624
|
+
const paths = parseGrepPaths(url, omitDirNames);
|
|
1625
|
+
const regex = url.searchParams.get("regex") === "1";
|
|
1626
|
+
if (!query.trim())
|
|
1627
|
+
return json({ ref, engine: ref === "worktree" ? "fallback" : "git", truncated: false, matches: [] });
|
|
1628
|
+
if (ref === "worktree" || ref === "")
|
|
1629
|
+
return json(grepWorktree(query, max, paths, regex, omitDirNames));
|
|
1630
|
+
if (!verifyTreeRef(ref, cwd))
|
|
1631
|
+
return text("invalid target", 400);
|
|
1632
|
+
return json(grepTreeRef(ref, query, max, paths, regex, omitDirNames));
|
|
1633
|
+
}
|
|
1634
|
+
function handleFileDiff(url) {
|
|
1635
|
+
const path = url.searchParams.get("path") || "";
|
|
1636
|
+
if (!safePath(path))
|
|
1637
|
+
return text("invalid path", 400);
|
|
1638
|
+
const extras = [];
|
|
1639
|
+
if (url.searchParams.get("ignore_ws") === "1")
|
|
1640
|
+
extras.push("-w");
|
|
1641
|
+
if (url.searchParams.get("ignore_blank") === "1")
|
|
1642
|
+
extras.push("--ignore-blank-lines");
|
|
1643
|
+
const isUntracked = url.searchParams.get("untracked") === "1";
|
|
1644
|
+
const range = { from: url.searchParams.get("from") || "", to: url.searchParams.get("to") || "" };
|
|
1645
|
+
if (isSameWorktreeRange(range)) {
|
|
1646
|
+
return json({
|
|
1647
|
+
path,
|
|
1648
|
+
old_path: url.searchParams.get("old_path") || "",
|
|
1649
|
+
status: url.searchParams.get("status") || "",
|
|
1650
|
+
mode: url.searchParams.get("mode") || "full",
|
|
1651
|
+
diff: "",
|
|
1652
|
+
hunk_count: 0,
|
|
1653
|
+
rendered_hunk_count: 0,
|
|
1654
|
+
line_count: 0,
|
|
1655
|
+
truncated: false,
|
|
1656
|
+
binary: false,
|
|
1657
|
+
generation
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
const { args } = buildRangeArgs(range);
|
|
1661
|
+
const oldPath = url.searchParams.get("old_path");
|
|
1662
|
+
let cacheKey;
|
|
1663
|
+
try {
|
|
1664
|
+
cacheKey = fileDiffCacheKey({ path, oldPath, isUntracked, range, extras, args, cwd });
|
|
1665
|
+
} catch {
|
|
1666
|
+
return text("invalid diff range", 400);
|
|
1667
|
+
}
|
|
1668
|
+
const cached = fileCache.get(cacheKey);
|
|
1669
|
+
let diffText;
|
|
1670
|
+
let errText = "";
|
|
1671
|
+
if (cacheFresh(cached)) {
|
|
1672
|
+
diffText = cached.diffText;
|
|
1673
|
+
} else {
|
|
1674
|
+
if (isUntracked) {
|
|
1675
|
+
diffText = untrackedFileDiff(extras, path, cwd).stdout || "";
|
|
1676
|
+
} else {
|
|
1677
|
+
const res = fileDiffText([...extras, ...args], oldPath ? [oldPath, path] : path, cwd);
|
|
1678
|
+
diffText = res.stdout || "";
|
|
1679
|
+
if (res.code !== 0)
|
|
1680
|
+
errText = res.stderr;
|
|
1681
|
+
}
|
|
1682
|
+
setTimedCacheEntry(fileCache, cacheKey, { diffText });
|
|
1683
|
+
}
|
|
1684
|
+
const mode = url.searchParams.get("mode") || "full";
|
|
1685
|
+
const truncated = mode === "preview" ? truncateToNHunks(diffText, Number(url.searchParams.get("max_hunks")) || PREVIEW_HUNKS_DEFAULT, Number(url.searchParams.get("max_lines")) || PREVIEW_LINES_DEFAULT) : truncateToNHunks(diffText, 1e9);
|
|
1686
|
+
const body = {
|
|
1687
|
+
path,
|
|
1688
|
+
old_path: url.searchParams.get("old_path") || "",
|
|
1689
|
+
status: url.searchParams.get("status") || "",
|
|
1690
|
+
mode,
|
|
1691
|
+
diff: truncated.text,
|
|
1692
|
+
hunk_count: truncated.totalHunks,
|
|
1693
|
+
rendered_hunk_count: truncated.renderedHunks,
|
|
1694
|
+
line_count: truncated.lineCount,
|
|
1695
|
+
truncated: mode === "preview" && (truncated.totalHunks > truncated.renderedHunks || truncated.lineTruncated),
|
|
1696
|
+
binary: diffText.includes("Binary files"),
|
|
1697
|
+
error: errText,
|
|
1698
|
+
generation
|
|
1699
|
+
};
|
|
1700
|
+
return json(body);
|
|
1701
|
+
}
|
|
1702
|
+
function worktreeLineIndexSignature(full) {
|
|
1703
|
+
try {
|
|
1704
|
+
const stat = statSync(full);
|
|
1705
|
+
return `size:${stat.size}|mtime:${stat.mtimeMs}|ctime:${stat.ctimeMs}|ino:${stat.ino || 0}`;
|
|
1706
|
+
} catch {
|
|
1707
|
+
return null;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
async function getWorktreeLineIndex(full) {
|
|
1711
|
+
const signature = worktreeLineIndexSignature(full);
|
|
1712
|
+
if (!signature)
|
|
1713
|
+
return null;
|
|
1714
|
+
const cached = lineIndexCache.get(full);
|
|
1715
|
+
if (cached?.signature === signature) {
|
|
1716
|
+
lineIndexCache.delete(full);
|
|
1717
|
+
lineIndexCache.set(full, cached);
|
|
1718
|
+
return cached.index;
|
|
1719
|
+
}
|
|
1720
|
+
const stat = statSync(full);
|
|
1721
|
+
if (stat.size > LINE_INDEX_MAX_FILE_BYTES)
|
|
1722
|
+
return null;
|
|
1723
|
+
const index = await buildLineOffsetIndexFromStream(fileReadableStream(full), stat.size);
|
|
1724
|
+
lineIndexCache.delete(full);
|
|
1725
|
+
lineIndexCache.set(full, { signature, index });
|
|
1726
|
+
while (lineIndexCache.size > 32) {
|
|
1727
|
+
const oldest = lineIndexCache.keys().next().value;
|
|
1728
|
+
if (oldest === undefined)
|
|
1729
|
+
break;
|
|
1730
|
+
lineIndexCache.delete(oldest);
|
|
1731
|
+
}
|
|
1732
|
+
return index;
|
|
1733
|
+
}
|
|
1734
|
+
function cachedBlobLineRange(cacheKey, start, end) {
|
|
1735
|
+
const bytes = blobBytesCache.get(cacheKey);
|
|
1736
|
+
const index = blobLineIndexCache.get(cacheKey);
|
|
1737
|
+
if (!bytes || !index)
|
|
1738
|
+
return null;
|
|
1739
|
+
blobBytesCache.delete(cacheKey);
|
|
1740
|
+
blobBytesCache.set(cacheKey, bytes);
|
|
1741
|
+
blobLineIndexCache.delete(cacheKey);
|
|
1742
|
+
blobLineIndexCache.set(cacheKey, index);
|
|
1743
|
+
const range = lineByteRangeForIndex(index, start, end);
|
|
1744
|
+
const textValue = range ? new TextDecoder().decode(bytes.subarray(range.start, range.endExclusive)) : "";
|
|
1745
|
+
return collectLineRangeFromIndexedText(textValue, index, start, end);
|
|
1746
|
+
}
|
|
1747
|
+
function setBlobLineCache(cacheKey, bytes, index) {
|
|
1748
|
+
setBlobLineIndexCache(cacheKey, index);
|
|
1749
|
+
const existing = blobBytesCache.get(cacheKey);
|
|
1750
|
+
if (existing)
|
|
1751
|
+
blobLineCacheBytes -= existing.byteLength;
|
|
1752
|
+
blobBytesCache.delete(cacheKey);
|
|
1753
|
+
if (bytes.byteLength > BLOB_LINE_CACHE_MAX_BYTES)
|
|
1754
|
+
return;
|
|
1755
|
+
blobBytesCache.set(cacheKey, bytes);
|
|
1756
|
+
blobLineCacheBytes += bytes.byteLength;
|
|
1757
|
+
while (blobBytesCache.size > 16 || blobLineCacheBytes > BLOB_LINE_CACHE_MAX_BYTES) {
|
|
1758
|
+
const oldest = blobBytesCache.keys().next().value;
|
|
1759
|
+
if (oldest === undefined)
|
|
1760
|
+
break;
|
|
1761
|
+
const evicted = blobBytesCache.get(oldest);
|
|
1762
|
+
if (evicted)
|
|
1763
|
+
blobLineCacheBytes -= evicted.byteLength;
|
|
1764
|
+
blobBytesCache.delete(oldest);
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
function setBlobLineIndexCache(cacheKey, index) {
|
|
1768
|
+
blobLineIndexCache.delete(cacheKey);
|
|
1769
|
+
blobLineIndexCache.set(cacheKey, index);
|
|
1770
|
+
while (blobLineIndexCache.size > 128) {
|
|
1771
|
+
const oldest = blobLineIndexCache.keys().next().value;
|
|
1772
|
+
if (oldest === undefined)
|
|
1773
|
+
break;
|
|
1774
|
+
blobLineIndexCache.delete(oldest);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
async function collectGitBlobLineRangeWithIndex(cacheKey, oid, index, start, end) {
|
|
1778
|
+
blobLineIndexCache.delete(cacheKey);
|
|
1779
|
+
blobLineIndexCache.set(cacheKey, index);
|
|
1780
|
+
const range = lineByteRangeForIndex(index, start, end);
|
|
1781
|
+
if (!range)
|
|
1782
|
+
return collectLineRangeFromIndexedText("", index, start, end);
|
|
1783
|
+
const shown = catFileBlobStream(oid, cwd);
|
|
1784
|
+
const bytes = await collectByteRangeFromStream(shown.stream, range.start, range.endExclusive);
|
|
1785
|
+
await shown.exited;
|
|
1786
|
+
if (bytes.byteLength !== range.endExclusive - range.start)
|
|
1787
|
+
return null;
|
|
1788
|
+
const textValue = new TextDecoder().decode(bytes);
|
|
1789
|
+
return collectLineRangeFromIndexedText(textValue, index, start, end);
|
|
1790
|
+
}
|
|
1791
|
+
async function readGitBlobBytesWithIndex(oid, sizeHint) {
|
|
1792
|
+
const shown = catFileBlobStream(oid, cwd);
|
|
1793
|
+
const result = await collectBytesWithLineOffsetIndexFromStream(shown.stream, sizeHint);
|
|
1794
|
+
const code = await shown.exited;
|
|
1795
|
+
if (code !== 0)
|
|
1796
|
+
return null;
|
|
1797
|
+
return result;
|
|
1798
|
+
}
|
|
1799
|
+
async function collectGitBlobLineRangeFromStream(oid, start, end) {
|
|
1800
|
+
const shown = catFileBlobStream(oid, cwd);
|
|
1801
|
+
const result = await collectLineRangeFromStream(shown.stream, start, end);
|
|
1802
|
+
const code = await shown.exited;
|
|
1803
|
+
if (code !== 0 && result.complete)
|
|
1804
|
+
return null;
|
|
1805
|
+
return result;
|
|
1806
|
+
}
|
|
1807
|
+
async function collectIndexedGitBlobLineRange(path, oid, size, start, end) {
|
|
1808
|
+
const cacheKey = `${oid}\x00${path}`;
|
|
1809
|
+
const cached = cachedBlobLineRange(cacheKey, start, end);
|
|
1810
|
+
if (cached)
|
|
1811
|
+
return cached;
|
|
1812
|
+
const cachedIndex = blobLineIndexCache.get(cacheKey);
|
|
1813
|
+
if (cachedIndex)
|
|
1814
|
+
return collectGitBlobLineRangeWithIndex(cacheKey, oid, cachedIndex, start, end);
|
|
1815
|
+
if (start < LINE_INDEX_MIN_START) {
|
|
1816
|
+
return collectGitBlobLineRangeFromStream(oid, start, end);
|
|
1817
|
+
}
|
|
1818
|
+
if (size > LINE_INDEX_MAX_FILE_BYTES)
|
|
1819
|
+
return collectGitBlobLineRangeFromStream(oid, start, end);
|
|
1820
|
+
const indexedBlob = await readGitBlobBytesWithIndex(oid, size);
|
|
1821
|
+
if (!indexedBlob)
|
|
1822
|
+
return null;
|
|
1823
|
+
setBlobLineCache(cacheKey, indexedBlob.bytes, indexedBlob.index);
|
|
1824
|
+
return cachedBlobLineRange(cacheKey, start, end) || collectGitBlobLineRangeWithIndex(cacheKey, oid, indexedBlob.index, start, end);
|
|
1825
|
+
}
|
|
1826
|
+
async function collectIndexedWorktreeLineRange(full, start, end) {
|
|
1827
|
+
if (start < LINE_INDEX_MIN_START && !lineIndexCache.has(full)) {
|
|
1828
|
+
return collectLineRangeFromStream(fileReadableStream(full), start, end);
|
|
1829
|
+
}
|
|
1830
|
+
const index = await getWorktreeLineIndex(full);
|
|
1831
|
+
if (!index)
|
|
1832
|
+
return collectLineRangeFromStream(fileReadableStream(full), start, end);
|
|
1833
|
+
const range = lineByteRangeForIndex(index, start, end);
|
|
1834
|
+
const textValue = range ? await readFileTextRange(full, range.start, range.endExclusive) : "";
|
|
1835
|
+
return collectLineRangeFromIndexedText(textValue, index, start, end);
|
|
1836
|
+
}
|
|
1837
|
+
async function handleFileRange(url) {
|
|
1838
|
+
const path = url.searchParams.get("path") || "";
|
|
1839
|
+
if (!safePath(path))
|
|
1840
|
+
return text("invalid path", 400);
|
|
1841
|
+
let start = Number(url.searchParams.get("start") || "1") || 1;
|
|
1842
|
+
let end = Number(url.searchParams.get("end") || url.searchParams.get("endline") || "0") || 0;
|
|
1843
|
+
if (start < 1)
|
|
1844
|
+
start = 1;
|
|
1845
|
+
if (end < start)
|
|
1846
|
+
end = start;
|
|
1847
|
+
const ref = url.searchParams.get("ref") || "worktree";
|
|
1848
|
+
if (ref === "worktree" || ref === "") {
|
|
1849
|
+
const full = safeWorktreePath(path);
|
|
1850
|
+
if (!full)
|
|
1851
|
+
return text("no file", 404);
|
|
1852
|
+
const result = await collectIndexedWorktreeLineRange(full, start, end);
|
|
1853
|
+
const body = { path, ref, start, end, lines: result.lines, total: result.total, complete: result.complete, generation };
|
|
1854
|
+
return json(body);
|
|
1855
|
+
} else {
|
|
1856
|
+
if (!verifyTreeRef(ref, cwd))
|
|
1857
|
+
return text("invalid ref", 400);
|
|
1858
|
+
const oid = objectId(ref, path, cwd);
|
|
1859
|
+
if (oid.code !== 0 || !oid.oid)
|
|
1860
|
+
return text("not in ref", 404);
|
|
1861
|
+
const size = objectByteSize(oid.oid, cwd);
|
|
1862
|
+
if (size.code !== 0)
|
|
1863
|
+
return text("cannot read ref", 500);
|
|
1864
|
+
const result = await collectIndexedGitBlobLineRange(path, oid.oid, size.size, start, end);
|
|
1865
|
+
if (!result)
|
|
1866
|
+
return text("cannot read ref", 500);
|
|
1867
|
+
const body = { path, ref, start, end, lines: result.lines, total: result.total, complete: result.complete, generation };
|
|
1868
|
+
return json(body);
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
function handleRawFile(req, url) {
|
|
1872
|
+
const path = url.searchParams.get("path") || "";
|
|
1873
|
+
if (!safePath(path))
|
|
1874
|
+
return text("forbidden", 403);
|
|
1875
|
+
const ref = url.searchParams.get("ref") || "worktree";
|
|
1876
|
+
let body;
|
|
1877
|
+
if (ref !== "worktree" && ref !== "") {
|
|
1878
|
+
if (!verifyTreeRef(ref, cwd))
|
|
1879
|
+
return text("invalid ref", 400);
|
|
1880
|
+
const size = rawFileSize(path, ref);
|
|
1881
|
+
if (size == null)
|
|
1882
|
+
return text("not in ref", 404);
|
|
1883
|
+
if (req.method === "HEAD")
|
|
1884
|
+
return new Response(null, { headers: rawFileHeaders(path, size) });
|
|
1885
|
+
const res = showBytes(ref, path, cwd);
|
|
1886
|
+
if (res.code !== 0)
|
|
1887
|
+
return text("not in ref", 404);
|
|
1888
|
+
body = res.stdout.buffer.slice(res.stdout.byteOffset, res.stdout.byteOffset + res.stdout.byteLength);
|
|
1889
|
+
return new Response(body, { headers: rawFileHeaders(path, size) });
|
|
1890
|
+
} else {
|
|
1891
|
+
const full = safeWorktreePath(path);
|
|
1892
|
+
if (!full)
|
|
1893
|
+
return text("not found", 404);
|
|
1894
|
+
const size = rawFileSize(path, ref);
|
|
1895
|
+
if (size == null)
|
|
1896
|
+
return text("not found", 404);
|
|
1897
|
+
const rangeResult = req.headers.get("range") ? parseHttpByteRange(req.headers.get("range"), size) : null;
|
|
1898
|
+
if (rangeResult?.kind === "unsatisfiable") {
|
|
1899
|
+
return new Response(null, {
|
|
1900
|
+
status: 416,
|
|
1901
|
+
headers: { ...rawFileHeaders(path, size), "Content-Range": `bytes */${size}`, "Content-Length": "0" }
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
if (rangeResult?.kind === "range") {
|
|
1905
|
+
const range = rangeResult.range;
|
|
1906
|
+
if (req.method === "HEAD") {
|
|
1907
|
+
return new Response(null, {
|
|
1908
|
+
status: 206,
|
|
1909
|
+
headers: rawFileHeaders(path, size, range)
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
return new Response(fileByteRangeResponseBody(full, range.start, range.end), {
|
|
1913
|
+
status: 206,
|
|
1914
|
+
headers: rawFileHeaders(path, size, range)
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1917
|
+
if (req.method === "HEAD")
|
|
1918
|
+
return new Response(null, { headers: rawFileHeaders(path, size) });
|
|
1919
|
+
return new Response(fileReadableStream(full), { headers: rawFileHeaders(path, size) });
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
function rawFileSize(path, ref) {
|
|
1923
|
+
if (ref !== "worktree" && ref !== "") {
|
|
1924
|
+
if (!verifyTreeRef(ref, cwd))
|
|
1925
|
+
return null;
|
|
1926
|
+
const res = objectSize(ref, path, cwd);
|
|
1927
|
+
return res.code === 0 ? res.size : null;
|
|
1928
|
+
}
|
|
1929
|
+
const full = safeWorktreePath(path);
|
|
1930
|
+
if (!full)
|
|
1931
|
+
return null;
|
|
1932
|
+
try {
|
|
1933
|
+
return statSync(full).size;
|
|
1934
|
+
} catch {
|
|
1935
|
+
return null;
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
function rawFileHeaders(path, size = null, range) {
|
|
1939
|
+
const mime = {
|
|
1940
|
+
".png": "image/png",
|
|
1941
|
+
".jpg": "image/jpeg",
|
|
1942
|
+
".jpeg": "image/jpeg",
|
|
1943
|
+
".gif": "image/gif",
|
|
1944
|
+
".webp": "image/webp",
|
|
1945
|
+
".svg": "image/svg+xml",
|
|
1946
|
+
".mp4": "video/mp4",
|
|
1947
|
+
".webm": "video/webm",
|
|
1948
|
+
".mov": "video/quicktime",
|
|
1949
|
+
".pdf": "application/pdf",
|
|
1950
|
+
".mp3": "audio/mpeg",
|
|
1951
|
+
".wav": "audio/wav",
|
|
1952
|
+
".ogg": "audio/ogg",
|
|
1953
|
+
".flac": "audio/flac",
|
|
1954
|
+
".m4a": "audio/mp4",
|
|
1955
|
+
".aac": "audio/aac",
|
|
1956
|
+
".opus": "audio/ogg"
|
|
1957
|
+
};
|
|
1958
|
+
const headers = {
|
|
1959
|
+
"Content-Type": mime[extname(path).toLowerCase()] || "application/octet-stream",
|
|
1960
|
+
"Cache-Control": "no-store",
|
|
1961
|
+
"X-Content-Type-Options": "nosniff",
|
|
1962
|
+
"Content-Security-Policy": "sandbox",
|
|
1963
|
+
"Accept-Ranges": "bytes"
|
|
1964
|
+
};
|
|
1965
|
+
if (range && size != null) {
|
|
1966
|
+
headers["Content-Length"] = String(range.end - range.start + 1);
|
|
1967
|
+
headers["Content-Range"] = `bytes ${range.start}-${range.end}/${size}`;
|
|
1968
|
+
} else if (size != null) {
|
|
1969
|
+
headers["Content-Length"] = String(size);
|
|
1970
|
+
}
|
|
1971
|
+
return headers;
|
|
1972
|
+
}
|
|
1973
|
+
function isForbiddenUploadName(name) {
|
|
1974
|
+
const lower = name.toLowerCase();
|
|
1975
|
+
return lower.startsWith(".") || lower === "package.json" || lower === "package-lock.json" || lower === "bun.lock" || lower === "bun.lockb" || lower === "yarn.lock" || lower === "pnpm-lock.yaml" || lower === "makefile" || lower === "dockerfile" || lower.endsWith(".dockerfile") || /^(tsconfig|jsconfig|bunfig|vercel|netlify|wrangler|next|vite|webpack|rollup|esbuild|astro|svelte|tailwind|postcss|babel|prettier|eslint)\./.test(lower) || lower.endsWith(".config.js") || lower.endsWith(".config.jsx") || lower.endsWith(".config.ts") || lower.endsWith(".config.tsx") || lower.endsWith(".config.mjs") || lower.endsWith(".config.cjs") || lower.includes("credential") || lower.includes("secret") || lower.endsWith(".exe") || lower.endsWith(".dll") || lower.endsWith(".dylib") || lower.endsWith(".so") || lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh") || lower.endsWith(".fish") || lower.endsWith(".ps1") || lower.endsWith(".bat") || lower.endsWith(".cmd");
|
|
1976
|
+
}
|
|
1977
|
+
function safeUploadFileName(name) {
|
|
1978
|
+
if (!name || name.includes("\x00") || name.includes("/") || name.includes("\\"))
|
|
1979
|
+
return null;
|
|
1980
|
+
if (name === "." || name === "..")
|
|
1981
|
+
return null;
|
|
1982
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._ -]{0,180}$/.test(name))
|
|
1983
|
+
return null;
|
|
1984
|
+
if (isGitInternalPath(name) || isForbiddenUploadName(name))
|
|
1985
|
+
return null;
|
|
1986
|
+
if (!SAFE_UPLOAD_EXTENSIONS.has(extname(name).toLowerCase()))
|
|
1987
|
+
return null;
|
|
1988
|
+
return name;
|
|
1989
|
+
}
|
|
1990
|
+
function uploadOpenFlags() {
|
|
1991
|
+
return constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0);
|
|
1992
|
+
}
|
|
1993
|
+
async function handleUploadFiles(req) {
|
|
1994
|
+
if (!allowUpload)
|
|
1995
|
+
return text("upload disabled", 403);
|
|
1996
|
+
if (req.method !== "POST")
|
|
1997
|
+
return text("method not allowed", 405);
|
|
1998
|
+
if (!sideEffectRequestAllowed(req))
|
|
1999
|
+
return text("forbidden", 403);
|
|
2000
|
+
if (req.headers.get("content-encoding"))
|
|
2001
|
+
return text("unsupported media type", 415);
|
|
2002
|
+
const lengthHeader = req.headers.get("content-length");
|
|
2003
|
+
if (!lengthHeader)
|
|
2004
|
+
return text("content length required", 411);
|
|
2005
|
+
const length = Number(lengthHeader);
|
|
2006
|
+
if (!Number.isSafeInteger(length) || length < 0)
|
|
2007
|
+
return text("invalid content length", 400);
|
|
2008
|
+
if (length > MAX_UPLOAD_BODY_BYTES)
|
|
2009
|
+
return text("upload too large", 413);
|
|
2010
|
+
const contentType = req.headers.get("content-type") || "";
|
|
2011
|
+
if (!/^multipart\/form-data;\s*boundary=/i.test(contentType))
|
|
2012
|
+
return text("unsupported media type", 415);
|
|
2013
|
+
let form;
|
|
2014
|
+
try {
|
|
2015
|
+
form = await req.formData();
|
|
2016
|
+
} catch {
|
|
2017
|
+
return text("invalid form data", 400);
|
|
2018
|
+
}
|
|
2019
|
+
const dir = String(form.get("dir") || "").replace(/^\/+|\/+$/g, "");
|
|
2020
|
+
if (!safeRepoPath(dir))
|
|
2021
|
+
return text("invalid dir", 400);
|
|
2022
|
+
if (dir && isGitInternalPath(dir))
|
|
2023
|
+
return text("forbidden", 403);
|
|
2024
|
+
const realDir = safeOpenWorktreePath(dir);
|
|
2025
|
+
if (!realDir)
|
|
2026
|
+
return text("not found", 404);
|
|
2027
|
+
const stats = statSync(realDir);
|
|
2028
|
+
if (!stats.isDirectory())
|
|
2029
|
+
return text("not a directory", 400);
|
|
2030
|
+
const files = form.getAll("files").filter((item) => item instanceof File);
|
|
2031
|
+
if (!files.length)
|
|
2032
|
+
return text("no files", 400);
|
|
2033
|
+
if (files.length > MAX_UPLOAD_FILES)
|
|
2034
|
+
return text("too many files", 413);
|
|
2035
|
+
let total = 0;
|
|
2036
|
+
const names = new Set;
|
|
2037
|
+
const uploads = [];
|
|
2038
|
+
for (const file of files) {
|
|
2039
|
+
const safeName = safeUploadFileName(file.name);
|
|
2040
|
+
if (!safeName)
|
|
2041
|
+
return text("invalid filename", 400);
|
|
2042
|
+
const lowerName = safeName.toLowerCase();
|
|
2043
|
+
if (names.has(lowerName))
|
|
2044
|
+
return text("duplicate filename", 409);
|
|
2045
|
+
names.add(lowerName);
|
|
2046
|
+
if (file.size > MAX_UPLOAD_FILE_BYTES)
|
|
2047
|
+
return text("file too large", 413);
|
|
2048
|
+
total += file.size;
|
|
2049
|
+
if (total > MAX_UPLOAD_TOTAL_BYTES)
|
|
2050
|
+
return text("upload too large", 413);
|
|
2051
|
+
const target = join4(realDir, safeName);
|
|
2052
|
+
if (relative(realDir, dirname2(target)) !== "")
|
|
2053
|
+
return text("invalid filename", 400);
|
|
2054
|
+
if (existsSync3(target))
|
|
2055
|
+
return text("file exists", 409);
|
|
2056
|
+
uploads.push({ file, name: safeName, target });
|
|
2057
|
+
}
|
|
2058
|
+
const written = [];
|
|
2059
|
+
try {
|
|
2060
|
+
for (const upload of uploads) {
|
|
2061
|
+
const fd = openSync(upload.target, uploadOpenFlags(), 420);
|
|
2062
|
+
try {
|
|
2063
|
+
writeFileSync(fd, new Uint8Array(await upload.file.arrayBuffer()));
|
|
2064
|
+
} finally {
|
|
2065
|
+
closeSync(fd);
|
|
2066
|
+
}
|
|
2067
|
+
written.push(upload.target);
|
|
2068
|
+
}
|
|
2069
|
+
} catch (error) {
|
|
2070
|
+
for (const path of written) {
|
|
2071
|
+
try {
|
|
2072
|
+
unlinkSync(path);
|
|
2073
|
+
} catch {}
|
|
2074
|
+
}
|
|
2075
|
+
if (error.code === "EEXIST")
|
|
2076
|
+
return text("file exists", 409);
|
|
2077
|
+
return text("upload failed", 500);
|
|
2078
|
+
}
|
|
2079
|
+
generation++;
|
|
2080
|
+
fileCache.clear();
|
|
2081
|
+
metaCache.clear();
|
|
2082
|
+
sendSse("update");
|
|
2083
|
+
return json({ ok: true, files: uploads.map((upload) => upload.name), generation });
|
|
2084
|
+
}
|
|
2085
|
+
function openOsPath(path) {
|
|
2086
|
+
const cmd = process.platform === "darwin" ? ["open", "--", path] : process.platform === "win32" ? ["explorer.exe", path] : ["xdg-open", path];
|
|
2087
|
+
spawnDetached(cmd);
|
|
2088
|
+
}
|
|
2089
|
+
async function handleOpenPath(req) {
|
|
2090
|
+
if (req.method !== "POST")
|
|
2091
|
+
return text("method not allowed", 405);
|
|
2092
|
+
if (!sideEffectRequestAllowed(req))
|
|
2093
|
+
return text("forbidden", 403);
|
|
2094
|
+
const contentType = req.headers.get("content-type") || "";
|
|
2095
|
+
if (!/^application\/json(?:;|$)/i.test(contentType))
|
|
2096
|
+
return text("unsupported media type", 415);
|
|
2097
|
+
const length = Number(req.headers.get("content-length") || "0");
|
|
2098
|
+
if (length > 1024)
|
|
2099
|
+
return text("payload too large", 413);
|
|
2100
|
+
let body = {};
|
|
2101
|
+
try {
|
|
2102
|
+
const raw = await req.text();
|
|
2103
|
+
if (raw.length > 1024)
|
|
2104
|
+
return text("payload too large", 413);
|
|
2105
|
+
body = JSON.parse(raw);
|
|
2106
|
+
} catch {
|
|
2107
|
+
return text("invalid json", 400);
|
|
2108
|
+
}
|
|
2109
|
+
const path = typeof body.path === "string" ? body.path.replace(/^\/+|\/+$/g, "") : "";
|
|
2110
|
+
const kind = body.kind;
|
|
2111
|
+
if (kind !== "directory" && kind !== "file-parent")
|
|
2112
|
+
return text("invalid kind", 400);
|
|
2113
|
+
if (kind === "file-parent" && !path)
|
|
2114
|
+
return text("invalid path", 400);
|
|
2115
|
+
if (!safeRepoPath(path))
|
|
2116
|
+
return text("invalid path", 400);
|
|
2117
|
+
if (path && isGitInternalPath(path))
|
|
2118
|
+
return text("forbidden", 403);
|
|
2119
|
+
const targetPath = kind === "file-parent" ? parentRepoPath(path) : path;
|
|
2120
|
+
const target = safeOpenWorktreePath(targetPath);
|
|
2121
|
+
if (!target)
|
|
2122
|
+
return text("not found", 404);
|
|
2123
|
+
const stats = statSync(target);
|
|
2124
|
+
if (!stats.isDirectory())
|
|
2125
|
+
return text("not a directory", 400);
|
|
2126
|
+
openOsPath(target);
|
|
2127
|
+
return json({ ok: true });
|
|
2128
|
+
}
|
|
2129
|
+
function sendSse(event, data = "tick") {
|
|
2130
|
+
const payload = enc.encode(`event: ${event}
|
|
2131
|
+
data: ${data}
|
|
2132
|
+
|
|
2133
|
+
`);
|
|
2134
|
+
for (const client of [...sseClients]) {
|
|
2135
|
+
try {
|
|
2136
|
+
client.enqueue(payload);
|
|
2137
|
+
} catch {
|
|
2138
|
+
sseClients.delete(client);
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
function openBrowser(url) {
|
|
2143
|
+
const cmd = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd.exe", "/c", "start", "", url] : ["xdg-open", url];
|
|
2144
|
+
spawnDetached(cmd);
|
|
2145
|
+
}
|
|
2146
|
+
parseCli();
|
|
2147
|
+
var server = await startServer({
|
|
2148
|
+
hostname: "127.0.0.1",
|
|
2149
|
+
port: listenPort,
|
|
2150
|
+
async fetch(req) {
|
|
2151
|
+
if (!requestAllowed(req))
|
|
2152
|
+
return text("forbidden", 403);
|
|
2153
|
+
const url = new URL(req.url);
|
|
2154
|
+
const staticResponse = staticFile(url.pathname);
|
|
2155
|
+
if (staticResponse)
|
|
2156
|
+
return staticResponse;
|
|
2157
|
+
if (url.pathname === "/diff.json")
|
|
2158
|
+
return handleDiffJson(url);
|
|
2159
|
+
if (url.pathname === "/_settings")
|
|
2160
|
+
return handleSettings();
|
|
2161
|
+
if (url.pathname === "/_tree")
|
|
2162
|
+
return handleTree(url);
|
|
2163
|
+
if (url.pathname === "/_files")
|
|
2164
|
+
return handleFiles(url);
|
|
2165
|
+
if (url.pathname === "/_grep")
|
|
2166
|
+
return handleGrep(url);
|
|
2167
|
+
if (url.pathname === "/file_diff")
|
|
2168
|
+
return handleFileDiff(url);
|
|
2169
|
+
if (url.pathname === "/file_range")
|
|
2170
|
+
return handleFileRange(url);
|
|
2171
|
+
if (url.pathname === "/_file")
|
|
2172
|
+
return handleRawFile(req, url);
|
|
2173
|
+
if (url.pathname === "/_open_path")
|
|
2174
|
+
return handleOpenPath(req);
|
|
2175
|
+
if (url.pathname === "/_upload_files")
|
|
2176
|
+
return handleUploadFiles(req);
|
|
2177
|
+
if (url.pathname === "/_refs")
|
|
2178
|
+
return json(refs(cwd));
|
|
2179
|
+
if (url.pathname === "/refresh" && req.method === "POST") {
|
|
2180
|
+
if (!sideEffectRequestAllowed(req))
|
|
2181
|
+
return text("forbidden", 403);
|
|
2182
|
+
generation++;
|
|
2183
|
+
fileCache.clear();
|
|
2184
|
+
metaCache.clear();
|
|
2185
|
+
fileListCache.clear();
|
|
2186
|
+
sendSse("update");
|
|
2187
|
+
return json({ ok: true, generation });
|
|
2188
|
+
}
|
|
2189
|
+
if (url.pathname === "/events") {
|
|
2190
|
+
let ctrl;
|
|
2191
|
+
let keepalive;
|
|
2192
|
+
return new Response(new ReadableStream({
|
|
2193
|
+
start(controller) {
|
|
2194
|
+
ctrl = controller;
|
|
2195
|
+
sseClients.add(controller);
|
|
2196
|
+
controller.enqueue(enc.encode(`event: open
|
|
2197
|
+
data: ok
|
|
2198
|
+
|
|
2199
|
+
`));
|
|
2200
|
+
keepalive = setInterval(() => {
|
|
2201
|
+
try {
|
|
2202
|
+
controller.enqueue(enc.encode(`: ping
|
|
2203
|
+
|
|
2204
|
+
`));
|
|
2205
|
+
} catch {
|
|
2206
|
+
sseClients.delete(controller);
|
|
2207
|
+
clearInterval(keepalive);
|
|
2208
|
+
}
|
|
2209
|
+
}, 15000);
|
|
2210
|
+
},
|
|
2211
|
+
cancel() {
|
|
2212
|
+
if (ctrl)
|
|
2213
|
+
sseClients.delete(ctrl);
|
|
2214
|
+
if (keepalive)
|
|
2215
|
+
clearInterval(keepalive);
|
|
2216
|
+
}
|
|
2217
|
+
}), {
|
|
2218
|
+
headers: {
|
|
2219
|
+
"Content-Type": "text/event-stream",
|
|
2220
|
+
"Cache-Control": "no-cache"
|
|
2221
|
+
}
|
|
2222
|
+
});
|
|
2223
|
+
}
|
|
2224
|
+
return text("not found", 404);
|
|
2225
|
+
}
|
|
2226
|
+
});
|
|
2227
|
+
startDevAssetReload({
|
|
2228
|
+
enabled: process.env.CODE_VIEWER_DEV === "1",
|
|
2229
|
+
webRoot: WEB_ROOT,
|
|
2230
|
+
watchedFiles: WATCHED_ASSET_FILES,
|
|
2231
|
+
watch,
|
|
2232
|
+
sendReload: () => sendSse("reload")
|
|
2233
|
+
});
|
|
2234
|
+
console.log(`GDP_LISTEN_URL=http://127.0.0.1:${server.port}/`);
|
|
2235
|
+
console.log(`git-diff-preview serving ${cwd}`);
|