@youtyan/code-viewer 0.1.16 → 0.1.18

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.
@@ -1,506 +0,0 @@
1
- import { existsSync, lstatSync, readdirSync, 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
- export type GitTreeEntry = {
17
- name: string;
18
- path: string;
19
- type: 'tree' | 'blob' | 'commit';
20
- children_omitted?: true;
21
- children_omitted_reason?: 'heavy' | 'internal' | 'truncated';
22
- };
23
-
24
- const WORKTREE_RECURSIVE_DEPTH_LIMIT = 32;
25
- export const WORKTREE_RECURSIVE_ENTRY_LIMIT = 50000;
26
- export const DEFAULT_WORKTREE_OMIT_DIR_NAMES = [
27
- 'node_modules',
28
- '.venv',
29
- 'venv',
30
- '.next',
31
- '.nuxt',
32
- '.svelte-kit',
33
- '.astro',
34
- '.vercel',
35
- 'dist',
36
- 'build',
37
- 'out',
38
- 'target',
39
- '.gradle',
40
- '__pycache__',
41
- '.pytest_cache',
42
- '.tox',
43
- '.terraform',
44
- '.idea',
45
- '.vscode',
46
- 'vendor',
47
- '.cache',
48
- 'coverage',
49
- 'DerivedData',
50
- 'Pods',
51
- 'bin',
52
- 'obj',
53
- ];
54
-
55
- function run(args: string[], cwd: string): { code: number; stdout: string; stderr: string } {
56
- const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe' });
57
- return {
58
- code: proc.exitCode,
59
- stdout: new TextDecoder().decode(proc.stdout),
60
- stderr: new TextDecoder().decode(proc.stderr),
61
- };
62
- }
63
-
64
- function runBytes(args: string[], cwd: string): { code: number; stdout: Uint8Array; stderr: string } {
65
- const proc = Bun.spawnSync(args, { cwd, stdout: 'pipe', stderr: 'pipe' });
66
- return {
67
- code: proc.exitCode,
68
- stdout: new Uint8Array(proc.stdout),
69
- stderr: new TextDecoder().decode(proc.stderr),
70
- };
71
- }
72
-
73
- export function repoRoot(cwd: string): string | null {
74
- const res = run(['git', 'rev-parse', '--show-toplevel'], cwd);
75
- return res.code === 0 ? res.stdout.trimEnd() : null;
76
- }
77
-
78
- export function currentBranch(cwd: string): string | null {
79
- const res = run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd);
80
- return res.code === 0 ? res.stdout.trimEnd() : null;
81
- }
82
-
83
- export function show(ref: string, path: string, cwd: string): { code: number; stdout: string; stderr: string } {
84
- return run(['git', 'show', `${ref}:${path}`], cwd);
85
- }
86
-
87
- export function showBytes(ref: string, path: string, cwd: string): { code: number; stdout: Uint8Array; stderr: string } {
88
- return runBytes(['git', 'show', `${ref}:${path}`], cwd);
89
- }
90
-
91
- export function catFileBlobStream(oid: string, cwd: string): { stream: ReadableStream<Uint8Array>; exited: Promise<number>; kill(signal?: string): void } {
92
- const proc = Bun.spawn(['git', 'cat-file', 'blob', oid], { cwd, stdout: 'pipe', stderr: 'ignore', stdin: 'ignore' });
93
- return {
94
- stream: proc.stdout as ReadableStream<Uint8Array>,
95
- exited: proc.exited,
96
- kill: (signal?: string) => proc.kill(signal),
97
- };
98
- }
99
-
100
- export function objectSize(ref: string, path: string, cwd: string): { code: number; size: number; stderr: string } {
101
- const res = run(['git', 'cat-file', '-s', `${ref}:${path}`], cwd);
102
- return { code: res.code, size: Number(res.stdout.trim()) || 0, stderr: res.stderr };
103
- }
104
-
105
- export function objectByteSize(oid: string, cwd: string): { code: number; size: number; stderr: string } {
106
- const res = run(['git', 'cat-file', '-s', oid], cwd);
107
- return { code: res.code, size: Number(res.stdout.trim()) || 0, stderr: res.stderr };
108
- }
109
-
110
- export function objectId(ref: string, path: string, cwd: string): { code: number; oid: string; stderr: string } {
111
- const res = run(['git', 'rev-parse', '--verify', `${ref}:${path}`], cwd);
112
- const oid = res.stdout.trim();
113
- if (res.code !== 0 || !oid) return { code: res.code || 1, oid: '', stderr: res.stderr };
114
- const type = run(['git', 'cat-file', '-t', oid], cwd);
115
- if (type.code !== 0 || type.stdout.trim() !== 'blob') return { code: 1, oid: '', stderr: type.stderr };
116
- return { code: 0, oid, stderr: '' };
117
- }
118
-
119
- export function verifyTreeRef(ref: string, cwd: string): boolean {
120
- if (!ref || ref === 'worktree') return false;
121
- if (ref.startsWith('-')) return false;
122
- const res = run(['git', 'rev-parse', '--verify', `${ref}^{tree}`], cwd);
123
- return res.code === 0;
124
- }
125
-
126
- export function refs(cwd: string): { branches: string[]; tags: string[]; commits: string[]; current: string } {
127
- const out = { branches: [] as string[], tags: [] as string[], commits: [] as string[], current: '' };
128
- const branches = run([
129
- 'git', 'for-each-ref', '--sort=-committerdate', '--format=%(refname:short)', 'refs/heads', 'refs/remotes',
130
- ], cwd);
131
- if (branches.code === 0) {
132
- out.branches = branches.stdout.split('\n').filter((line) => line && line !== 'origin/HEAD');
133
- }
134
- const tags = run(['git', 'for-each-ref', '--sort=-creatordate', '--format=%(refname:short)', 'refs/tags'], cwd);
135
- if (tags.code === 0) out.tags = tags.stdout.split('\n').filter(Boolean);
136
- const commits = run(['git', 'log', '-50', '--format=%h\t%s\t%an\t%ar'], cwd);
137
- if (commits.code === 0) out.commits = commits.stdout.split('\n').filter(Boolean);
138
- out.current = currentBranch(cwd) || '';
139
- return out;
140
- }
141
-
142
- export function nameStatus(args: string[], cwd: string): GitFileMeta[] {
143
- const res = run([
144
- 'git', '-c', 'core.quotepath=false', 'diff',
145
- '--no-color', '--no-ext-diff', '--find-renames', '--name-status', '-z',
146
- ...args,
147
- ], cwd);
148
- if (res.code !== 0) return [];
149
- const parts = res.stdout.split('\0');
150
- const files: GitFileMeta[] = [];
151
- for (let i = 0; i < parts.length;) {
152
- const status = parts[i++];
153
- if (!status) break;
154
- const kind = status[0];
155
- if (kind === 'R' || kind === 'C') {
156
- const oldPath = parts[i++] || '';
157
- const path = parts[i++] || '';
158
- if (path) files.push({ status: kind, old_path: oldPath, path, similarity: Number(status.slice(1)) || undefined });
159
- } else {
160
- const path = parts[i++] || '';
161
- if (path) files.push({ status: kind, path });
162
- }
163
- }
164
- return files;
165
- }
166
-
167
- export function numstatZ(args: string[], cwd: string): GitFileMeta[] {
168
- const res = run([
169
- 'git', '-c', 'core.quotepath=false', 'diff',
170
- '--no-color', '--no-ext-diff', '--find-renames', '--numstat', '-z',
171
- ...args,
172
- ], cwd);
173
- if (res.code !== 0) return [];
174
- const parts = res.stdout.split('\0');
175
- const files: GitFileMeta[] = [];
176
- for (let i = 0; i < parts.length;) {
177
- const rec = parts[i++];
178
- if (!rec) break;
179
- const match = rec.match(/^(\S+)\t(\S+)\t(.*)$/);
180
- if (!match) break;
181
- const [, add, del, rest] = match;
182
- const binary = add === '-' && del === '-';
183
- const additions = binary ? 0 : Number(add) || 0;
184
- const deletions = binary ? 0 : Number(del) || 0;
185
- if (rest === '') {
186
- const oldPath = parts[i++] || '';
187
- const path = parts[i++] || '';
188
- if (path) files.push({ old_path: oldPath, path, additions, deletions, binary });
189
- } else {
190
- files.push({ path: rest, additions, deletions, binary });
191
- }
192
- }
193
- return files;
194
- }
195
-
196
- export function untracked(cwd: string, path = ''): string[] {
197
- const args = ['git', 'ls-files', '--others', '--exclude-standard'];
198
- if (path) args.push('--', `${path}/`);
199
- const res = run(args, cwd);
200
- return res.code === 0 ? res.stdout.split('\n').filter(Boolean) : [];
201
- }
202
-
203
- function normalizeTreePath(path: string): string {
204
- return path.replace(/^\/+|\/+$/g, '');
205
- }
206
-
207
- function sortTreeEntries(entries: GitTreeEntry[]): GitTreeEntry[] {
208
- return [...entries].sort((a, b) => {
209
- if (a.type !== b.type) return a.type === 'tree' ? -1 : 1;
210
- return a.name.localeCompare(b.name);
211
- });
212
- }
213
-
214
- function omittedWorktreeDirectoryReason(name: string, omitDirNames: Set<string>): GitTreeEntry['children_omitted_reason'] | undefined {
215
- if (name === '.git') return 'internal';
216
- return omitDirNames.has(name) ? 'heavy' : undefined;
217
- }
218
-
219
- function worktreeEntryFromDirent(base: string, dir: string, name: string, isDirectory: boolean, omitDirNames: Set<string>): GitTreeEntry {
220
- const entryPath = base ? `${base}/${name}` : name;
221
- const type = isDirectory
222
- ? hasDotGitEntry(join(dir, name)) ? 'commit' as const : 'tree' as const
223
- : 'blob' as const;
224
- const omittedReason = type === 'tree' ? omittedWorktreeDirectoryReason(name, omitDirNames) : undefined;
225
- return omittedReason
226
- ? {
227
- name,
228
- path: entryPath,
229
- type,
230
- children_omitted: true,
231
- children_omitted_reason: omittedReason,
232
- }
233
- : { name, path: entryPath, type };
234
- }
235
-
236
- function worktreeFilesystemEntries(cwd: string, path: string, recursive: boolean, omitDirNames: string[] = DEFAULT_WORKTREE_OMIT_DIR_NAMES): GitTreeEntry[] {
237
- const base = normalizeTreePath(path);
238
- const root = join(cwd, base);
239
- const omitDirNameSet = new Set(omitDirNames);
240
- let directEntries: GitTreeEntry[];
241
- try {
242
- const dirents = readdirSync(root, { withFileTypes: true });
243
- directEntries = sortTreeEntries(dirents
244
- .map(entry => worktreeEntryFromDirent(base, root, entry.name, entry.isDirectory(), omitDirNameSet)));
245
- } catch {
246
- return [];
247
- }
248
- if (!recursive) return directEntries;
249
-
250
- const fileEntries: GitTreeEntry[] = [];
251
- let truncated = false;
252
- const pushRecursiveEntry = (entry: GitTreeEntry): boolean => {
253
- if (fileEntries.length >= WORKTREE_RECURSIVE_ENTRY_LIMIT) {
254
- if (!truncated) {
255
- fileEntries.push({
256
- name: 'more...',
257
- path: '__code_viewer_truncated__',
258
- type: 'tree',
259
- children_omitted: true,
260
- children_omitted_reason: 'truncated',
261
- });
262
- truncated = true;
263
- }
264
- return false;
265
- }
266
- fileEntries.push(entry);
267
- return true;
268
- };
269
- const walk = (dir: string, prefix: string, depth: number) => {
270
- if (truncated) return;
271
- if (depth >= WORKTREE_RECURSIVE_DEPTH_LIMIT) return;
272
- let entries;
273
- try {
274
- entries = readdirSync(dir, { withFileTypes: true });
275
- } catch {
276
- return;
277
- }
278
- for (const entry of entries) {
279
- const entryPath = prefix ? `${prefix}/${entry.name}` : entry.name;
280
- const full = join(dir, entry.name);
281
- if (entry.isDirectory()) {
282
- const omittedReason = omittedWorktreeDirectoryReason(entry.name, omitDirNameSet);
283
- if (omittedReason) {
284
- if (!pushRecursiveEntry({
285
- name: entry.name,
286
- path: entryPath,
287
- type: 'tree',
288
- children_omitted: true,
289
- children_omitted_reason: omittedReason,
290
- })) return;
291
- continue;
292
- }
293
- if (hasDotGitEntry(full)) continue;
294
- walk(full, entryPath, depth + 1);
295
- } else if (entry.isFile() || entry.isSymbolicLink()) {
296
- if (!pushRecursiveEntry({ name: entry.name, path: entryPath, type: 'blob' })) return;
297
- }
298
- }
299
- };
300
- walk(root, base, 0);
301
- return combineDirectAndRecursiveFiles(directEntries, fileEntries.sort((a, b) => a.path.localeCompare(b.path)));
302
- }
303
-
304
- function hasDotGitEntry(dir: string): boolean {
305
- try {
306
- lstatSync(join(dir, '.git'));
307
- return true;
308
- } catch (err) {
309
- return !!err && typeof err === 'object' && 'code' in err && err.code !== 'ENOENT';
310
- }
311
- }
312
-
313
- function gitTreeEntries(ref: string, path: string, cwd: string, recursive: boolean): { code: number; entries: GitTreeEntry[]; stderr: string } {
314
- const base = normalizeTreePath(path);
315
- const args = ['git', '-c', 'core.quotepath=false', 'ls-tree'];
316
- if (recursive) args.push('-r');
317
- args.push('-z', '--full-tree', ref, '--');
318
- if (base) args.push(`${base}/`);
319
- const res = run(args, cwd);
320
- if (res.code !== 0) return { code: res.code, entries: [], stderr: res.stderr };
321
- const allowedTypes = recursive ? 'blob|commit' : 'tree|blob|commit';
322
- let entries = res.stdout.split('\0').filter(Boolean).map((rec) => {
323
- const match = rec.match(new RegExp(`^\\d+\\s+(${allowedTypes})\\s+[0-9a-fA-F]+\\t(.+)$`));
324
- if (!match) return null;
325
- const entryPath = match[2];
326
- return { name: entryPath.split('/').pop() || entryPath, path: entryPath, type: match[1] as GitTreeEntry['type'] };
327
- }).filter((entry): entry is GitTreeEntry => !!entry);
328
- if (recursive) entries.sort((a, b) => a.path.localeCompare(b.path));
329
- else entries = sortTreeEntries(entries);
330
- return { code: 0, entries, stderr: '' };
331
- }
332
-
333
- function combineDirectAndRecursiveFiles(directEntries: GitTreeEntry[], fileEntries: GitTreeEntry[]): GitTreeEntry[] {
334
- const seen = new Set(directEntries.map((entry) => entry.path));
335
- return [
336
- ...directEntries,
337
- ...fileEntries.filter((entry) => !seen.has(entry.path)),
338
- ];
339
- }
340
-
341
- export function worktreeEntries(cwd: string, path: string): GitTreeEntry[] {
342
- return listTree('worktree', path, cwd).entries;
343
- }
344
-
345
- export function worktreeFiles(cwd: string): GitTreeEntry[] {
346
- return listTree('worktree', '', cwd, { recursive: true }).entries;
347
- }
348
-
349
- export function treeEntries(ref: string, path: string, cwd: string): { code: number; entries: GitTreeEntry[]; stderr: string } {
350
- return listTree(ref, path, cwd);
351
- }
352
-
353
- export function treeFiles(ref: string, cwd: string): { code: number; entries: GitTreeEntry[]; stderr: string } {
354
- return listTree(ref, '', cwd, { recursive: true });
355
- }
356
-
357
- export function listTree(
358
- ref: string,
359
- path: string,
360
- cwd: string,
361
- options: { recursive?: boolean; omitDirNames?: string[] } = {},
362
- ): { code: number; entries: GitTreeEntry[]; stderr: string } {
363
- const base = normalizeTreePath(path);
364
- if (ref === 'worktree') {
365
- return { code: 0, entries: worktreeFilesystemEntries(cwd, base, !!options.recursive, options.omitDirNames), stderr: '' };
366
- }
367
-
368
- const direct = gitTreeEntries(ref, base, cwd, false);
369
- if (direct.code !== 0 || !options.recursive) return direct;
370
- const recursive = gitTreeEntries(ref, base, cwd, true);
371
- if (recursive.code !== 0) return recursive;
372
- return { code: 0, entries: combineDirectAndRecursiveFiles(direct.entries, recursive.entries), stderr: '' };
373
- }
374
-
375
- export function untrackedMeta(cwd: string): GitFileMeta[] {
376
- return untracked(cwd).map((path) => {
377
- const full = join(cwd, path);
378
- let binary = false;
379
- let lines = 0;
380
- if (existsSync(full)) {
381
- const data = readFileSync(full);
382
- const probe = data.subarray(0, 8192);
383
- binary = probe.includes(0);
384
- if (!binary) lines = data.toString('utf8').split('\n').length - 1;
385
- }
386
- return { path, status: 'A', additions: binary ? 0 : lines, deletions: 0, binary, untracked: true };
387
- });
388
- }
389
-
390
- export function fileMeta(args: string[], cwd: string, includeUntracked = false): GitFileMeta[] {
391
- const ns = nameStatus(args, cwd);
392
- const nm = numstatZ(args, cwd);
393
- const byPath = new Map(nm.map((file) => [file.path, file]));
394
- const files: GitFileMeta[] = ns.map((file) => {
395
- const stats = byPath.get(file.path);
396
- return {
397
- ...file,
398
- additions: stats?.additions || 0,
399
- deletions: stats?.deletions || 0,
400
- binary: stats?.binary || false,
401
- };
402
- });
403
- return includeUntracked ? files.concat(untrackedMeta(cwd)) : files;
404
- }
405
-
406
- export function fileDiffText(args: string[], path: string | string[], cwd: string): { code: number; stdout: string; stderr: string } {
407
- const paths = Array.isArray(path) ? path : [path];
408
- return run([
409
- 'git', '-c', 'core.quotepath=false', 'diff',
410
- '--no-color', '--no-ext-diff', '--find-renames',
411
- ...args, '--', ...paths,
412
- ], cwd);
413
- }
414
-
415
- export function untrackedFileDiff(extras: string[], path: string, cwd: string): { code: number; stdout: string; stderr: string } {
416
- return run([
417
- 'git', '-c', 'core.quotepath=false', 'diff',
418
- '--no-color', '--no-ext-diff', '--no-index',
419
- ...extras, '/dev/null', path,
420
- ], cwd);
421
- }
422
-
423
- export function splitHunks(diffText: string): { header: string; hunks: string[] } {
424
- if (!diffText) return { header: '', hunks: [] };
425
- const first = diffText.startsWith('@@') ? 0 : diffText.indexOf('\n@@') + 1;
426
- if (first <= 0) return { header: diffText, hunks: [] };
427
- const header = diffText.slice(0, first);
428
- const hunks: string[] = [];
429
- let cur = first;
430
- while (cur < diffText.length) {
431
- const next = diffText.indexOf('\n@@', cur + 1);
432
- const end = next >= 0 ? next : diffText.length;
433
- hunks.push(diffText.slice(cur, end));
434
- if (next < 0) break;
435
- cur = next + 1;
436
- }
437
- return { header, hunks };
438
- }
439
-
440
- export function truncateToNHunks(diffText: string, n: number): {
441
- text: string;
442
- totalHunks: number;
443
- renderedHunks: number;
444
- lineCount: number;
445
- lineTruncated: boolean;
446
- };
447
- export function truncateToNHunks(diffText: string, n: number, maxLines: number): {
448
- text: string;
449
- totalHunks: number;
450
- renderedHunks: number;
451
- lineCount: number;
452
- lineTruncated: boolean;
453
- };
454
- export function truncateToNHunks(diffText: string, n: number, maxLines = Number.POSITIVE_INFINITY): {
455
- text: string;
456
- totalHunks: number;
457
- renderedHunks: number;
458
- lineCount: number;
459
- lineTruncated: boolean;
460
- } {
461
- const { header, hunks } = splitHunks(diffText);
462
- if (hunks.length === 0) {
463
- const lines = diffText.split('\n');
464
- const lineTruncated = Number.isFinite(maxLines) && lines.length > maxLines;
465
- const text = lineTruncated ? lines.slice(0, maxLines).join('\n') : diffText;
466
- return {
467
- text,
468
- totalHunks: 0,
469
- renderedHunks: 0,
470
- lineCount: (text.match(/\n/g) || []).length,
471
- lineTruncated,
472
- };
473
- }
474
- const maxHunks = Math.min(n, hunks.length);
475
- const rendered: string[] = [];
476
- let renderedHunks = 0;
477
- let usedLines = (header.match(/\n/g) || []).length;
478
- let lineTruncated = false;
479
- for (let index = 0; index < maxHunks; index++) {
480
- const hunk = hunks[index];
481
- const lines = hunk.split('\n');
482
- const separatorLines = rendered.length > 0 ? 1 : 0;
483
- const remaining = maxLines - usedLines - separatorLines;
484
- if (remaining <= 0) {
485
- lineTruncated = true;
486
- break;
487
- }
488
- if (Number.isFinite(maxLines) && lines.length > remaining) {
489
- rendered.push(lines.slice(0, remaining).join('\n'));
490
- renderedHunks++;
491
- lineTruncated = true;
492
- break;
493
- }
494
- rendered.push(hunk);
495
- renderedHunks++;
496
- usedLines += separatorLines + lines.length;
497
- }
498
- const text = header + rendered.join('\n');
499
- return {
500
- text,
501
- totalHunks: hunks.length,
502
- renderedHunks,
503
- lineCount: (text.match(/\n/g) || []).length,
504
- lineTruncated,
505
- };
506
- }