@youtyan/code-viewer 0.1.0 → 0.1.1

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,95 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { readdirSync, statSync } from 'node:fs';
4
+ import { join, normalize } from 'node:path';
5
+
6
+ const ROOT = normalize(join(import.meta.dir, '..', '..'));
7
+ const SERVER_ROOT = join(ROOT, 'web-src', 'server');
8
+ const DEFAULT_DEV_PORT = 64160;
9
+
10
+ type ChildProcess = {
11
+ kill(signal?: string): void;
12
+ exited: Promise<number>;
13
+ };
14
+
15
+ let server: ChildProcess | null = null;
16
+ let build: ChildProcess | null = null;
17
+ let restarting = false;
18
+ let firstStart = true;
19
+
20
+ function withDefaultPort(args: string[]) {
21
+ if (args.includes('--port')) return args;
22
+ return ['--port', String(DEFAULT_DEV_PORT), ...args];
23
+ }
24
+
25
+ function withoutOpen(args: string[]) {
26
+ return args.filter((arg) => arg !== '--open');
27
+ }
28
+
29
+ function serverArgs() {
30
+ const args = withDefaultPort(process.argv.slice(2));
31
+ return firstStart ? args : withoutOpen(args);
32
+ }
33
+
34
+ function watchedFiles() {
35
+ return readdirSync(SERVER_ROOT)
36
+ .filter((name) => name.endsWith('.ts') && name !== 'runtime.d.ts')
37
+ .map((name) => join(SERVER_ROOT, name))
38
+ .concat(join(ROOT, 'web-src', 'types.ts'));
39
+ }
40
+
41
+ function watchSignature() {
42
+ return watchedFiles()
43
+ .map((file) => `${file}:${statSync(file).mtimeMs}`)
44
+ .join('|');
45
+ }
46
+
47
+ function startBuild() {
48
+ build = Bun.spawn([
49
+ 'bun', 'build', '--watch', '--target=browser', '--format=iife',
50
+ '--outfile=web/app.js', 'web-src/app.ts',
51
+ ], { cwd: ROOT, stdout: 'inherit', stderr: 'inherit' }) as ChildProcess;
52
+ }
53
+
54
+ function startServer() {
55
+ const args = serverArgs();
56
+ firstStart = false;
57
+ server = Bun.spawn([
58
+ 'bun', 'run', 'web-src/server/preview.ts', ...args,
59
+ ], { cwd: ROOT, stdout: 'inherit', stderr: 'inherit' }) as ChildProcess;
60
+ }
61
+
62
+ async function restartServer() {
63
+ if (restarting) return;
64
+ restarting = true;
65
+ const old = server;
66
+ server = null;
67
+ if (old) {
68
+ old.kill();
69
+ await old.exited.catch(() => 1);
70
+ }
71
+ startServer();
72
+ restarting = false;
73
+ }
74
+
75
+ function shutdown() {
76
+ if (server) server.kill();
77
+ if (build) build.kill();
78
+ process.exit(0);
79
+ }
80
+
81
+ process.on('SIGINT', shutdown);
82
+ process.on('SIGTERM', shutdown);
83
+
84
+ console.log(`code-viewer dev server watching ${SERVER_ROOT}`);
85
+ startBuild();
86
+ startServer();
87
+
88
+ let sig = watchSignature();
89
+ setInterval(() => {
90
+ const next = watchSignature();
91
+ if (next === sig) return;
92
+ sig = next;
93
+ console.log('server source changed; restarting preview server');
94
+ restartServer();
95
+ }, 500);
@@ -13,6 +13,12 @@ export type GitFileMeta = {
13
13
  untracked?: boolean;
14
14
  };
15
15
 
