@youtyan/code-viewer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
2
+ Theme: GitHub Dark
3
+ Description: Dark theme as seen on github.com
4
+ Author: github.com
5
+ Maintainer: @Hirse
6
+ Updated: 2021-05-15
7
+
8
+ Outdated base version: https://github.com/primer/github-syntax-dark
9
+ Current colors taken from GitHub's CSS
10
+ */.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}
@@ -0,0 +1,10 @@
1
+ pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
2
+ Theme: GitHub
3
+ Description: Light theme as seen on github.com
4
+ Author: github.com
5
+ Maintainer: @Hirse
6
+ Updated: 2021-05-15
7
+
8
+ Outdated base version: https://github.com/primer/github-syntax-light
9
+ Current colors taken from GitHub's CSS
10
+ */.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}
@@ -0,0 +1,197 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export type GitFileMeta = {
5
+ order?: number;
6
+ path: string;
7
+ old_path?: string;
8
+ status?: string;
9
+ similarity?: number;
10
+ additions?: number;
11
+ deletions?: number;
12
+ binary?: boolean;
13
+ untracked?: boolean;
14
+ };
15
+
16
+ function run(args: string[], cwd: string): { code: number; stdout: string; stderr: string } {
17
+ const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe' });
18
+ return {
19
+ code: proc.exitCode,
20
+ stdout: new TextDecoder().decode(proc.stdout),
21
+ stderr: new TextDecoder().decode(proc.stderr),
22
+ };
23
+ }
24
+
25
+ export function repoRoot(cwd: string): string | null {
26
+ const res = run(['git', 'rev-parse', '--show-toplevel'], cwd);
27
+ return res.code === 0 ? res.stdout.trimEnd() : null;
28
+ }
29
+
30
+ export function currentBranch(cwd: string): string | null {
31
+ const res = run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd);
32
+ return res.code === 0 ? res.stdout.trimEnd() : null;
33
+ }
34
+
35
+ export function show(ref: string, path: string, cwd: string): { code: number; stdout: string; stderr: string } {
36
+ return run(['git', 'show', `${ref}:${path}`], cwd);
37
+ }
38
+
39
+ export function refs(cwd: string): { branches: string[]; tags: string[]; commits: string[]; current: string } {
40
+ const out = { branches: [] as string[], tags: [] as string[], commits: [] as string[], current: '' };
41
+ const branches = run([
42
+ 'git', 'for-each-ref', '--sort=-committerdate', '--format=%(refname:short)', 'refs/heads', 'refs/remotes',
43
+ ], cwd);
44
+ if (branches.code === 0) {
45
+ out.branches = branches.stdout.split('\n').filter((line) => line && line !== 'origin/HEAD');
46
+ }
47
+ const tags = run(['git', 'for-each-ref', '--sort=-creatordate', '--format=%(refname:short)', 'refs/tags'], cwd);
48
+ if (tags.code === 0) out.tags = tags.stdout.split('\n').filter(Boolean);
49
+ const commits = run(['git', 'log', '-50', '--format=%h\t%s\t%an\t%ar'], cwd);
50
+ if (commits.code === 0) out.commits = commits.stdout.split('\n').filter(Boolean);
51
+ out.current = currentBranch(cwd) || '';
52
+ return out;
53
+ }
54
+
55
+ export function nameStatus(args: string[], cwd: string): GitFileMeta[] {
56
+ const res = run([
57
+ 'git', '-c', 'core.quotepath=false', 'diff',
58
+ '--no-color', '--no-ext-diff', '--find-renames', '--name-status', '-z',
59
+ ...args,
60
+ ], cwd);
61
+ if (res.code !== 0) return [];
62
+ const parts = res.stdout.split('\0');
63
+ const files: GitFileMeta[] = [];
64
+ for (let i = 0; i < parts.length;) {
65
+ const status = parts[i++];
66
+ if (!status) break;
67
+ const kind = status[0];
68
+ if (kind === 'R' || kind === 'C') {
69
+ const oldPath = parts[i++] || '';
70
+ const path = parts[i++] || '';
71
+ if (path) files.push({ status: kind, old_path: oldPath, path, similarity: Number(status.slice(1)) || undefined });
72
+ } else {
73
+ const path = parts[i++] || '';
74
+ if (path) files.push({ status: kind, path });
75
+ }
76
+ }
77
+ return files;
78
+ }
79
+
80
+ export function numstatZ(args: string[], cwd: string): GitFileMeta[] {
81
+ const res = run([
82
+ 'git', '-c', 'core.quotepath=false', 'diff',
83
+ '--no-color', '--no-ext-diff', '--find-renames', '--numstat', '-z',
84
+ ...args,
85
+ ], cwd);
86
+ if (res.code !== 0) return [];
87
+ const parts = res.stdout.split('\0');
88
+ const files: GitFileMeta[] = [];
89
+ for (let i = 0; i < parts.length;) {
90
+ const rec = parts[i++];
91
+ if (!rec) break;
92
+ const match = rec.match(/^(\S+)\t(\S+)\t(.*)$/);
93
+ if (!match) break;
94
+ const [, add, del, rest] = match;
95
+ const binary = add === '-' && del === '-';
96
+ const additions = binary ? 0 : Number(add) || 0;
97
+ const deletions = binary ? 0 : Number(del) || 0;
98
+ if (rest === '') {
99
+ const oldPath = parts[i++] || '';
100
+ const path = parts[i++] || '';
101
+ if (path) files.push({ old_path: oldPath, path, additions, deletions, binary });
102
+ } else {
103
+ files.push({ path: rest, additions, deletions, binary });
104
+ }
105
+ }
106
+ return files;
107
+ }
108
+
109
+ export function untracked(cwd: string): string[] {
110
+ const res = run(['git', 'ls-files', '--others', '--exclude-standard'], cwd);
111
+ return res.code === 0 ? res.stdout.split('\n').filter(Boolean) : [];
112
+ }
113
+
114
+ export function untrackedMeta(cwd: string): GitFileMeta[] {
115
+ return untracked(cwd).map((path) => {
116
+ const full = join(cwd, path);
117
+ let binary = false;
118
+ let lines = 0;
119
+ if (existsSync(full)) {
120
+ const data = readFileSync(full);
121
+ const probe = data.subarray(0, 8192);
122
+ binary = probe.includes(0);
123
+ if (!binary) lines = data.toString('utf8').split('\n').length - 1;
124
+ }
125
+ return { path, status: 'A', additions: binary ? 0 : lines, deletions: 0, binary, untracked: true };
126
+ });
127
+ }
128
+
129
+ export function fileMeta(args: string[], cwd: string, includeUntracked = false): GitFileMeta[] {
130
+ const ns = nameStatus(args, cwd);
131
+ const nm = numstatZ(args, cwd);
132
+ const byPath = new Map(nm.map((file) => [file.path, file]));
133
+ const files: GitFileMeta[] = ns.map((file) => {
134
+ const stats = byPath.get(file.path);
135
+ return {
136
+ ...file,
137
+ additions: stats?.additions || 0,
138
+ deletions: stats?.deletions || 0,
139
+ binary: stats?.binary || false,
140
+ };
141
+ });
142
+ return includeUntracked ? files.concat(untrackedMeta(cwd)) : files;
143
+ }
144
+
145
+ export function fileDiffText(args: string[], path: string | string[], cwd: string): { code: number; stdout: string; stderr: string } {
146
+ const paths = Array.isArray(path) ? path : [path];
147
+ return run([
148
+ 'git', '-c', 'core.quotepath=false', 'diff',
149
+ '--no-color', '--no-ext-diff', '--find-renames',
150
+ ...args, '--', ...paths,
151
+ ], cwd);
152
+ }
153
+
154
+ export function untrackedFileDiff(extras: string[], path: string, cwd: string): { code: number; stdout: string; stderr: string } {
155
+ return run([
156
+ 'git', '-c', 'core.quotepath=false', 'diff',
157
+ '--no-color', '--no-ext-diff', '--no-index',
158
+ ...extras, '/dev/null', path,
159
+ ], cwd);
160
+ }
161
+
162
+ export function splitHunks(diffText: string): { header: string; hunks: string[] } {
163
+ if (!diffText) return { header: '', hunks: [] };
164
+ const first = diffText.startsWith('@@') ? 0 : diffText.indexOf('\n@@') + 1;
165
+ if (first <= 0) return { header: diffText, hunks: [] };
166
+ const header = diffText.slice(0, first);
167
+ const hunks: string[] = [];
168
+ let cur = first;
169
+ while (cur < diffText.length) {
170
+ const next = diffText.indexOf('\n@@', cur + 1);
171
+ const end = next >= 0 ? next : diffText.length;
172
+ hunks.push(diffText.slice(cur, end));
173
+ if (next < 0) break;
174
+ cur = next + 1;
175
+ }
176
+ return { header, hunks };
177
+ }
178
+
179
+ export function truncateToNHunks(diffText: string, n: number): {
180
+ text: string;
181
+ totalHunks: number;
182
+ renderedHunks: number;
183
+ lineCount: number;
184
+ } {
185
+ const { header, hunks } = splitHunks(diffText);
186
+ if (hunks.length === 0) {
187
+ return { text: diffText, totalHunks: 0, renderedHunks: 0, lineCount: (diffText.match(/\n/g) || []).length };
188
+ }
189
+ const renderedHunks = Math.min(n, hunks.length);
190
+ const text = header + hunks.slice(0, renderedHunks).join('');
191
+ return {
192
+ text,
193
+ totalHunks: hunks.length,
194
+ renderedHunks,
195
+ lineCount: (text.match(/\n/g) || []).length,
196
+ };
197
+ }
@@ -0,0 +1,414 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
4
+ import { basename, extname, join, normalize, relative } from 'node:path';
5
+ import type { DiffMeta, FileDiffResponse, FileMeta, FileRangeResponse } from '../types';
6
+ import * as git from './git';
7
+
8
+ const ROOT = normalize(join(import.meta.dir, '..', '..'));
9
+ const WEB_ROOT = join(ROOT, 'web');
10
+ const VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version as string;
11
+ const DEFAULT_ARGS = ['HEAD'];
12
+ const PREVIEW_HUNKS_DEFAULT = 3;
13
+ const WATCHED_ASSET_FILES = ['index.html', 'style.css', 'app.js'];
14
+ const SIZE_SMALL = 2000;
15
+ const SIZE_MEDIUM = 8000;
16
+ const SIZE_LARGE = 20000;
17
+
18
+ let generation = 1;
19
+ let cwd = git.repoRoot(process.cwd()) || process.cwd();
20
+ let cliArgs = DEFAULT_ARGS;
21
+
22
+ const enc = new TextEncoder();
23
+ const sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
24
+ const fileCache = new Map<string, string>();
25
+ const metaCache = new Map<string, { body: string; sig: string }>();
26
+
27
+ function parseCli() {
28
+ const rest: string[] = [];
29
+ for (let i = 2; i < process.argv.length; i++) {
30
+ const arg = process.argv[i];
31
+ if (arg === '--help' || arg === '-h') {
32
+ console.log(`code-viewer ${VERSION}
33
+
34
+ Usage:
35
+ code-viewer [--cwd <repo>] [--open] [git-diff-args...]
36
+
37
+ Examples:
38
+ code-viewer --open
39
+ code-viewer --cwd /path/to/repo --open
40
+ code-viewer HEAD~1 HEAD
41
+ code-viewer --staged
42
+ `);
43
+ process.exit(0);
44
+ } else if (arg === '--version' || arg === '-v') {
45
+ console.log(VERSION);
46
+ process.exit(0);
47
+ } else if (arg === '--cwd') {
48
+ const next = process.argv[++i];
49
+ if (!next) {
50
+ console.error('--cwd requires a value');
51
+ process.exit(1);
52
+ }
53
+ cwd = git.repoRoot(next) || cwd;
54
+ } else if (arg === '--open') {
55
+ setTimeout(() => openBrowser(`http://127.0.0.1:${server.port}/`), 0);
56
+ } else {
57
+ rest.push(arg);
58
+ }
59
+ }
60
+ if (rest.length) cliArgs = rest;
61
+ }
62
+
63
+ function json(data: unknown, init: ResponseInit = {}) {
64
+ return new Response(JSON.stringify(data), {
65
+ ...init,
66
+ headers: {
67
+ 'Content-Type': 'application/json; charset=utf-8',
68
+ 'Cache-Control': 'no-store',
69
+ ...(init.headers || {}),
70
+ },
71
+ });
72
+ }
73
+
74
+ function text(body: string, status = 200) {
75
+ return new Response(body, {
76
+ status,
77
+ headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' },
78
+ });
79
+ }
80
+
81
+ function requestAllowed(req: Request) {
82
+ const host = req.headers.get('host') || '';
83
+ const origin = req.headers.get('origin');
84
+ const okHost = /^(127\.0\.0\.1|localhost|\[::1\]):\d+$/i.test(host);
85
+ const okOrigin = !origin || origin === 'null' || /^http:\/\/(127\.0\.0\.1|localhost|\[::1\]):\d+$/i.test(origin);
86
+ return okHost && okOrigin;
87
+ }
88
+
89
+ function staticFile(pathname: string): Response | null {
90
+ const map: Record<string, [string, string]> = {
91
+ '/': ['index.html', 'text/html; charset=utf-8'],
92
+ '/index.html': ['index.html', 'text/html; charset=utf-8'],
93
+ '/style.css': ['style.css', 'text/css; charset=utf-8'],
94
+ '/app.js': ['app.js', 'application/javascript; charset=utf-8'],
95
+ '/vendor/diff2html/diff2html.min.css': ['vendor/diff2html/diff2html.min.css', 'text/css; charset=utf-8'],
96
+ '/vendor/diff2html/diff2html-ui.min.js': ['vendor/diff2html/diff2html-ui.min.js', 'application/javascript; charset=utf-8'],
97
+ '/vendor/highlight.js/highlight.min.js': ['vendor/highlight.js/highlight.min.js', 'application/javascript; charset=utf-8'],
98
+ '/vendor/highlight.js/styles/github.min.css': ['vendor/highlight.js/styles/github.min.css', 'text/css; charset=utf-8'],
99
+ '/vendor/highlight.js/styles/github-dark.min.css': ['vendor/highlight.js/styles/github-dark.min.css', 'text/css; charset=utf-8'],
100
+ };
101
+ const spec = map[pathname];
102
+ if (!spec) return null;
103
+ const full = join(WEB_ROOT, spec[0]);
104
+ if (!existsSync(full)) return text('not found', 404);
105
+ return new Response(readFileSync(full), {
106
+ headers: { 'Content-Type': spec[1], 'Cache-Control': 'no-store' },
107
+ });
108
+ }
109
+
110
+ function buildRangeArgs(range: { from?: string; to?: string }) {
111
+ const refs = [];
112
+ if (range.from && range.from !== 'worktree') refs.push(range.from);
113
+ if (range.to && range.to !== 'worktree') refs.push(range.to);
114
+ return { args: refs.length ? refs : cliArgs, refs };
115
+ }
116
+
117
+ function includeUntracked(range: { from?: string; to?: string }, refs: string[]) {
118
+ const toWorktree = !range.to || range.to === 'worktree';
119
+ if (refs.length > 0) return toWorktree && refs.length < 2;
120
+ return cliArgs.length === 0 || (cliArgs.length === 1 && cliArgs[0] === 'HEAD');
121
+ }
122
+
123
+ function guessMediaKind(path: string) {
124
+ const ext = extname(path).slice(1).toLowerCase();
125
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico'].includes(ext)) return 'image';
126
+ if (['mp4', 'webm', 'mov'].includes(ext)) return 'video';
127
+ return null;
128
+ }
129
+
130
+ function classify(file: git.GitFileMeta) {
131
+ if (file.binary) return 'binary';
132
+ const total = (file.additions || 0) + (file.deletions || 0);
133
+ if (total <= SIZE_SMALL) return 'small';
134
+ if (total <= SIZE_MEDIUM) return 'medium';
135
+ if (total <= SIZE_LARGE) return 'large';
136
+ return 'huge';
137
+ }
138
+
139
+ function estimateHeight(file: git.GitFileMeta, sizeClass: string) {
140
+ if (file.binary) return 380;
141
+ if (sizeClass === 'small') return Math.min(800, ((file.additions || 0) + (file.deletions || 0) + 10) * 22);
142
+ return 140;
143
+ }
144
+
145
+ function buildQuery(params: Record<string, unknown>) {
146
+ const q = new URLSearchParams();
147
+ for (const key of Object.keys(params).sort()) {
148
+ const value = params[key];
149
+ if (value !== undefined && value !== null && value !== '') q.set(key, String(value));
150
+ }
151
+ const s = q.toString();
152
+ return s ? `?${s}` : '';
153
+ }
154
+
155
+ function fileToMeta(file: git.GitFileMeta, range: { from?: string; to?: string }, extraQs: Record<string, string>): FileMeta {
156
+ const sizeClass = classify(file);
157
+ const q = { path: file.path, old_path: file.old_path, status: file.status, from: range.from, to: range.to, ...extraQs };
158
+ if (file.untracked) Object.assign(q, { untracked: '1' });
159
+ const previewQ = { ...q, mode: 'preview', max_hunks: PREVIEW_HUNKS_DEFAULT };
160
+ const previewUrl = sizeClass === 'large' || sizeClass === 'huge' ? `/file_diff${buildQuery(previewQ)}` : null;
161
+ return {
162
+ order: file.order,
163
+ key: `${file.status || 'M'}\0${file.old_path || ''}\0${file.path}`,
164
+ path: file.path,
165
+ old_path: file.old_path,
166
+ display_path: file.path,
167
+ status: file.status || 'M',
168
+ additions: file.additions || 0,
169
+ deletions: file.deletions || 0,
170
+ binary: file.binary || false,
171
+ media_kind: guessMediaKind(file.path),
172
+ size_class: sizeClass,
173
+ force_layout: sizeClass === 'large' || sizeClass === 'huge' ? 'line-by-line' : undefined,
174
+ highlight: sizeClass === 'small',
175
+ load_url: `/file_diff${buildQuery(q)}`,
176
+ preview_url: previewUrl,
177
+ estimated_height_px: estimateHeight(file, sizeClass),
178
+ untracked: file.untracked || false,
179
+ };
180
+ }
181
+
182
+ function computePayload(extras: string[], range: { from?: string; to?: string }): DiffMeta {
183
+ const { args, refs } = buildRangeArgs(range);
184
+ const fullArgs = [...extras, ...args];
185
+ const files = git.fileMeta(fullArgs, cwd, false);
186
+ if (includeUntracked(range, refs)) files.push(...git.untrackedMeta(cwd));
187
+ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
188
+ files.forEach((file, i) => { file.order = i + 1; });
189
+ const extraQs: Record<string, string> = {};
190
+ for (const e of extras) {
191
+ if (e === '-w' || e === '--ignore-all-space') extraQs.ignore_ws = '1';
192
+ if (e === '--ignore-blank-lines') extraQs.ignore_blank = '1';
193
+ }
194
+ const meta = files.map((file) => fileToMeta(file, range, extraQs));
195
+ const totals = meta.reduce((acc, file) => {
196
+ acc.additions += file.additions || 0;
197
+ acc.deletions += file.deletions || 0;
198
+ return acc;
199
+ }, { files: meta.length, additions: 0, deletions: 0 });
200
+ const toWorktree = !range.to || range.to === 'worktree';
201
+ const label = refs.length ? `${refs.join(' .. ')}${toWorktree && refs.length === 1 ? ' .. worktree' : ''}` : cliArgs.join(' ');
202
+ return { files: meta, totals, range: label || 'HEAD', project: basename(cwd), branch: git.currentBranch(cwd) || undefined, generation };
203
+ }
204
+
205
+ function handleDiffJson(url: URL) {
206
+ const extras = [];
207
+ if (url.searchParams.get('ignore_ws') === '1') extras.push('-w');
208
+ if (url.searchParams.get('ignore_blank') === '1') extras.push('--ignore-blank-lines');
209
+ const range = { from: url.searchParams.get('from') || '', to: url.searchParams.get('to') || '' };
210
+ const key = `${range.from}|${range.to}|${url.searchParams.get('ignore_ws') || ''}|${url.searchParams.get('ignore_blank') || ''}`;
211
+ if (url.searchParams.get('nocache') === '1') {
212
+ const payload = computePayload(extras, range);
213
+ const sig = JSON.stringify({ ...payload, generation: undefined });
214
+ const cached = metaCache.get(key);
215
+ if (!cached || cached.sig !== sig) {
216
+ generation++;
217
+ payload.generation = generation;
218
+ metaCache.clear();
219
+ fileCache.clear();
220
+ }
221
+ const body = JSON.stringify(payload);
222
+ metaCache.set(key, { body, sig });
223
+ return new Response(body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
224
+ }
225
+ const cached = metaCache.get(key);
226
+ if (cached) return new Response(cached.body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
227
+ const payload = computePayload(extras, range);
228
+ const body = JSON.stringify(payload);
229
+ metaCache.set(key, { body, sig: JSON.stringify({ ...payload, generation: undefined }) });
230
+ return new Response(body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
231
+ }
232
+
233
+ function safePath(path: string) {
234
+ return path && !path.includes('../') && !path.includes('..\\') && !path.startsWith('/') && !path.startsWith('\\');
235
+ }
236
+
237
+ function safeWorktreePath(path: string): string | null {
238
+ if (!safePath(path)) return null;
239
+ const full = join(cwd, path);
240
+ if (!existsSync(full)) return null;
241
+ const realCwd = realpathSync(cwd);
242
+ const realFull = realpathSync(full);
243
+ const rel = relative(realCwd, realFull);
244
+ if (rel === '' || rel.startsWith('..') || rel.startsWith('/') || rel.startsWith('\\')) return null;
245
+ return realFull;
246
+ }
247
+
248
+ function handleFileDiff(url: URL) {
249
+ const path = url.searchParams.get('path') || '';
250
+ if (!safePath(path)) return text('invalid path', 400);
251
+ const extras = [];
252
+ if (url.searchParams.get('ignore_ws') === '1') extras.push('-w');
253
+ if (url.searchParams.get('ignore_blank') === '1') extras.push('--ignore-blank-lines');
254
+ const isUntracked = url.searchParams.get('untracked') === '1';
255
+ const range = { from: url.searchParams.get('from') || '', to: url.searchParams.get('to') || '' };
256
+ const { args } = buildRangeArgs(range);
257
+ const oldPath = url.searchParams.get('old_path');
258
+ const cacheKey = isUntracked
259
+ ? `u\0${path}\0${extras.join('\0')}`
260
+ : `t\0${path}\0${oldPath || ''}\0${[...extras, ...args].join('\0')}`;
261
+ let diffText = fileCache.get(cacheKey);
262
+ let errText = '';
263
+ if (!diffText) {
264
+ if (isUntracked) {
265
+ diffText = git.untrackedFileDiff(extras, path, cwd).stdout || '';
266
+ } else {
267
+ const res = git.fileDiffText([...extras, ...args], oldPath ? [oldPath, path] : path, cwd);
268
+ diffText = res.stdout || '';
269
+ if (res.code !== 0) errText = res.stderr;
270
+ }
271
+ fileCache.set(cacheKey, diffText);
272
+ }
273
+ const mode = url.searchParams.get('mode') || 'full';
274
+ const truncated = mode === 'preview'
275
+ ? git.truncateToNHunks(diffText, Number(url.searchParams.get('max_hunks')) || PREVIEW_HUNKS_DEFAULT)
276
+ : git.truncateToNHunks(diffText, 1e9);
277
+ const body: FileDiffResponse & { line_count?: number; error?: string } = {
278
+ path,
279
+ old_path: url.searchParams.get('old_path') || '',
280
+ status: url.searchParams.get('status') || '',
281
+ mode,
282
+ diff: truncated.text,
283
+ hunk_count: truncated.totalHunks,
284
+ rendered_hunk_count: truncated.renderedHunks,
285
+ line_count: truncated.lineCount,
286
+ truncated: mode === 'preview' && truncated.totalHunks > truncated.renderedHunks,
287
+ binary: diffText.includes('Binary files'),
288
+ error: errText,
289
+ generation,
290
+ };
291
+ return json(body);
292
+ }
293
+
294
+ function handleFileRange(url: URL) {
295
+ const path = url.searchParams.get('path') || '';
296
+ if (!safePath(path)) return text('invalid path', 400);
297
+ let start = Number(url.searchParams.get('start') || '1') || 1;
298
+ let end = Number(url.searchParams.get('end') || url.searchParams.get('endline') || '0') || 0;
299
+ if (start < 1) start = 1;
300
+ if (end < start) end = start;
301
+ const ref = url.searchParams.get('ref') || 'worktree';
302
+ let content = '';
303
+ if (ref === 'worktree' || ref === '') {
304
+ const full = safeWorktreePath(path);
305
+ if (!full) return text('no file', 404);
306
+ content = readFileSync(full, 'utf8');
307
+ } else {
308
+ const res = git.show(ref, path, cwd);
309
+ if (res.code !== 0) return text('not in ref', 404);
310
+ content = res.stdout;
311
+ }
312
+ const lines: string[] = [];
313
+ const all = `${content}\n`.split('\n');
314
+ for (let i = start; i <= end && i <= all.length; i++) lines.push(all[i - 1]);
315
+ const body: FileRangeResponse = { path, ref, start, end, lines, total: Math.min(all.length, end + 1), generation };
316
+ return json(body);
317
+ }
318
+
319
+ function handleRawFile(url: URL) {
320
+ const path = url.searchParams.get('path') || '';
321
+ if (!safePath(path)) return text('forbidden', 403);
322
+ const ref = url.searchParams.get('ref') || 'worktree';
323
+ let body: BodyInit;
324
+ if (ref !== 'worktree' && ref !== '') {
325
+ const res = git.show(ref, path, cwd);
326
+ if (res.code !== 0) return text('not in ref', 404);
327
+ body = res.stdout;
328
+ } else {
329
+ const full = safeWorktreePath(path);
330
+ if (!full) return text('not found', 404);
331
+ body = new Uint8Array(readFileSync(full));
332
+ }
333
+ const mime: Record<string, string> = {
334
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
335
+ '.webp': 'image/webp', '.svg': 'image/svg+xml', '.mp4': 'video/mp4', '.webm': 'video/webm',
336
+ };
337
+ return new Response(body, { headers: { 'Content-Type': mime[extname(path).toLowerCase()] || 'application/octet-stream', 'Cache-Control': 'no-store' } });
338
+ }
339
+
340
+ function sendSse(event: string, data = 'tick') {
341
+ const payload = enc.encode(`event: ${event}\ndata: ${data}\n\n`);
342
+ for (const client of [...sseClients]) {
343
+ try { client.enqueue(payload); } catch { sseClients.delete(client); }
344
+ }
345
+ }
346
+
347
+ function openBrowser(url: string) {
348
+ const cmd = process.platform === 'darwin' ? ['open', url]
349
+ : process.platform === 'win32' ? ['cmd.exe', '/c', 'start', '', url]
350
+ : ['xdg-open', url];
351
+ Bun.spawn(cmd, { stdout: 'ignore', stderr: 'ignore' });
352
+ }
353
+
354
+ parseCli();
355
+
356
+ const server = Bun.serve({
357
+ hostname: '127.0.0.1',
358
+ port: 0,
359
+ fetch(req) {
360
+ if (!requestAllowed(req)) return text('forbidden', 403);
361
+ const url = new URL(req.url);
362
+ const staticResponse = staticFile(url.pathname);
363
+ if (staticResponse) return staticResponse;
364
+ if (url.pathname === '/diff.json') return handleDiffJson(url);
365
+ if (url.pathname === '/file_diff') return handleFileDiff(url);
366
+ if (url.pathname === '/file_range') return handleFileRange(url);
367
+ if (url.pathname === '/_file') return handleRawFile(url);
368
+ if (url.pathname === '/_refs') return json(git.refs(cwd));
369
+ if (url.pathname === '/_asset_version') {
370
+ const version = Math.max(...WATCHED_ASSET_FILES.map((name) => statSync(join(WEB_ROOT, name)).mtimeMs));
371
+ return json({ version });
372
+ }
373
+ if (url.pathname === '/refresh' && req.method === 'POST') {
374
+ generation++;
375
+ fileCache.clear();
376
+ metaCache.clear();
377
+ sendSse('update');
378
+ return json({ ok: true, generation });
379
+ }
380
+ if (url.pathname === '/events') {
381
+ let ctrl: ReadableStreamDefaultController<Uint8Array>;
382
+ let keepalive: ReturnType<typeof setInterval>;
383
+ return new Response(new ReadableStream<Uint8Array>({
384
+ start(controller) {
385
+ ctrl = controller;
386
+ sseClients.add(controller);
387
+ controller.enqueue(enc.encode('event: open\ndata: ok\n\n'));
388
+ keepalive = setInterval(() => {
389
+ try {
390
+ controller.enqueue(enc.encode(': ping\n\n'));
391
+ } catch {
392
+ sseClients.delete(controller);
393
+ clearInterval(keepalive);
394
+ }
395
+ }, 15000);
396
+ },
397
+ cancel() {
398
+ if (ctrl) sseClients.delete(ctrl);
399
+ if (keepalive) clearInterval(keepalive);
400
+ },
401
+ }), {
402
+ headers: {
403
+ 'Content-Type': 'text/event-stream',
404
+ 'Cache-Control': 'no-cache',
405
+ Connection: 'keep-alive',
406
+ },
407
+ });
408
+ }
409
+ return text('not found', 404);
410
+ },
411
+ });
412
+
413
+ console.log(`GDP_LISTEN_URL=http://127.0.0.1:${server.port}/`);
414
+ console.log(`git-diff-preview serving ${cwd}`);
@@ -0,0 +1,40 @@
1
+ declare const Bun: {
2
+ spawn(args: string[], opts?: Record<string, unknown>): unknown;
3
+ spawnSync(args: string[], opts?: Record<string, unknown>): {
4
+ exitCode: number;
5
+ stdout: Uint8Array;
6
+ stderr: Uint8Array;
7
+ };
8
+ serve(opts: {
9
+ hostname?: string;
10
+ port?: number;
11
+ fetch(req: Request): Response | Promise<Response>;
12
+ }): { port: number };
13
+ };
14
+
15
+ declare const process: {
16
+ argv: string[];
17
+ cwd(): string;
18
+ platform: 'darwin' | 'win32' | string;
19
+ exit(code?: number): never;
20
+ };
21
+
22
+ interface ImportMeta {
23
+ dir: string;
24
+ }
25
+
26
+ declare module 'node:fs' {
27
+ export function existsSync(path: string): boolean;
28
+ export function readFileSync(path: string): Buffer;
29
+ export function readFileSync(path: string, encoding: BufferEncoding): string;
30
+ export function realpathSync(path: string): string;
31
+ export function statSync(path: string): { mtimeMs: number };
32
+ }
33
+
34
+ declare module 'node:path' {
35
+ export function basename(path: string): string;
36
+ export function extname(path: string): string;
37
+ export function join(...parts: string[]): string;
38
+ export function normalize(path: string): string;
39
+ export function relative(from: string, to: string): string;
40
+ }