@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
|
@@ -1,985 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
|
|
3
|
-
import { closeSync, constants, existsSync, lstatSync, openSync, readFileSync, realpathSync, statSync, unlinkSync, watch, writeFileSync } from 'node:fs';
|
|
4
|
-
import { basename, dirname, extname, join, normalize, relative } from 'node:path';
|
|
5
|
-
import { APP_ENTRY_PATHS, SPA_PATHS } from '../routes';
|
|
6
|
-
import type { DiffMeta, FileDiffResponse, FileMeta, FileRangeResponse, FileSearchListResponse, GrepMatch, GrepResponse, RepoTreeResponse, SettingsResponse } from '../types';
|
|
7
|
-
import { cacheFresh, fileDiffCacheKey, setTimedCacheEntry, type TimedCacheEntry } from './cache';
|
|
8
|
-
import { startDevAssetReload } from './dev-assets';
|
|
9
|
-
import * as git from './git';
|
|
10
|
-
import { isSameWorktreeRange } from './range';
|
|
11
|
-
import {
|
|
12
|
-
GREP_MAX_FILE_BYTES,
|
|
13
|
-
buildFileSearchList,
|
|
14
|
-
buildRgArgs,
|
|
15
|
-
fixedStringLineMatches,
|
|
16
|
-
isSkippableSearchPath,
|
|
17
|
-
normalizeGrepMax,
|
|
18
|
-
parseGitGrepOutput,
|
|
19
|
-
parseRgOutput,
|
|
20
|
-
} from './search';
|
|
21
|
-
|
|
22
|
-
const ROOT = normalize(join(import.meta.dir, '..', '..'));
|
|
23
|
-
const WEB_ROOT = join(ROOT, 'web');
|
|
24
|
-
const VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version as string;
|
|
25
|
-
const DEFAULT_ARGS = ['HEAD'];
|
|
26
|
-
const PREVIEW_HUNKS_DEFAULT = 3;
|
|
27
|
-
const PREVIEW_LINES_DEFAULT = 1200;
|
|
28
|
-
const WATCHED_ASSET_FILES = ['index.html', 'style.css', 'app.js'];
|
|
29
|
-
const SIZE_SMALL = 2000;
|
|
30
|
-
const SIZE_MEDIUM = 8000;
|
|
31
|
-
const SIZE_LARGE = 20000;
|
|
32
|
-
const MAX_UPLOAD_FILE_BYTES = 10 * 1024 * 1024;
|
|
33
|
-
const MAX_UPLOAD_TOTAL_BYTES = 50 * 1024 * 1024;
|
|
34
|
-
const MAX_UPLOAD_BODY_BYTES = MAX_UPLOAD_TOTAL_BYTES + 1024 * 1024;
|
|
35
|
-
const MAX_UPLOAD_FILES = 50;
|
|
36
|
-
const SAFE_UPLOAD_EXTENSIONS = new Set([
|
|
37
|
-
'.txt', '.md', '.markdown', '.json', '.csv', '.tsv', '.yaml', '.yml', '.toml',
|
|
38
|
-
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.pdf',
|
|
39
|
-
'.ts', '.tsx', '.js', '.jsx', '.css', '.scss', '.html',
|
|
40
|
-
]);
|
|
41
|
-
|
|
42
|
-
let generation = 1;
|
|
43
|
-
let cwd = git.repoRoot(process.cwd()) || process.cwd();
|
|
44
|
-
let cliArgs = DEFAULT_ARGS;
|
|
45
|
-
let listenPort = 0;
|
|
46
|
-
let allowUpload = false;
|
|
47
|
-
let uploadAllowedByCli = false;
|
|
48
|
-
let scopeOmitDirNames = git.DEFAULT_WORKTREE_OMIT_DIR_NAMES;
|
|
49
|
-
let scopeOmitDirCliOverride: string[] | null = null;
|
|
50
|
-
let rgAvailableCache: boolean | null = null;
|
|
51
|
-
|
|
52
|
-
const enc = new TextEncoder();
|
|
53
|
-
const sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
|
|
54
|
-
const fileCache = new Map<string, TimedCacheEntry<{ diffText: string }>>();
|
|
55
|
-
const metaCache = new Map<string, TimedCacheEntry<{ body: string; sig: string }>>();
|
|
56
|
-
const fileListCache = new Map<string, { generation: number; body: FileSearchListResponse }>();
|
|
57
|
-
|
|
58
|
-
function parseCli() {
|
|
59
|
-
const rest: string[] = [];
|
|
60
|
-
for (let i = 2; i < process.argv.length; i++) {
|
|
61
|
-
const arg = process.argv[i];
|
|
62
|
-
if (arg === '--help' || arg === '-h') {
|
|
63
|
-
console.log(`code-viewer ${VERSION}
|
|
64
|
-
|
|
65
|
-
Usage:
|
|
66
|
-
code-viewer [--cwd <repo>] [--port <port>] [--open] [git-diff-args...]
|
|
67
|
-
|
|
68
|
-
Examples:
|
|
69
|
-
code-viewer --open
|
|
70
|
-
code-viewer --cwd /path/to/repo --open
|
|
71
|
-
code-viewer HEAD~1 HEAD
|
|
72
|
-
code-viewer --staged
|
|
73
|
-
`);
|
|
74
|
-
process.exit(0);
|
|
75
|
-
} else if (arg === '--version' || arg === '-v') {
|
|
76
|
-
console.log(VERSION);
|
|
77
|
-
process.exit(0);
|
|
78
|
-
} else if (arg === '--cwd') {
|
|
79
|
-
const next = process.argv[++i];
|
|
80
|
-
if (!next) {
|
|
81
|
-
console.error('--cwd requires a value');
|
|
82
|
-
process.exit(1);
|
|
83
|
-
}
|
|
84
|
-
try {
|
|
85
|
-
cwd = git.repoRoot(next) || realpathSync(next);
|
|
86
|
-
} catch {
|
|
87
|
-
console.error('--cwd must point to an existing directory');
|
|
88
|
-
process.exit(1);
|
|
89
|
-
}
|
|
90
|
-
} else if (arg === '--port') {
|
|
91
|
-
const next = process.argv[++i];
|
|
92
|
-
const parsed = Number(next);
|
|
93
|
-
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
|
|
94
|
-
console.error('--port requires a TCP port number');
|
|
95
|
-
process.exit(1);
|
|
96
|
-
}
|
|
97
|
-
listenPort = parsed;
|
|
98
|
-
} else if (arg === '--open') {
|
|
99
|
-
setTimeout(() => openBrowser(`http://127.0.0.1:${server.port}/`), 0);
|
|
100
|
-
} else if (arg === '--allow-upload') {
|
|
101
|
-
allowUpload = true;
|
|
102
|
-
uploadAllowedByCli = true;
|
|
103
|
-
} else if (arg === '--scope-omit-dir') {
|
|
104
|
-
const next = process.argv[++i];
|
|
105
|
-
if (!next) {
|
|
106
|
-
console.error('--scope-omit-dir requires a directory name');
|
|
107
|
-
process.exit(1);
|
|
108
|
-
}
|
|
109
|
-
scopeOmitDirCliOverride = normalizeScopeOmitDirNames([...(scopeOmitDirCliOverride || []), next]);
|
|
110
|
-
} else {
|
|
111
|
-
rest.push(arg);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
if (rest.length) cliArgs = rest;
|
|
115
|
-
if (!uploadAllowedByCli) allowUpload = loadProjectConfigUploadEnabled();
|
|
116
|
-
const configScopeOmitDirs = loadProjectConfigScopeOmitDirs();
|
|
117
|
-
if (scopeOmitDirCliOverride) {
|
|
118
|
-
scopeOmitDirNames = scopeOmitDirCliOverride;
|
|
119
|
-
} else if (configScopeOmitDirs) {
|
|
120
|
-
scopeOmitDirNames = configScopeOmitDirs;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function json(data: unknown, init: ResponseInit = {}) {
|
|
125
|
-
return new Response(JSON.stringify(data), {
|
|
126
|
-
...init,
|
|
127
|
-
headers: {
|
|
128
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
129
|
-
'Cache-Control': 'no-store',
|
|
130
|
-
...(init.headers || {}),
|
|
131
|
-
},
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function text(body: string, status = 200) {
|
|
136
|
-
return new Response(body, {
|
|
137
|
-
status,
|
|
138
|
-
headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' },
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function requestAllowed(req: Request) {
|
|
143
|
-
const host = req.headers.get('host') || '';
|
|
144
|
-
const origin = req.headers.get('origin');
|
|
145
|
-
const okHost = /^(127\.0\.0\.1|localhost|\[::1\]):\d+$/i.test(host);
|
|
146
|
-
const okOrigin = !origin || origin === 'null' || /^http:\/\/(127\.0\.0\.1|localhost|\[::1\]):\d+$/i.test(origin);
|
|
147
|
-
return okHost && okOrigin;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function sideEffectRequestAllowed(req: Request) {
|
|
151
|
-
const host = req.headers.get('host') || '';
|
|
152
|
-
const origin = req.headers.get('origin');
|
|
153
|
-
const fetchSite = req.headers.get('sec-fetch-site');
|
|
154
|
-
const requestedBy = req.headers.get('x-code-viewer-action');
|
|
155
|
-
return /^(127\.0\.0\.1|localhost|\[::1\]):\d+$/i.test(host) &&
|
|
156
|
-
origin === `http://${host}` &&
|
|
157
|
-
(!fetchSite || fetchSite === 'same-origin') &&
|
|
158
|
-
requestedBy === '1';
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function staticFile(pathname: string): Response | null {
|
|
162
|
-
const map: Record<string, [string, string]> = {
|
|
163
|
-
'/favicon.png': ['favicon.png', 'image/png'],
|
|
164
|
-
'/style.css': ['style.css', 'text/css; charset=utf-8'],
|
|
165
|
-
'/app.js': ['app.js', 'application/javascript; charset=utf-8'],
|
|
166
|
-
'/mermaid.js': ['mermaid.js', 'application/javascript; charset=utf-8'],
|
|
167
|
-
'/shiki.js': ['shiki.js', 'application/javascript; charset=utf-8'],
|
|
168
|
-
'/vendor/diff2html/diff2html.min.css': ['vendor/diff2html/diff2html.min.css', 'text/css; charset=utf-8'],
|
|
169
|
-
'/vendor/diff2html/diff2html-ui.min.js': ['vendor/diff2html/diff2html-ui.min.js', 'application/javascript; charset=utf-8'],
|
|
170
|
-
'/vendor/highlight.js/highlight.min.js': ['vendor/highlight.js/highlight.min.js', 'application/javascript; charset=utf-8'],
|
|
171
|
-
'/vendor/highlight.js/styles/github.min.css': ['vendor/highlight.js/styles/github.min.css', 'text/css; charset=utf-8'],
|
|
172
|
-
'/vendor/highlight.js/styles/github-dark.min.css': ['vendor/highlight.js/styles/github-dark.min.css', 'text/css; charset=utf-8'],
|
|
173
|
-
};
|
|
174
|
-
for (const spaPath of [...APP_ENTRY_PATHS, ...SPA_PATHS]) {
|
|
175
|
-
map[spaPath] = ['index.html', 'text/html; charset=utf-8'];
|
|
176
|
-
}
|
|
177
|
-
const spec = map[pathname];
|
|
178
|
-
if (!spec) return null;
|
|
179
|
-
const full = join(WEB_ROOT, spec[0]);
|
|
180
|
-
if (!existsSync(full)) return text('not found', 404);
|
|
181
|
-
return new Response(readFileSync(full), {
|
|
182
|
-
headers: { 'Content-Type': spec[1], 'Cache-Control': 'no-store' },
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function buildRangeArgs(range: { from?: string; to?: string }) {
|
|
187
|
-
const refs = [];
|
|
188
|
-
if (range.from && range.from !== 'worktree') refs.push(range.from);
|
|
189
|
-
if (range.to && range.to !== 'worktree') refs.push(range.to);
|
|
190
|
-
return { args: refs.length ? refs : cliArgs, refs };
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function includeUntracked(range: { from?: string; to?: string }, refs: string[]) {
|
|
194
|
-
const toWorktree = !range.to || range.to === 'worktree';
|
|
195
|
-
if (refs.length > 0) return toWorktree && refs.length < 2;
|
|
196
|
-
return cliArgs.length === 0 || (cliArgs.length === 1 && cliArgs[0] === 'HEAD');
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function guessMediaKind(path: string) {
|
|
200
|
-
const ext = extname(path).slice(1).toLowerCase();
|
|
201
|
-
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico'].includes(ext)) return 'image';
|
|
202
|
-
if (['mp4', 'webm', 'mov'].includes(ext)) return 'video';
|
|
203
|
-
if (['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'opus'].includes(ext)) return 'audio';
|
|
204
|
-
return null;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function classify(file: git.GitFileMeta) {
|
|
208
|
-
if (file.binary) return 'binary';
|
|
209
|
-
const total = (file.additions || 0) + (file.deletions || 0);
|
|
210
|
-
if (total <= SIZE_SMALL) return 'small';
|
|
211
|
-
if (total <= SIZE_MEDIUM) return 'medium';
|
|
212
|
-
if (total <= SIZE_LARGE) return 'large';
|
|
213
|
-
return 'huge';
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function estimateHeight(file: git.GitFileMeta, sizeClass: string) {
|
|
217
|
-
if (file.binary) return 380;
|
|
218
|
-
if (sizeClass === 'small') return Math.min(800, ((file.additions || 0) + (file.deletions || 0) + 10) * 22);
|
|
219
|
-
return 140;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function buildQuery(params: Record<string, unknown>) {
|
|
223
|
-
const q = new URLSearchParams();
|
|
224
|
-
for (const key of Object.keys(params).sort()) {
|
|
225
|
-
const value = params[key];
|
|
226
|
-
if (value !== undefined && value !== null && value !== '') q.set(key, String(value));
|
|
227
|
-
}
|
|
228
|
-
const s = q.toString();
|
|
229
|
-
return s ? `?${s}` : '';
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function fileToMeta(file: git.GitFileMeta, range: { from?: string; to?: string }, extraQs: Record<string, string>): FileMeta {
|
|
233
|
-
const sizeClass = classify(file);
|
|
234
|
-
const q = { path: file.path, old_path: file.old_path, status: file.status, from: range.from, to: range.to, ...extraQs };
|
|
235
|
-
if (file.untracked) Object.assign(q, { untracked: '1' });
|
|
236
|
-
const previewQ = { ...q, mode: 'preview', max_hunks: PREVIEW_HUNKS_DEFAULT };
|
|
237
|
-
const previewUrl = sizeClass !== 'small' ? `/file_diff${buildQuery(previewQ)}` : null;
|
|
238
|
-
return {
|
|
239
|
-
order: file.order,
|
|
240
|
-
key: `${file.status || 'M'}\0${file.old_path || ''}\0${file.path}`,
|
|
241
|
-
path: file.path,
|
|
242
|
-
old_path: file.old_path,
|
|
243
|
-
display_path: file.path,
|
|
244
|
-
status: file.status || 'M',
|
|
245
|
-
additions: file.additions || 0,
|
|
246
|
-
deletions: file.deletions || 0,
|
|
247
|
-
binary: file.binary || false,
|
|
248
|
-
media_kind: guessMediaKind(file.path),
|
|
249
|
-
size_class: sizeClass,
|
|
250
|
-
force_layout: sizeClass === 'huge' ? 'line-by-line' : undefined,
|
|
251
|
-
highlight: sizeClass === 'small',
|
|
252
|
-
load_url: `/file_diff${buildQuery(q)}`,
|
|
253
|
-
preview_url: previewUrl,
|
|
254
|
-
estimated_height_px: estimateHeight(file, sizeClass),
|
|
255
|
-
untracked: file.untracked || false,
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function computePayload(extras: string[], range: { from?: string; to?: string }): DiffMeta {
|
|
260
|
-
if (isSameWorktreeRange(range)) {
|
|
261
|
-
return {
|
|
262
|
-
files: [],
|
|
263
|
-
totals: { files: 0, additions: 0, deletions: 0 },
|
|
264
|
-
range: 'worktree .. worktree',
|
|
265
|
-
project: basename(cwd),
|
|
266
|
-
branch: git.currentBranch(cwd) || undefined,
|
|
267
|
-
generation,
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
const { args, refs } = buildRangeArgs(range);
|
|
271
|
-
const fullArgs = [...extras, ...args];
|
|
272
|
-
const files = git.fileMeta(fullArgs, cwd, false);
|
|
273
|
-
if (includeUntracked(range, refs)) files.push(...git.untrackedMeta(cwd));
|
|
274
|
-
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
275
|
-
files.forEach((file, i) => { file.order = i + 1; });
|
|
276
|
-
const extraQs: Record<string, string> = {};
|
|
277
|
-
for (const e of extras) {
|
|
278
|
-
if (e === '-w' || e === '--ignore-all-space') extraQs.ignore_ws = '1';
|
|
279
|
-
if (e === '--ignore-blank-lines') extraQs.ignore_blank = '1';
|
|
280
|
-
}
|
|
281
|
-
const meta = files.map((file) => fileToMeta(file, range, extraQs));
|
|
282
|
-
const totals = meta.reduce((acc, file) => {
|
|
283
|
-
acc.additions += file.additions || 0;
|
|
284
|
-
acc.deletions += file.deletions || 0;
|
|
285
|
-
return acc;
|
|
286
|
-
}, { files: meta.length, additions: 0, deletions: 0 });
|
|
287
|
-
const toWorktree = !range.to || range.to === 'worktree';
|
|
288
|
-
const label = refs.length ? `${refs.join(' .. ')}${toWorktree && refs.length === 1 ? ' .. worktree' : ''}` : cliArgs.join(' ');
|
|
289
|
-
return { files: meta, totals, range: label || 'HEAD', project: basename(cwd), branch: git.currentBranch(cwd) || undefined, generation };
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function handleDiffJson(url: URL) {
|
|
293
|
-
const extras = [];
|
|
294
|
-
if (url.searchParams.get('ignore_ws') === '1') extras.push('-w');
|
|
295
|
-
if (url.searchParams.get('ignore_blank') === '1') extras.push('--ignore-blank-lines');
|
|
296
|
-
const range = { from: url.searchParams.get('from') || '', to: url.searchParams.get('to') || '' };
|
|
297
|
-
const key = `${range.from}|${range.to}|${url.searchParams.get('ignore_ws') || ''}|${url.searchParams.get('ignore_blank') || ''}`;
|
|
298
|
-
if (url.searchParams.get('nocache') === '1') {
|
|
299
|
-
const payload = computePayload(extras, range);
|
|
300
|
-
const sig = JSON.stringify({ ...payload, generation: undefined });
|
|
301
|
-
const cached = metaCache.get(key);
|
|
302
|
-
if (!cached || cached.sig !== sig) {
|
|
303
|
-
generation++;
|
|
304
|
-
payload.generation = generation;
|
|
305
|
-
metaCache.clear();
|
|
306
|
-
fileCache.clear();
|
|
307
|
-
}
|
|
308
|
-
const body = JSON.stringify(payload);
|
|
309
|
-
setTimedCacheEntry(metaCache, key, { body, sig });
|
|
310
|
-
return new Response(body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
|
|
311
|
-
}
|
|
312
|
-
const cached = metaCache.get(key);
|
|
313
|
-
if (cacheFresh(cached)) return new Response(cached.body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
|
|
314
|
-
const payload = computePayload(extras, range);
|
|
315
|
-
const body = JSON.stringify(payload);
|
|
316
|
-
setTimedCacheEntry(metaCache, key, { body, sig: JSON.stringify({ ...payload, generation: undefined }) });
|
|
317
|
-
return new Response(body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function safePath(path: string) {
|
|
321
|
-
if (!path || path.startsWith('/') || path.startsWith('\\') || path.includes('\0')) return false;
|
|
322
|
-
return !path.split(/[\\/]+/).includes('..');
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function safeRepoPath(path: string) {
|
|
326
|
-
return path === '' || safePath(path);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function normalizeScopeOmitDirNames(names: unknown): string[] {
|
|
330
|
-
if (!Array.isArray(names)) return [];
|
|
331
|
-
return [...new Set(names
|
|
332
|
-
.filter((name): name is string => typeof name === 'string')
|
|
333
|
-
.map(name => name.trim())
|
|
334
|
-
.filter(name => name && name.length <= 64 && !name.includes('/') && !name.includes('\\') && !name.includes('\0') && name !== '.' && name !== '..' && name !== '.git'))]
|
|
335
|
-
.sort((a, b) => a.localeCompare(b));
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function parseScopeOmitDirNamesQuery(value: string): string[] | null {
|
|
339
|
-
const names = value ? value.split(',') : [];
|
|
340
|
-
if (names.length > 100) return null;
|
|
341
|
-
for (const raw of names) {
|
|
342
|
-
const name = raw.trim();
|
|
343
|
-
if (!name || name.length > 64 || name.includes('/') || name.includes('\\') || name.includes('\0') || name === '.' || name === '..' || name === '.git') return null;
|
|
344
|
-
}
|
|
345
|
-
return normalizeScopeOmitDirNames(names);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function loadProjectConfig(): Record<string, unknown> | null {
|
|
349
|
-
const full = join(cwd, '.code-viewer.json');
|
|
350
|
-
if (!existsSync(full)) return null;
|
|
351
|
-
let realCwd: string;
|
|
352
|
-
let realConfig: string;
|
|
353
|
-
try {
|
|
354
|
-
realCwd = realpathSync(cwd);
|
|
355
|
-
realConfig = realpathSync(full);
|
|
356
|
-
} catch {
|
|
357
|
-
return null;
|
|
358
|
-
}
|
|
359
|
-
if (dirname(realConfig) !== realCwd || basename(realConfig) !== '.code-viewer.json') return null;
|
|
360
|
-
try {
|
|
361
|
-
const parsed = JSON.parse(readFileSync(realConfig, 'utf8'));
|
|
362
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'version' in parsed && (parsed as { version?: unknown }).version !== 1) return null;
|
|
363
|
-
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
364
|
-
? parsed as Record<string, unknown>
|
|
365
|
-
: null;
|
|
366
|
-
} catch {
|
|
367
|
-
return null;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function loadProjectConfigUploadEnabled(): boolean {
|
|
372
|
-
const config = loadProjectConfig() as { upload?: { enabled?: unknown } } | null;
|
|
373
|
-
return config?.upload?.enabled === true;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function loadProjectConfigScopeOmitDirs(): string[] | null {
|
|
377
|
-
const config = loadProjectConfig() as { scope?: { omitDirs?: unknown } } | null;
|
|
378
|
-
if (!config?.scope || !Array.isArray(config.scope.omitDirs)) return null;
|
|
379
|
-
return normalizeScopeOmitDirNames(config.scope.omitDirs);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function scopeOmitDirNamesFromQuery(url: URL): string[] {
|
|
383
|
-
if (!url.searchParams.has('omit_dirs')) return scopeOmitDirNames;
|
|
384
|
-
return parseScopeOmitDirNamesQuery(url.searchParams.get('omit_dirs') || '') || scopeOmitDirNames;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function invalidScopeOmitDirNamesQuery(url: URL): boolean {
|
|
388
|
-
return url.searchParams.has('omit_dirs') && !parseScopeOmitDirNamesQuery(url.searchParams.get('omit_dirs') || '');
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
function isGitInternalPath(path: string): boolean {
|
|
392
|
-
return path.split(/[\\/]+/).some(part => part.toLowerCase() === '.git');
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
function safeWorktreePath(path: string): string | null {
|
|
396
|
-
if (!safePath(path)) return null;
|
|
397
|
-
if (isGitInternalPath(path)) return null;
|
|
398
|
-
const full = join(cwd, path);
|
|
399
|
-
if (!existsSync(full)) return null;
|
|
400
|
-
let realCwd: string;
|
|
401
|
-
let realFull: string;
|
|
402
|
-
try {
|
|
403
|
-
realCwd = realpathSync(cwd);
|
|
404
|
-
realFull = realpathSync(full);
|
|
405
|
-
} catch {
|
|
406
|
-
return null;
|
|
407
|
-
}
|
|
408
|
-
const rel = relative(realCwd, realFull);
|
|
409
|
-
if (rel === '' || rel.startsWith('..') || rel.startsWith('/') || rel.startsWith('\\')) return null;
|
|
410
|
-
if (isGitInternalPath(rel)) return null;
|
|
411
|
-
return realFull;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function safeOpenWorktreePath(path: string): string | null {
|
|
415
|
-
if (path === '') {
|
|
416
|
-
try {
|
|
417
|
-
const realCwd = realpathSync(cwd);
|
|
418
|
-
if (isGitInternalPath(realCwd)) return null;
|
|
419
|
-
return realCwd;
|
|
420
|
-
} catch {
|
|
421
|
-
return null;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
return safeWorktreePath(path);
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
function parentRepoPath(path: string): string {
|
|
428
|
-
const parent = dirname(path);
|
|
429
|
-
return parent === '.' ? '' : parent;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
function readReadme(target: string, dirPath: string): RepoTreeResponse['readme'] {
|
|
433
|
-
const candidates = ['README.md', 'readme.md', 'README.markdown', 'README'];
|
|
434
|
-
for (const name of candidates) {
|
|
435
|
-
const path = dirPath ? `${dirPath}/${name}` : name;
|
|
436
|
-
if (target === 'worktree' || target === '') {
|
|
437
|
-
const full = safeWorktreePath(path);
|
|
438
|
-
if (!full) continue;
|
|
439
|
-
try {
|
|
440
|
-
return { path, text: readFileSync(full, 'utf8') };
|
|
441
|
-
} catch {
|
|
442
|
-
continue;
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
const res = git.show(target, path, cwd);
|
|
446
|
-
if (res.code === 0) return { path, text: res.stdout };
|
|
447
|
-
}
|
|
448
|
-
return null;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function handleTree(url: URL) {
|
|
452
|
-
const target = url.searchParams.get('ref') || url.searchParams.get('target') || 'worktree';
|
|
453
|
-
const path = (url.searchParams.get('path') || '').replace(/^\/+|\/+$/g, '');
|
|
454
|
-
if (!safeRepoPath(path)) return text('invalid path', 400);
|
|
455
|
-
if ((target === 'worktree' || target === '') && isGitInternalPath(path)) return text('forbidden', 403);
|
|
456
|
-
if (target !== 'worktree' && !git.verifyTreeRef(target, cwd)) return text('invalid target', 400);
|
|
457
|
-
const recursive = url.searchParams.get('recursive') === '1';
|
|
458
|
-
if (invalidScopeOmitDirNamesQuery(url)) return text('invalid omit dirs', 400);
|
|
459
|
-
const entries = git.listTree(target, path, cwd, { recursive, omitDirNames: scopeOmitDirNamesFromQuery(url) }).entries;
|
|
460
|
-
return json({
|
|
461
|
-
ref: target,
|
|
462
|
-
path,
|
|
463
|
-
project: basename(cwd),
|
|
464
|
-
branch: git.currentBranch(cwd) || undefined,
|
|
465
|
-
entries,
|
|
466
|
-
readme: readReadme(target, path),
|
|
467
|
-
upload_enabled: allowUpload && (target === 'worktree' || target === ''),
|
|
468
|
-
} satisfies RepoTreeResponse);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
function handleSettings() {
|
|
472
|
-
return json({
|
|
473
|
-
project: basename(cwd),
|
|
474
|
-
scope: {
|
|
475
|
-
omit_dirs_effective: scopeOmitDirNames,
|
|
476
|
-
omit_dirs_built_in: git.DEFAULT_WORKTREE_OMIT_DIR_NAMES,
|
|
477
|
-
max_entries: git.WORKTREE_RECURSIVE_ENTRY_LIMIT,
|
|
478
|
-
},
|
|
479
|
-
} satisfies SettingsResponse);
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
function handleFiles(url: URL) {
|
|
483
|
-
const target = url.searchParams.get('ref') || url.searchParams.get('target') || 'worktree';
|
|
484
|
-
if (target !== 'worktree' && !git.verifyTreeRef(target, cwd)) return text('invalid target', 400);
|
|
485
|
-
if (invalidScopeOmitDirNamesQuery(url)) return text('invalid omit dirs', 400);
|
|
486
|
-
const omitDirNames = scopeOmitDirNamesFromQuery(url);
|
|
487
|
-
const key = `${target || 'worktree'}\0${omitDirNames.join('\0')}`;
|
|
488
|
-
const cached = fileListCache.get(key);
|
|
489
|
-
if (cached && cached.generation === generation) return json(cached.body);
|
|
490
|
-
const ref = target || 'worktree';
|
|
491
|
-
const entries = git.listTree(ref, '', cwd, { recursive: true, omitDirNames }).entries;
|
|
492
|
-
const body = buildFileSearchList(ref, generation, entries);
|
|
493
|
-
fileListCache.set(key, { generation, body });
|
|
494
|
-
return json(body);
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
function parseGrepPaths(url: URL, omitDirNames: string[]): string[] {
|
|
498
|
-
return url.searchParams.getAll('path').filter(path => safePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, omitDirNames));
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function rgAvailable(): boolean {
|
|
502
|
-
if (rgAvailableCache !== null) return rgAvailableCache;
|
|
503
|
-
const proc = Bun.spawnSync(['rg', '--version'], { cwd, stdout: 'pipe', stderr: 'pipe' });
|
|
504
|
-
rgAvailableCache = proc.exitCode === 0;
|
|
505
|
-
return rgAvailableCache;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
function grepWorktreeFallback(query: string, max: number, paths: string[], omitDirNames: string[]): GrepMatch[] {
|
|
509
|
-
const candidates = paths.length ? paths : git.worktreeFiles(cwd).map(entry => entry.path);
|
|
510
|
-
const matches: GrepMatch[] = [];
|
|
511
|
-
for (const path of candidates) {
|
|
512
|
-
if (matches.length >= max) break;
|
|
513
|
-
if (!safePath(path) || isGitInternalPath(path) || isSkippableSearchPath(path, omitDirNames)) continue;
|
|
514
|
-
const full = safeWorktreePath(path);
|
|
515
|
-
if (!full) continue;
|
|
516
|
-
let stat;
|
|
517
|
-
try {
|
|
518
|
-
stat = lstatSync(full);
|
|
519
|
-
} catch {
|
|
520
|
-
continue;
|
|
521
|
-
}
|
|
522
|
-
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > GREP_MAX_FILE_BYTES) continue;
|
|
523
|
-
let data: Buffer;
|
|
524
|
-
try {
|
|
525
|
-
data = readFileSync(full);
|
|
526
|
-
} catch {
|
|
527
|
-
continue;
|
|
528
|
-
}
|
|
529
|
-
if (data.subarray(0, 8192).includes(0)) continue;
|
|
530
|
-
matches.push(...fixedStringLineMatches(path, data.toString('utf8'), query, max - matches.length));
|
|
531
|
-
}
|
|
532
|
-
return matches;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function grepWorktree(query: string, max: number, paths: string[], regex: boolean, omitDirNames: string[]): GrepResponse {
|
|
536
|
-
if (rgAvailable()) {
|
|
537
|
-
const safePaths = paths.filter(path => safePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, omitDirNames) && safeWorktreePath(path));
|
|
538
|
-
const args = buildRgArgs(query, max, safePaths, regex, omitDirNames);
|
|
539
|
-
const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', timeout: 5000, killSignal: 'SIGKILL' });
|
|
540
|
-
const stdout = new TextDecoder().decode(proc.stdout);
|
|
541
|
-
const matches = parseRgOutput(stdout, max, omitDirNames)
|
|
542
|
-
.filter(match => safePath(match.path) && !isGitInternalPath(match.path) && !isSkippableSearchPath(match.path, omitDirNames) && !!safeWorktreePath(match.path));
|
|
543
|
-
return { ref: 'worktree', engine: 'rg', truncated: matches.length >= max, matches };
|
|
544
|
-
}
|
|
545
|
-
if (regex) return { ref: 'worktree', engine: 'fallback', truncated: false, matches: [] };
|
|
546
|
-
const matches = grepWorktreeFallback(query, max, paths, omitDirNames);
|
|
547
|
-
return { ref: 'worktree', engine: 'fallback', truncated: matches.length >= max, matches };
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
function grepTreeRef(ref: string, query: string, max: number, paths: string[], regex: boolean, omitDirNames: string[]): GrepResponse {
|
|
551
|
-
const safePaths = paths.filter(path => safePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, omitDirNames));
|
|
552
|
-
const args = [
|
|
553
|
-
'git', '-c', 'core.quotepath=false', 'grep',
|
|
554
|
-
'-n', '--column', '-i', regex ? '-E' : '-F', '--no-color',
|
|
555
|
-
'-e', query,
|
|
556
|
-
ref, '--',
|
|
557
|
-
...safePaths,
|
|
558
|
-
];
|
|
559
|
-
const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', timeout: 5000, killSignal: 'SIGKILL' });
|
|
560
|
-
const stdout = new TextDecoder().decode(proc.stdout);
|
|
561
|
-
const matches = parseGitGrepOutput(stdout, ref, max, omitDirNames).slice(0, max);
|
|
562
|
-
return { ref, engine: 'git', truncated: matches.length >= max, matches };
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
function handleGrep(url: URL) {
|
|
566
|
-
const query = url.searchParams.get('q') || '';
|
|
567
|
-
const ref = url.searchParams.get('ref') || 'worktree';
|
|
568
|
-
const max = normalizeGrepMax(url.searchParams.get('max'));
|
|
569
|
-
if (invalidScopeOmitDirNamesQuery(url)) return text('invalid omit dirs', 400);
|
|
570
|
-
const omitDirNames = scopeOmitDirNamesFromQuery(url);
|
|
571
|
-
const paths = parseGrepPaths(url, omitDirNames);
|
|
572
|
-
const regex = url.searchParams.get('regex') === '1';
|
|
573
|
-
if (!query.trim()) return json({ ref, engine: ref === 'worktree' ? 'fallback' : 'git', truncated: false, matches: [] } satisfies GrepResponse);
|
|
574
|
-
if (ref === 'worktree' || ref === '') return json(grepWorktree(query, max, paths, regex, omitDirNames));
|
|
575
|
-
if (!git.verifyTreeRef(ref, cwd)) return text('invalid target', 400);
|
|
576
|
-
return json(grepTreeRef(ref, query, max, paths, regex, omitDirNames));
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
function handleFileDiff(url: URL) {
|
|
580
|
-
const path = url.searchParams.get('path') || '';
|
|
581
|
-
if (!safePath(path)) return text('invalid path', 400);
|
|
582
|
-
const extras = [];
|
|
583
|
-
if (url.searchParams.get('ignore_ws') === '1') extras.push('-w');
|
|
584
|
-
if (url.searchParams.get('ignore_blank') === '1') extras.push('--ignore-blank-lines');
|
|
585
|
-
const isUntracked = url.searchParams.get('untracked') === '1';
|
|
586
|
-
const range = { from: url.searchParams.get('from') || '', to: url.searchParams.get('to') || '' };
|
|
587
|
-
if (isSameWorktreeRange(range)) {
|
|
588
|
-
return json({
|
|
589
|
-
path,
|
|
590
|
-
old_path: url.searchParams.get('old_path') || '',
|
|
591
|
-
status: url.searchParams.get('status') || '',
|
|
592
|
-
mode: url.searchParams.get('mode') || 'full',
|
|
593
|
-
diff: '',
|
|
594
|
-
hunk_count: 0,
|
|
595
|
-
rendered_hunk_count: 0,
|
|
596
|
-
line_count: 0,
|
|
597
|
-
truncated: false,
|
|
598
|
-
binary: false,
|
|
599
|
-
generation,
|
|
600
|
-
});
|
|
601
|
-
}
|
|
602
|
-
const { args } = buildRangeArgs(range);
|
|
603
|
-
const oldPath = url.searchParams.get('old_path');
|
|
604
|
-
let cacheKey: string;
|
|
605
|
-
try {
|
|
606
|
-
cacheKey = fileDiffCacheKey({ path, oldPath, isUntracked, range, extras, args, cwd });
|
|
607
|
-
} catch {
|
|
608
|
-
return text('invalid diff range', 400);
|
|
609
|
-
}
|
|
610
|
-
const cached = fileCache.get(cacheKey);
|
|
611
|
-
let diffText: string;
|
|
612
|
-
let errText = '';
|
|
613
|
-
if (cacheFresh(cached)) {
|
|
614
|
-
diffText = cached.diffText;
|
|
615
|
-
} else {
|
|
616
|
-
if (isUntracked) {
|
|
617
|
-
diffText = git.untrackedFileDiff(extras, path, cwd).stdout || '';
|
|
618
|
-
} else {
|
|
619
|
-
const res = git.fileDiffText([...extras, ...args], oldPath ? [oldPath, path] : path, cwd);
|
|
620
|
-
diffText = res.stdout || '';
|
|
621
|
-
if (res.code !== 0) errText = res.stderr;
|
|
622
|
-
}
|
|
623
|
-
setTimedCacheEntry(fileCache, cacheKey, { diffText });
|
|
624
|
-
}
|
|
625
|
-
const mode = url.searchParams.get('mode') || 'full';
|
|
626
|
-
const truncated = mode === 'preview'
|
|
627
|
-
? git.truncateToNHunks(
|
|
628
|
-
diffText,
|
|
629
|
-
Number(url.searchParams.get('max_hunks')) || PREVIEW_HUNKS_DEFAULT,
|
|
630
|
-
Number(url.searchParams.get('max_lines')) || PREVIEW_LINES_DEFAULT,
|
|
631
|
-
)
|
|
632
|
-
: git.truncateToNHunks(diffText, 1e9);
|
|
633
|
-
const body: FileDiffResponse & { line_count?: number; error?: string } = {
|
|
634
|
-
path,
|
|
635
|
-
old_path: url.searchParams.get('old_path') || '',
|
|
636
|
-
status: url.searchParams.get('status') || '',
|
|
637
|
-
mode,
|
|
638
|
-
diff: truncated.text,
|
|
639
|
-
hunk_count: truncated.totalHunks,
|
|
640
|
-
rendered_hunk_count: truncated.renderedHunks,
|
|
641
|
-
line_count: truncated.lineCount,
|
|
642
|
-
truncated: mode === 'preview' && (truncated.totalHunks > truncated.renderedHunks || truncated.lineTruncated),
|
|
643
|
-
binary: diffText.includes('Binary files'),
|
|
644
|
-
error: errText,
|
|
645
|
-
generation,
|
|
646
|
-
};
|
|
647
|
-
return json(body);
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
function handleFileRange(url: URL) {
|
|
651
|
-
const path = url.searchParams.get('path') || '';
|
|
652
|
-
if (!safePath(path)) return text('invalid path', 400);
|
|
653
|
-
let start = Number(url.searchParams.get('start') || '1') || 1;
|
|
654
|
-
let end = Number(url.searchParams.get('end') || url.searchParams.get('endline') || '0') || 0;
|
|
655
|
-
if (start < 1) start = 1;
|
|
656
|
-
if (end < start) end = start;
|
|
657
|
-
const ref = url.searchParams.get('ref') || 'worktree';
|
|
658
|
-
let content = '';
|
|
659
|
-
if (ref === 'worktree' || ref === '') {
|
|
660
|
-
const full = safeWorktreePath(path);
|
|
661
|
-
if (!full) return text('no file', 404);
|
|
662
|
-
content = readFileSync(full, 'utf8');
|
|
663
|
-
} else {
|
|
664
|
-
if (!git.verifyTreeRef(ref, cwd)) return text('invalid ref', 400);
|
|
665
|
-
const res = git.show(ref, path, cwd);
|
|
666
|
-
if (res.code !== 0) return text('not in ref', 404);
|
|
667
|
-
content = res.stdout;
|
|
668
|
-
}
|
|
669
|
-
const lines: string[] = [];
|
|
670
|
-
const all = `${content}\n`.split('\n');
|
|
671
|
-
for (let i = start; i <= end && i <= all.length; i++) lines.push(all[i - 1]);
|
|
672
|
-
const body: FileRangeResponse = { path, ref, start, end, lines, total: Math.min(all.length, end + 1), generation };
|
|
673
|
-
return json(body);
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
function handleRawFile(req: Request, url: URL) {
|
|
677
|
-
const path = url.searchParams.get('path') || '';
|
|
678
|
-
if (!safePath(path)) return text('forbidden', 403);
|
|
679
|
-
const ref = url.searchParams.get('ref') || 'worktree';
|
|
680
|
-
let body: BodyInit;
|
|
681
|
-
if (ref !== 'worktree' && ref !== '') {
|
|
682
|
-
if (!git.verifyTreeRef(ref, cwd)) return text('invalid ref', 400);
|
|
683
|
-
const size = rawFileSize(path, ref);
|
|
684
|
-
if (size == null) return text('not in ref', 404);
|
|
685
|
-
if (req.method === 'HEAD') return new Response(null, { headers: rawFileHeaders(path, size) });
|
|
686
|
-
const res = git.showBytes(ref, path, cwd);
|
|
687
|
-
if (res.code !== 0) return text('not in ref', 404);
|
|
688
|
-
body = res.stdout.buffer.slice(res.stdout.byteOffset, res.stdout.byteOffset + res.stdout.byteLength) as ArrayBuffer;
|
|
689
|
-
return new Response(body, { headers: rawFileHeaders(path, size) });
|
|
690
|
-
} else {
|
|
691
|
-
const full = safeWorktreePath(path);
|
|
692
|
-
if (!full) return text('not found', 404);
|
|
693
|
-
const size = rawFileSize(path, ref);
|
|
694
|
-
if (size == null) return text('not found', 404);
|
|
695
|
-
if (req.method === 'HEAD') return new Response(null, { headers: rawFileHeaders(path, size) });
|
|
696
|
-
const bytes = new Uint8Array(readFileSync(full));
|
|
697
|
-
body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
698
|
-
return new Response(body, { headers: rawFileHeaders(path, size) });
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
function rawFileSize(path: string, ref: string): number | null {
|
|
703
|
-
if (ref !== 'worktree' && ref !== '') {
|
|
704
|
-
if (!git.verifyTreeRef(ref, cwd)) return null;
|
|
705
|
-
const res = git.objectSize(ref, path, cwd);
|
|
706
|
-
return res.code === 0 ? res.size : null;
|
|
707
|
-
}
|
|
708
|
-
const full = safeWorktreePath(path);
|
|
709
|
-
if (!full) return null;
|
|
710
|
-
try {
|
|
711
|
-
return (statSync(full) as unknown as { size: number }).size;
|
|
712
|
-
} catch {
|
|
713
|
-
return null;
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
function rawFileHeaders(path: string, size: number | null = null): HeadersInit {
|
|
718
|
-
const mime: Record<string, string> = {
|
|
719
|
-
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
|
720
|
-
'.webp': 'image/webp', '.svg': 'image/svg+xml', '.mp4': 'video/mp4', '.webm': 'video/webm',
|
|
721
|
-
'.mov': 'video/quicktime', '.pdf': 'application/pdf',
|
|
722
|
-
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.flac': 'audio/flac',
|
|
723
|
-
'.m4a': 'audio/mp4', '.aac': 'audio/aac', '.opus': 'audio/ogg',
|
|
724
|
-
};
|
|
725
|
-
const headers: Record<string, string> = {
|
|
726
|
-
'Content-Type': mime[extname(path).toLowerCase()] || 'application/octet-stream',
|
|
727
|
-
'Cache-Control': 'no-store',
|
|
728
|
-
'X-Content-Type-Options': 'nosniff',
|
|
729
|
-
'Content-Security-Policy': 'sandbox',
|
|
730
|
-
};
|
|
731
|
-
if (size != null) headers['Content-Length'] = String(size);
|
|
732
|
-
return headers;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
function isForbiddenUploadName(name: string): boolean {
|
|
736
|
-
const lower = name.toLowerCase();
|
|
737
|
-
return lower.startsWith('.') ||
|
|
738
|
-
lower === 'package.json' ||
|
|
739
|
-
lower === 'package-lock.json' ||
|
|
740
|
-
lower === 'bun.lock' ||
|
|
741
|
-
lower === 'bun.lockb' ||
|
|
742
|
-
lower === 'yarn.lock' ||
|
|
743
|
-
lower === 'pnpm-lock.yaml' ||
|
|
744
|
-
lower === 'makefile' ||
|
|
745
|
-
lower === 'dockerfile' ||
|
|
746
|
-
lower.endsWith('.dockerfile') ||
|
|
747
|
-
/^(tsconfig|jsconfig|bunfig|vercel|netlify|wrangler|next|vite|webpack|rollup|esbuild|astro|svelte|tailwind|postcss|babel|prettier|eslint)\./.test(lower) ||
|
|
748
|
-
lower.endsWith('.config.js') ||
|
|
749
|
-
lower.endsWith('.config.jsx') ||
|
|
750
|
-
lower.endsWith('.config.ts') ||
|
|
751
|
-
lower.endsWith('.config.tsx') ||
|
|
752
|
-
lower.endsWith('.config.mjs') ||
|
|
753
|
-
lower.endsWith('.config.cjs') ||
|
|
754
|
-
lower.includes('credential') ||
|
|
755
|
-
lower.includes('secret') ||
|
|
756
|
-
lower.endsWith('.exe') ||
|
|
757
|
-
lower.endsWith('.dll') ||
|
|
758
|
-
lower.endsWith('.dylib') ||
|
|
759
|
-
lower.endsWith('.so') ||
|
|
760
|
-
lower.endsWith('.sh') ||
|
|
761
|
-
lower.endsWith('.bash') ||
|
|
762
|
-
lower.endsWith('.zsh') ||
|
|
763
|
-
lower.endsWith('.fish') ||
|
|
764
|
-
lower.endsWith('.ps1') ||
|
|
765
|
-
lower.endsWith('.bat') ||
|
|
766
|
-
lower.endsWith('.cmd');
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
function safeUploadFileName(name: string): string | null {
|
|
770
|
-
if (!name || name.includes('\0') || name.includes('/') || name.includes('\\')) return null;
|
|
771
|
-
if (name === '.' || name === '..') return null;
|
|
772
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._ -]{0,180}$/.test(name)) return null;
|
|
773
|
-
if (isGitInternalPath(name) || isForbiddenUploadName(name)) return null;
|
|
774
|
-
if (!SAFE_UPLOAD_EXTENSIONS.has(extname(name).toLowerCase())) return null;
|
|
775
|
-
return name;
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
function uploadOpenFlags() {
|
|
779
|
-
return constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0);
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
async function handleUploadFiles(req: Request) {
|
|
783
|
-
if (!allowUpload) return text('upload disabled', 403);
|
|
784
|
-
if (req.method !== 'POST') return text('method not allowed', 405);
|
|
785
|
-
if (!sideEffectRequestAllowed(req)) return text('forbidden', 403);
|
|
786
|
-
if (req.headers.get('content-encoding')) return text('unsupported media type', 415);
|
|
787
|
-
const lengthHeader = req.headers.get('content-length');
|
|
788
|
-
if (!lengthHeader) return text('content length required', 411);
|
|
789
|
-
const length = Number(lengthHeader);
|
|
790
|
-
if (!Number.isSafeInteger(length) || length < 0) return text('invalid content length', 400);
|
|
791
|
-
if (length > MAX_UPLOAD_BODY_BYTES) return text('upload too large', 413);
|
|
792
|
-
const contentType = req.headers.get('content-type') || '';
|
|
793
|
-
if (!/^multipart\/form-data;\s*boundary=/i.test(contentType)) return text('unsupported media type', 415);
|
|
794
|
-
|
|
795
|
-
let form: FormData;
|
|
796
|
-
try {
|
|
797
|
-
form = await req.formData();
|
|
798
|
-
} catch {
|
|
799
|
-
return text('invalid form data', 400);
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
const dir = String(form.get('dir') || '').replace(/^\/+|\/+$/g, '');
|
|
803
|
-
if (!safeRepoPath(dir)) return text('invalid dir', 400);
|
|
804
|
-
if (dir && isGitInternalPath(dir)) return text('forbidden', 403);
|
|
805
|
-
const realDir = safeOpenWorktreePath(dir);
|
|
806
|
-
if (!realDir) return text('not found', 404);
|
|
807
|
-
const stats = statSync(realDir) as unknown as { isDirectory(): boolean };
|
|
808
|
-
if (!stats.isDirectory()) return text('not a directory', 400);
|
|
809
|
-
|
|
810
|
-
const files = form.getAll('files').filter((item): item is File => item instanceof File);
|
|
811
|
-
if (!files.length) return text('no files', 400);
|
|
812
|
-
if (files.length > MAX_UPLOAD_FILES) return text('too many files', 413);
|
|
813
|
-
|
|
814
|
-
let total = 0;
|
|
815
|
-
const names = new Set<string>();
|
|
816
|
-
const uploads: Array<{ file: File; name: string; target: string }> = [];
|
|
817
|
-
for (const file of files) {
|
|
818
|
-
const safeName = safeUploadFileName(file.name);
|
|
819
|
-
if (!safeName) return text('invalid filename', 400);
|
|
820
|
-
const lowerName = safeName.toLowerCase();
|
|
821
|
-
if (names.has(lowerName)) return text('duplicate filename', 409);
|
|
822
|
-
names.add(lowerName);
|
|
823
|
-
if (file.size > MAX_UPLOAD_FILE_BYTES) return text('file too large', 413);
|
|
824
|
-
total += file.size;
|
|
825
|
-
if (total > MAX_UPLOAD_TOTAL_BYTES) return text('upload too large', 413);
|
|
826
|
-
const target = join(realDir, safeName);
|
|
827
|
-
if (relative(realDir, dirname(target)) !== '') return text('invalid filename', 400);
|
|
828
|
-
if (existsSync(target)) return text('file exists', 409);
|
|
829
|
-
uploads.push({ file, name: safeName, target });
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
const written: string[] = [];
|
|
833
|
-
try {
|
|
834
|
-
for (const upload of uploads) {
|
|
835
|
-
const fd = openSync(upload.target, uploadOpenFlags(), 0o644);
|
|
836
|
-
try {
|
|
837
|
-
writeFileSync(fd, new Uint8Array(await upload.file.arrayBuffer()));
|
|
838
|
-
} finally {
|
|
839
|
-
closeSync(fd);
|
|
840
|
-
}
|
|
841
|
-
written.push(upload.target);
|
|
842
|
-
}
|
|
843
|
-
} catch (error) {
|
|
844
|
-
for (const path of written) {
|
|
845
|
-
try { unlinkSync(path); } catch { /* best-effort cleanup */ }
|
|
846
|
-
}
|
|
847
|
-
if ((error as { code?: string }).code === 'EEXIST') return text('file exists', 409);
|
|
848
|
-
return text('upload failed', 500);
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
generation++;
|
|
852
|
-
fileCache.clear();
|
|
853
|
-
metaCache.clear();
|
|
854
|
-
sendSse('update');
|
|
855
|
-
return json({ ok: true, files: uploads.map(upload => upload.name), generation });
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
function openOsPath(path: string) {
|
|
859
|
-
const cmd = process.platform === 'darwin' ? ['open', '--', path]
|
|
860
|
-
: process.platform === 'win32' ? ['explorer.exe', path]
|
|
861
|
-
: ['xdg-open', path];
|
|
862
|
-
Bun.spawn(cmd, { stdout: 'ignore', stderr: 'ignore' });
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
async function handleOpenPath(req: Request) {
|
|
866
|
-
if (req.method !== 'POST') return text('method not allowed', 405);
|
|
867
|
-
if (!sideEffectRequestAllowed(req)) return text('forbidden', 403);
|
|
868
|
-
const contentType = req.headers.get('content-type') || '';
|
|
869
|
-
if (!/^application\/json(?:;|$)/i.test(contentType)) return text('unsupported media type', 415);
|
|
870
|
-
const length = Number(req.headers.get('content-length') || '0');
|
|
871
|
-
if (length > 1024) return text('payload too large', 413);
|
|
872
|
-
|
|
873
|
-
let body: { path?: unknown; kind?: unknown } = {};
|
|
874
|
-
try {
|
|
875
|
-
const raw = await req.text();
|
|
876
|
-
if (raw.length > 1024) return text('payload too large', 413);
|
|
877
|
-
body = JSON.parse(raw);
|
|
878
|
-
} catch {
|
|
879
|
-
return text('invalid json', 400);
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
const path = typeof body.path === 'string' ? body.path.replace(/^\/+|\/+$/g, '') : '';
|
|
883
|
-
const kind = body.kind;
|
|
884
|
-
if (kind !== 'directory' && kind !== 'file-parent') return text('invalid kind', 400);
|
|
885
|
-
if (kind === 'file-parent' && !path) return text('invalid path', 400);
|
|
886
|
-
if (!safeRepoPath(path)) return text('invalid path', 400);
|
|
887
|
-
if (path && isGitInternalPath(path)) return text('forbidden', 403);
|
|
888
|
-
|
|
889
|
-
const targetPath = kind === 'file-parent' ? parentRepoPath(path) : path;
|
|
890
|
-
const target = safeOpenWorktreePath(targetPath);
|
|
891
|
-
if (!target) return text('not found', 404);
|
|
892
|
-
|
|
893
|
-
const stats = statSync(target) as unknown as { isDirectory(): boolean };
|
|
894
|
-
if (!stats.isDirectory()) return text('not a directory', 400);
|
|
895
|
-
openOsPath(target);
|
|
896
|
-
return json({ ok: true });
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
function sendSse(event: string, data = 'tick') {
|
|
900
|
-
const payload = enc.encode(`event: ${event}\ndata: ${data}\n\n`);
|
|
901
|
-
for (const client of [...sseClients]) {
|
|
902
|
-
try { client.enqueue(payload); } catch { sseClients.delete(client); }
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
function openBrowser(url: string) {
|
|
907
|
-
const cmd = process.platform === 'darwin' ? ['open', url]
|
|
908
|
-
: process.platform === 'win32' ? ['cmd.exe', '/c', 'start', '', url]
|
|
909
|
-
: ['xdg-open', url];
|
|
910
|
-
Bun.spawn(cmd, { stdout: 'ignore', stderr: 'ignore' });
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
parseCli();
|
|
914
|
-
|
|
915
|
-
const server = Bun.serve({
|
|
916
|
-
hostname: '127.0.0.1',
|
|
917
|
-
port: listenPort,
|
|
918
|
-
async fetch(req) {
|
|
919
|
-
if (!requestAllowed(req)) return text('forbidden', 403);
|
|
920
|
-
const url = new URL(req.url);
|
|
921
|
-
const staticResponse = staticFile(url.pathname);
|
|
922
|
-
if (staticResponse) return staticResponse;
|
|
923
|
-
if (url.pathname === '/diff.json') return handleDiffJson(url);
|
|
924
|
-
if (url.pathname === '/_settings') return handleSettings();
|
|
925
|
-
if (url.pathname === '/_tree') return handleTree(url);
|
|
926
|
-
if (url.pathname === '/_files') return handleFiles(url);
|
|
927
|
-
if (url.pathname === '/_grep') return handleGrep(url);
|
|
928
|
-
if (url.pathname === '/file_diff') return handleFileDiff(url);
|
|
929
|
-
if (url.pathname === '/file_range') return handleFileRange(url);
|
|
930
|
-
if (url.pathname === '/_file') return handleRawFile(req, url);
|
|
931
|
-
if (url.pathname === '/_open_path') return handleOpenPath(req);
|
|
932
|
-
if (url.pathname === '/_upload_files') return handleUploadFiles(req);
|
|
933
|
-
if (url.pathname === '/_refs') return json(git.refs(cwd));
|
|
934
|
-
if (url.pathname === '/refresh' && req.method === 'POST') {
|
|
935
|
-
if (!sideEffectRequestAllowed(req)) return text('forbidden', 403);
|
|
936
|
-
generation++;
|
|
937
|
-
fileCache.clear();
|
|
938
|
-
metaCache.clear();
|
|
939
|
-
fileListCache.clear();
|
|
940
|
-
sendSse('update');
|
|
941
|
-
return json({ ok: true, generation });
|
|
942
|
-
}
|
|
943
|
-
if (url.pathname === '/events') {
|
|
944
|
-
let ctrl: ReadableStreamDefaultController<Uint8Array>;
|
|
945
|
-
let keepalive: ReturnType<typeof setInterval>;
|
|
946
|
-
return new Response(new ReadableStream<Uint8Array>({
|
|
947
|
-
start(controller) {
|
|
948
|
-
ctrl = controller;
|
|
949
|
-
sseClients.add(controller);
|
|
950
|
-
controller.enqueue(enc.encode('event: open\ndata: ok\n\n'));
|
|
951
|
-
keepalive = setInterval(() => {
|
|
952
|
-
try {
|
|
953
|
-
controller.enqueue(enc.encode(': ping\n\n'));
|
|
954
|
-
} catch {
|
|
955
|
-
sseClients.delete(controller);
|
|
956
|
-
clearInterval(keepalive);
|
|
957
|
-
}
|
|
958
|
-
}, 15000);
|
|
959
|
-
},
|
|
960
|
-
cancel() {
|
|
961
|
-
if (ctrl) sseClients.delete(ctrl);
|
|
962
|
-
if (keepalive) clearInterval(keepalive);
|
|
963
|
-
},
|
|
964
|
-
}), {
|
|
965
|
-
headers: {
|
|
966
|
-
'Content-Type': 'text/event-stream',
|
|
967
|
-
'Cache-Control': 'no-cache',
|
|
968
|
-
Connection: 'keep-alive',
|
|
969
|
-
},
|
|
970
|
-
});
|
|
971
|
-
}
|
|
972
|
-
return text('not found', 404);
|
|
973
|
-
},
|
|
974
|
-
});
|
|
975
|
-
|
|
976
|
-
startDevAssetReload({
|
|
977
|
-
enabled: process.env.CODE_VIEWER_DEV === '1',
|
|
978
|
-
webRoot: WEB_ROOT,
|
|
979
|
-
watchedFiles: WATCHED_ASSET_FILES,
|
|
980
|
-
watch,
|
|
981
|
-
sendReload: () => sendSse('reload'),
|
|
982
|
-
});
|
|
983
|
-
|
|
984
|
-
console.log(`GDP_LISTEN_URL=http://127.0.0.1:${server.port}/`);
|
|
985
|
-
console.log(`git-diff-preview serving ${cwd}`);
|