16
+ export type GitTreeEntry = {
17
+ name: string;
18
+ path: string;
19
+ type: 'tree' | 'blob' | 'commit';
20
+ };
21
+
16
22
  function run(args: string[], cwd: string): { code: number; stdout: string; stderr: string } {
17
23
  const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe' });
18
24
  return {
@@ -36,6 +42,13 @@ export function show(ref: string, path: string, cwd: string): { code: number; st
36
42
  return run(['git', 'show', `${ref}:${path}`], cwd);
37
43
  }
38
44
 
45
+ export function verifyTreeRef(ref: string, cwd: string): boolean {
46
+ if (!ref || ref === 'worktree') return false;
47
+ if (ref.startsWith('-')) return false;
48
+ const res = run(['git', 'rev-parse', '--verify', `${ref}^{tree}`], cwd);
49
+ return res.code === 0;
50
+ }
51
+
39
52
  export function refs(cwd: string): { branches: string[]; tags: string[]; commits: string[]; current: string } {
40
53
  const out = { branches: [] as string[], tags: [] as string[], commits: [] as string[], current: '' };
41
54
  const branches = run([
@@ -106,11 +119,99 @@ export function numstatZ(args: string[], cwd: string): GitFileMeta[] {
106
119
  return files;
107
120
  }
108
121
 
109
- export function untracked(cwd: string): string[] {
110
- const res = run(['git', 'ls-files', '--others', '--exclude-standard'], cwd);
122
+ export function untracked(cwd: string, path = ''): string[] {
123
+ const args = ['git', 'ls-files', '--others', '--exclude-standard'];
124
+ if (path) args.push('--', `${path}/`);
125
+ const res = run(args, cwd);
111
126
  return res.code === 0 ? res.stdout.split('\n').filter(Boolean) : [];
112
127
  }
113
128
 
129
+ function normalizeTreePath(path: string): string {
130
+ return path.replace(/^\/+|\/+$/g, '');
131
+ }
132
+
133
+ function directChildren(paths: string[], basePath: string): GitTreeEntry[] {
134
+ const base = normalizeTreePath(basePath);
135
+ const prefix = base ? `${base}/` : '';
136
+ const entries = new Map<string, GitTreeEntry>();
137
+ for (const rawPath of paths) {
138
+ if (!rawPath || (prefix && !rawPath.startsWith(prefix))) continue;
139
+ const rest = prefix ? rawPath.slice(prefix.length) : rawPath;
140
+ if (!rest) continue;
141
+ const slash = rest.indexOf('/');
142
+ const name = slash >= 0 ? rest.slice(0, slash) : rest;
143
+ const childPath = prefix + name;
144
+ const type = slash >= 0 ? 'tree' : 'blob';
145
+ const existing = entries.get(childPath);
146
+ if (!existing || existing.type !== 'tree') entries.set(childPath, { name, path: childPath, type });
147
+ }
148
+ return [...entries.values()].sort((a, b) => {
149
+ if (a.type !== b.type) return a.type === 'tree' ? -1 : 1;
150
+ return a.name.localeCompare(b.name);
151
+ });
152
+ }
153
+
154
+ export function worktreeEntries(cwd: string, path: string): GitTreeEntry[] {
155
+ return listTree('worktree', path, cwd).entries;
156
+ }
157
+
158
+ export function worktreeFiles(cwd: string): GitTreeEntry[] {
159
+ return listTree('worktree', '', cwd, { recursive: true }).entries;
160
+ }
161
+
162
+ export function treeEntries(ref: string, path: string, cwd: string): { code: number; entries: GitTreeEntry[]; stderr: string } {
163
+ return listTree(ref, path, cwd);
164
+ }
165
+
166
+ export function treeFiles(ref: string, cwd: string): { code: number; entries: GitTreeEntry[]; stderr: string } {
167
+ return listTree(ref, '', cwd, { recursive: true });
168
+ }
169
+
170
+ export function listTree(
171
+ ref: string,
172
+ path: string,
173
+ cwd: string,
174
+ options: { recursive?: boolean } = {},
175
+ ): { code: number; entries: GitTreeEntry[]; stderr: string } {
176
+ const base = normalizeTreePath(path);
177
+ if (ref === 'worktree') {
178
+ const trackedArgs = ['git', '-c', 'core.quotepath=false', 'ls-files', '-z'];
179
+ if (!options.recursive) trackedArgs.push('--', base ? `${base}/` : '.');
180
+ const tracked = run(trackedArgs, cwd);
181
+ const paths = tracked.code === 0 ? tracked.stdout.split('\0').filter(Boolean) : [];
182
+ paths.push(...untracked(cwd, options.recursive ? '' : base));
183
+ const uniquePaths = [...new Set(paths)].filter(Boolean);
184
+ const entries = options.recursive
185
+ ? uniquePaths.sort().map((entryPath) => ({
186
+ name: entryPath.split('/').pop() || entryPath,
187
+ path: entryPath,
188
+ type: 'blob' as const,
189
+ }))
190
+ : directChildren(uniquePaths, base);
191
+ return { code: 0, entries, stderr: '' };
192
+ }
193
+
194
+ const args = ['git', '-c', 'core.quotepath=false', 'ls-tree'];
195
+ if (options.recursive) args.push('-r');
196
+ args.push('-z', '--full-tree', ref, '--');
197
+ if (!options.recursive && base) args.push(`${base}/`);
198
+ const res = run(args, cwd);
199
+ if (res.code !== 0) return { code: res.code, entries: [], stderr: res.stderr };
200
+ const allowedTypes = options.recursive ? 'blob|commit' : 'tree|blob|commit';
201
+ const entries = res.stdout.split('\0').filter(Boolean).map((rec) => {
202
+ const match = rec.match(new RegExp(`^\\d+\\s+(${allowedTypes})\\s+[0-9a-fA-F]+\\t(.+)$`));
203
+ if (!match) return null;
204
+ const entryPath = match[2];
205
+ return { name: entryPath.split('/').pop() || entryPath, path: entryPath, type: match[1] as GitTreeEntry['type'] };
206
+ }).filter((entry): entry is GitTreeEntry => !!entry);
207
+ entries.sort((a, b) => {
208
+ if (options.recursive) return a.path.localeCompare(b.path);
209
+ if (a.type !== b.type) return a.type === 'tree' ? -1 : 1;
210
+ return a.name.localeCompare(b.name);
211
+ });
212
+ return { code: 0, entries, stderr: '' };
213
+ }
214
+
114
215
  export function untrackedMeta(cwd: string): GitFileMeta[] {
115
216
  return untracked(cwd).map((path) => {
116
217
  const full = join(cwd, path);
@@ -187,7 +288,7 @@ export function truncateToNHunks(diffText: string, n: number): {
187
288
  return { text: diffText, totalHunks: 0, renderedHunks: 0, lineCount: (diffText.match(/\n/g) || []).length };
188
289
  }
189
290
  const renderedHunks = Math.min(n, hunks.length);
190
- const text = header + hunks.slice(0, renderedHunks).join('');
291
+ const text = header + hunks.slice(0, renderedHunks).join('\n');
191
292
  return {
192
293
  text,
193
294
  totalHunks: hunks.length,
@@ -2,8 +2,10 @@
2
2
 
3
3
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
4
4
  import { basename, extname, join, normalize, relative } from 'node:path';
5
- import type { DiffMeta, FileDiffResponse, FileMeta, FileRangeResponse } from '../types';
5
+ import { APP_ENTRY_PATHS, SPA_PATHS } from '../routes';
6
+ import type { DiffMeta, FileDiffResponse, FileMeta, FileRangeResponse, RepoTreeResponse } from '../types';
6
7
  import * as git from './git';
8
+ import { isSameWorktreeRange } from './range';
7
9
 
8
10
  const ROOT = normalize(join(import.meta.dir, '..', '..'));
9
11
  const WEB_ROOT = join(ROOT, 'web');
@@ -18,6 +20,7 @@ const SIZE_LARGE = 20000;
18
20
  let generation = 1;
19
21
  let cwd = git.repoRoot(process.cwd()) || process.cwd();
20
22
  let cliArgs = DEFAULT_ARGS;
23
+ let listenPort = 0;
21
24
 
22
25
  const enc = new TextEncoder();
23
26
  const sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
@@ -32,7 +35,7 @@ function parseCli() {
32
35
  console.log(`code-viewer ${VERSION}
33
36
 
34
37
  Usage:
35
- code-viewer [--cwd <repo>] [--open] [git-diff-args...]
38
+ code-viewer [--cwd <repo>] [--port <port>] [--open] [git-diff-args...]
36
39
 
37
40
  Examples:
38
41
  code-viewer --open
@@ -51,6 +54,14 @@ Examples:
51
54
  process.exit(1);
52
55
  }
53
56
  cwd = git.repoRoot(next) || cwd;
57
+ } else if (arg === '--port') {
58
+ const next = process.argv[++i];
59
+ const parsed = Number(next);
60
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
61
+ console.error('--port requires a TCP port number');
62
+ process.exit(1);
63
+ }
64
+ listenPort = parsed;
54
65
  } else if (arg === '--open') {
55
66
  setTimeout(() => openBrowser(`http://127.0.0.1:${server.port}/`), 0);
56
67
  } else {
@@ -88,8 +99,7 @@ function requestAllowed(req: Request) {
88
99
 
89
100
  function staticFile(pathname: string): Response | null {
90
101
  const map: Record<string, [string, string]> = {
91
- '/': ['index.html', 'text/html; charset=utf-8'],
92
- '/index.html': ['index.html', 'text/html; charset=utf-8'],
102
+ '/favicon.png': ['favicon.png', 'image/png'],
93
103
  '/style.css': ['style.css', 'text/css; charset=utf-8'],
94
104
  '/app.js': ['app.js', 'application/javascript; charset=utf-8'],
95
105
  '/vendor/diff2html/diff2html.min.css': ['vendor/diff2html/diff2html.min.css', 'text/css; charset=utf-8'],
@@ -98,6 +108,9 @@ function staticFile(pathname: string): Response | null {
98
108
  '/vendor/highlight.js/styles/github.min.css': ['vendor/highlight.js/styles/github.min.css', 'text/css; charset=utf-8'],
99
109
  '/vendor/highlight.js/styles/github-dark.min.css': ['vendor/highlight.js/styles/github-dark.min.css', 'text/css; charset=utf-8'],
100
110
  };
111
+ for (const spaPath of [...APP_ENTRY_PATHS, ...SPA_PATHS]) {
112
+ map[spaPath] = ['index.html', 'text/html; charset=utf-8'];
113
+ }
101
114
  const spec = map[pathname];
102
115
  if (!spec) return null;
103
116
  const full = join(WEB_ROOT, spec[0]);
@@ -180,6 +193,16 @@ function fileToMeta(file: git.GitFileMeta, range: { from?: string; to?: string }
180
193
  }
181
194
 
182
195
  function computePayload(extras: string[], range: { from?: string; to?: string }): DiffMeta {
196
+ if (isSameWorktreeRange(range)) {
197
+ return {
198
+ files: [],
199
+ totals: { files: 0, additions: 0, deletions: 0 },
200
+ range: 'worktree .. worktree',
201
+ project: basename(cwd),
202
+ branch: git.currentBranch(cwd) || undefined,
203
+ generation,
204
+ };
205
+ }
183
206
  const { args, refs } = buildRangeArgs(range);
184
207
  const fullArgs = [...extras, ...args];
185
208
  const files = git.fileMeta(fullArgs, cwd, false);
@@ -231,7 +254,12 @@ function handleDiffJson(url: URL) {
231
254
  }
232
255
 
233
256
  function safePath(path: string) {
234
- return path && !path.includes('../') && !path.includes('..\\') && !path.startsWith('/') && !path.startsWith('\\');
257
+ if (!path || path.startsWith('/') || path.startsWith('\\') || path.includes('\0')) return false;
258
+ return !path.split(/[\\/]+/).includes('..');
259
+ }
260
+
261
+ function safeRepoPath(path: string) {
262
+ return path === '' || safePath(path);
235
263
  }
236
264
 
237
265
  function safeWorktreePath(path: string): string | null {
@@ -245,6 +273,42 @@ function safeWorktreePath(path: string): string | null {
245
273
  return realFull;
246
274
  }
247
275
 
276
+ function readReadme(target: string, dirPath: string): RepoTreeResponse['readme'] {
277
+ const candidates = ['README.md', 'readme.md', 'README.markdown', 'README'];
278
+ for (const name of candidates) {
279
+ const path = dirPath ? `${dirPath}/${name}` : name;
280
+ if (target === 'worktree' || target === '') {
281
+ const full = safeWorktreePath(path);
282
+ if (!full) continue;
283
+ try {
284
+ return { path, text: readFileSync(full, 'utf8') };
285
+ } catch {
286
+ continue;
287
+ }
288
+ }
289
+ const res = git.show(target, path, cwd);
290
+ if (res.code === 0) return { path, text: res.stdout };
291
+ }
292
+ return null;
293
+ }
294
+
295
+ function handleTree(url: URL) {
296
+ const target = url.searchParams.get('ref') || url.searchParams.get('target') || 'worktree';
297
+ const path = (url.searchParams.get('path') || '').replace(/^\/+|\/+$/g, '');
298
+ if (!safeRepoPath(path)) return text('invalid path', 400);
299
+ if (target !== 'worktree' && !git.verifyTreeRef(target, cwd)) return text('invalid target', 400);
300
+ const recursive = url.searchParams.get('recursive') === '1';
301
+ const entries = git.listTree(target, path, cwd, { recursive }).entries;
302
+ return json({
303
+ ref: target,
304
+ path,
305
+ project: basename(cwd),
306
+ branch: git.currentBranch(cwd) || undefined,
307
+ entries,
308
+ readme: readReadme(target, path),
309
+ } satisfies RepoTreeResponse);
310
+ }
311
+
248
312
  function handleFileDiff(url: URL) {
249
313
  const path = url.searchParams.get('path') || '';
250
314
  if (!safePath(path)) return text('invalid path', 400);
@@ -253,6 +317,21 @@ function handleFileDiff(url: URL) {
253
317
  if (url.searchParams.get('ignore_blank') === '1') extras.push('--ignore-blank-lines');
254
318
  const isUntracked = url.searchParams.get('untracked') === '1';
255
319
  const range = { from: url.searchParams.get('from') || '', to: url.searchParams.get('to') || '' };
320
+ if (isSameWorktreeRange(range)) {
321
+ return json({
322
+ path,
323
+ old_path: url.searchParams.get('old_path') || '',
324
+ status: url.searchParams.get('status') || '',
325
+ mode: url.searchParams.get('mode') || 'full',
326
+ diff: '',
327
+ hunk_count: 0,
328
+ rendered_hunk_count: 0,
329
+ line_count: 0,
330
+ truncated: false,
331
+ binary: false,
332
+ generation,
333
+ });
334
+ }
256
335
  const { args } = buildRangeArgs(range);
257
336
  const oldPath = url.searchParams.get('old_path');
258
337
  const cacheKey = isUntracked
@@ -305,6 +384,7 @@ function handleFileRange(url: URL) {
305
384
  if (!full) return text('no file', 404);
306
385
  content = readFileSync(full, 'utf8');
307
386
  } else {
387
+ if (!git.verifyTreeRef(ref, cwd)) return text('invalid ref', 400);
308
388
  const res = git.show(ref, path, cwd);
309
389
  if (res.code !== 0) return text('not in ref', 404);
310
390
  content = res.stdout;
@@ -322,6 +402,7 @@ function handleRawFile(url: URL) {
322
402
  const ref = url.searchParams.get('ref') || 'worktree';
323
403
  let body: BodyInit;
324
404
  if (ref !== 'worktree' && ref !== '') {
405
+ if (!git.verifyTreeRef(ref, cwd)) return text('invalid ref', 400);
325
406
  const res = git.show(ref, path, cwd);
326
407
  if (res.code !== 0) return text('not in ref', 404);
327
408
  body = res.stdout;
@@ -355,13 +436,14 @@ parseCli();
355
436
 
356
437
  const server = Bun.serve({
357
438
  hostname: '127.0.0.1',
358
- port: 0,
439
+ port: listenPort,
359
440
  fetch(req) {
360
441
  if (!requestAllowed(req)) return text('forbidden', 403);
361
442
  const url = new URL(req.url);
362
443
  const staticResponse = staticFile(url.pathname);
363
444
  if (staticResponse) return staticResponse;
364
445
  if (url.pathname === '/diff.json') return handleDiffJson(url);
446
+ if (url.pathname === '/_tree') return handleTree(url);
365
447
  if (url.pathname === '/file_diff') return handleFileDiff(url);
366
448
  if (url.pathname === '/file_range') return handleFileRange(url);
367
449
  if (url.pathname === '/_file') return handleRawFile(url);
@@ -0,0 +1,8 @@
1
+ export type DiffRange = {
2
+ from?: string;
3
+ to?: string;
4
+ };
5
+
6
+ export function isSameWorktreeRange(range: DiffRange): boolean {
7
+ return range.from === 'worktree' && range.to === 'worktree';
8
+ }
@@ -1,5 +1,8 @@
1
1
  declare const Bun: {
2
- spawn(args: string[], opts?: Record<string, unknown>): unknown;
2
+ spawn(args: string[], opts?: Record<string, unknown>): {
3
+ kill(signal?: string): void;
4
+ exited: Promise<number>;
5
+ };
3
6
  spawnSync(args: string[], opts?: Record<string, unknown>): {
4
7
  exitCode: number;
5
8
  stdout: Uint8Array;
@@ -16,6 +19,7 @@ declare const process: {
16
19
  argv: string[];
17
20
  cwd(): string;
18
21
  platform: 'darwin' | 'win32' | string;
22
+ on(event: 'SIGINT' | 'SIGTERM', listener: () => void): void;
19
23
  exit(code?: number): never;
20
24
  };
21
25
 
@@ -25,6 +29,7 @@ interface ImportMeta {
25
29
 
26
30
  declare module 'node:fs' {
27
31
  export function existsSync(path: string): boolean;
32
+ export function readdirSync(path: string): string[];
28
33
  export function readFileSync(path: string): Buffer;
29
34
  export function readFileSync(path: string, encoding: BufferEncoding): string;
30
35
  export function realpathSync(path: string): string;
package/web-src/types.ts CHANGED
@@ -33,6 +33,24 @@ export type DiffMeta = {
33
33
  generation?: number;
34
34
  };
35
35
 
36
+ export type RepoTreeEntry = {
37
+ name: string;
38
+ path: string;
39
+ type: 'tree' | 'blob' | 'commit';
40
+ };
41
+
42
+ export type RepoTreeResponse = {
43
+ ref: string;
44
+ path: string;
45
+ project: string;
46
+ branch?: string;
47
+ entries: RepoTreeEntry[];
48
+ readme?: {
49
+ path: string;
50
+ text: string;
51
+ } | null;
52
+ };
53
+
36
54
  export type FileDiffResponse = {
37
55
  path: string;
38
56
  old_path?: string;