@youtyan/code-viewer 0.1.5 → 0.1.7
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/package.json +1 -1
- package/web/app.js +18 -8
- package/web-src/server/cache.ts +13 -0
- package/web-src/server/git.ts +70 -50
- package/web-src/server/preview.ts +18 -8
package/package.json
CHANGED
package/web/app.js
CHANGED
|
@@ -382,12 +382,13 @@
|
|
|
382
382
|
button.innerHTML = expanded ? iconSvg("octicon-fold", FOLD_16_PATH) : iconSvg("octicon-unfold", UNFOLD_16_PATH);
|
|
383
383
|
}
|
|
384
384
|
function buildTree(files) {
|
|
385
|
-
const root = { name: "", dirs: {}, files: [], path: "", minOrder: Infinity };
|
|
385
|
+
const root = { name: "", dirs: {}, files: [], path: "", minOrder: Infinity, explicit: true };
|
|
386
386
|
for (const f of files) {
|
|
387
387
|
const parts = f.path.split("/");
|
|
388
388
|
let node = root;
|
|
389
389
|
let acc = "";
|
|
390
|
-
|
|
390
|
+
const dirPartCount = f.type === "tree" ? parts.length : parts.length - 1;
|
|
391
|
+
for (let i = 0;i < dirPartCount; i++) {
|
|
391
392
|
const p = parts[i];
|
|
392
393
|
acc = acc ? acc + "/" + p : p;
|
|
393
394
|
if (!node.dirs[p]) {
|
|
@@ -397,11 +398,15 @@
|
|
|
397
398
|
if (typeof f.order === "number" && f.order < node.minOrder)
|
|
398
399
|
node.minOrder = f.order;
|
|
399
400
|
}
|
|
401
|
+
if (f.type === "tree") {
|
|
402
|
+
node.explicit = true;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
400
405
|
node.files.push(f);
|
|
401
406
|
}
|
|
402
407
|
function compress(node) {
|
|
403
408
|
const ks = Object.keys(node.dirs);
|
|
404
|
-
while (ks.length === 1 && node.files.length === 0 && node !== root) {
|
|
409
|
+
while (ks.length === 1 && node.files.length === 0 && !node.explicit && node !== root) {
|
|
405
410
|
const only = node.dirs[ks[0]];
|
|
406
411
|
node.name = node.name ? node.name + "/" + only.name : only.name;
|
|
407
412
|
node.dirs = only.dirs;
|
|
@@ -432,6 +437,8 @@
|
|
|
432
437
|
const li = document.createElement("li");
|
|
433
438
|
li.className = "tree-dir";
|
|
434
439
|
li.dataset.dirpath = dir.path;
|
|
440
|
+
if (dir.explicit)
|
|
441
|
+
li.dataset.explicit = "true";
|
|
435
442
|
li.style.setProperty("--lvl-pad", 12 + depth * 14 + "px");
|
|
436
443
|
const chev = document.createElement("span");
|
|
437
444
|
chev.className = "chev";
|
|
@@ -650,17 +657,19 @@
|
|
|
650
657
|
const match = matches(card.dataset.path || "");
|
|
651
658
|
card.classList.toggle("hidden-by-filter", !match);
|
|
652
659
|
});
|
|
653
|
-
updateTreeDirVisibility();
|
|
660
|
+
updateTreeDirVisibility(matches, filter.kind !== "empty" && !invalid);
|
|
654
661
|
if (typeof applyViewedState === "function")
|
|
655
662
|
applyViewedState();
|
|
656
663
|
}
|
|
657
|
-
function updateTreeDirVisibility() {
|
|
664
|
+
function updateTreeDirVisibility(dirMatches, filterActive = false) {
|
|
658
665
|
$$("#filelist .tree-dir").forEach((dir) => {
|
|
659
666
|
const childUl = dir.nextElementSibling;
|
|
660
667
|
if (!childUl || !childUl.classList.contains("tree-children"))
|
|
661
668
|
return;
|
|
662
669
|
const anyVisible = !!childUl.querySelector(".tree-file:not(.hidden):not(.hidden-by-tests)");
|
|
663
|
-
dir.
|
|
670
|
+
const explicitVisible = dir.dataset.explicit === "true" && !filterActive;
|
|
671
|
+
const selfMatches = filterActive && !!dirMatches && dirMatches(dir.dataset.dirpath || "");
|
|
672
|
+
dir.classList.toggle("hidden", !anyVisible && !explicitVisible && !selfMatches);
|
|
664
673
|
});
|
|
665
674
|
}
|
|
666
675
|
let SERVER_GENERATION = 0;
|
|
@@ -1030,10 +1039,11 @@
|
|
|
1030
1039
|
throw new Error("failed to load repository tree");
|
|
1031
1040
|
return r.json();
|
|
1032
1041
|
})).then((meta) => {
|
|
1033
|
-
const files = meta.entries.
|
|
1042
|
+
const files = meta.entries.map((entry, index) => ({
|
|
1034
1043
|
order: index + 1,
|
|
1035
1044
|
path: entry.path,
|
|
1036
|
-
display_path: entry.path
|
|
1045
|
+
display_path: entry.path,
|
|
1046
|
+
type: entry.type
|
|
1037
1047
|
}));
|
|
1038
1048
|
renderSidebar(files, (file) => {
|
|
1039
1049
|
setRoute({ screen: "file", path: file.path, ref, view: "blob", range: currentRange() });
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Short enough that a browser reload self-heals stale git data, while still
|
|
2
|
+
// coalescing bursts from one render pass.
|
|
3
|
+
export const CACHE_TTL_MS = 1500;
|
|
4
|
+
|
|
5
|
+
export type TimedCacheEntry<T> = T & { storedAt: number };
|
|
6
|
+
|
|
7
|
+
export function cacheFresh<T>(
|
|
8
|
+
cached: TimedCacheEntry<T> | undefined,
|
|
9
|
+
now = Date.now(),
|
|
10
|
+
ttlMs = CACHE_TTL_MS,
|
|
11
|
+
): cached is TimedCacheEntry<T> {
|
|
12
|
+
return !!cached && now - cached.storedAt <= ttlMs;
|
|
13
|
+
}
|
package/web-src/server/git.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
4
|
export type GitFileMeta = {
|
|
@@ -130,27 +130,72 @@ function normalizeTreePath(path: string): string {
|
|
|
130
130
|
return path.replace(/^\/+|\/+$/g, '');
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
function
|
|
134
|
-
|
|
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) => {
|
|
133
|
+
function sortTreeEntries(entries: GitTreeEntry[]): GitTreeEntry[] {
|
|
134
|
+
return [...entries].sort((a, b) => {
|
|
149
135
|
if (a.type !== b.type) return a.type === 'tree' ? -1 : 1;
|
|
150
136
|
return a.name.localeCompare(b.name);
|
|
151
137
|
});
|
|
152
138
|
}
|
|
153
139
|
|
|
140
|
+
function worktreeDirectChildren(cwd: string, path: string): GitTreeEntry[] {
|
|
141
|
+
const base = normalizeTreePath(path);
|
|
142
|
+
const dir = join(cwd, base);
|
|
143
|
+
try {
|
|
144
|
+
return sortTreeEntries(readdirSync(dir, { withFileTypes: true }).map((entry) => {
|
|
145
|
+
const entryPath = base ? `${base}/${entry.name}` : entry.name;
|
|
146
|
+
return {
|
|
147
|
+
name: entry.name,
|
|
148
|
+
path: entryPath,
|
|
149
|
+
type: entry.isDirectory() ? 'tree' as const : 'blob' as const,
|
|
150
|
+
};
|
|
151
|
+
}));
|
|
152
|
+
} catch {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function worktreeRecursiveFiles(cwd: string, path: string): GitTreeEntry[] {
|
|
158
|
+
const base = normalizeTreePath(path);
|
|
159
|
+
const trackedArgs = ['git', '-c', 'core.quotepath=false', 'ls-files', '-z'];
|
|
160
|
+
if (base) trackedArgs.push('--', `${base}/`);
|
|
161
|
+
const tracked = run(trackedArgs, cwd);
|
|
162
|
+
const paths = tracked.code === 0 ? tracked.stdout.split('\0').filter(Boolean) : [];
|
|
163
|
+
paths.push(...untracked(cwd, base));
|
|
164
|
+
return [...new Set(paths)].filter(Boolean).sort().map((entryPath) => ({
|
|
165
|
+
name: entryPath.split('/').pop() || entryPath,
|
|
166
|
+
path: entryPath,
|
|
167
|
+
type: 'blob' as const,
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function gitTreeEntries(ref: string, path: string, cwd: string, recursive: boolean): { code: number; entries: GitTreeEntry[]; stderr: string } {
|
|
172
|
+
const base = normalizeTreePath(path);
|
|
173
|
+
const args = ['git', '-c', 'core.quotepath=false', 'ls-tree'];
|
|
174
|
+
if (recursive) args.push('-r');
|
|
175
|
+
args.push('-z', '--full-tree', ref, '--');
|
|
176
|
+
if (base) args.push(`${base}/`);
|
|
177
|
+
const res = run(args, cwd);
|
|
178
|
+
if (res.code !== 0) return { code: res.code, entries: [], stderr: res.stderr };
|
|
179
|
+
const allowedTypes = recursive ? 'blob|commit' : 'tree|blob|commit';
|
|
180
|
+
let entries = res.stdout.split('\0').filter(Boolean).map((rec) => {
|
|
181
|
+
const match = rec.match(new RegExp(`^\\d+\\s+(${allowedTypes})\\s+[0-9a-fA-F]+\\t(.+)$`));
|
|
182
|
+
if (!match) return null;
|
|
183
|
+
const entryPath = match[2];
|
|
184
|
+
return { name: entryPath.split('/').pop() || entryPath, path: entryPath, type: match[1] as GitTreeEntry['type'] };
|
|
185
|
+
}).filter((entry): entry is GitTreeEntry => !!entry);
|
|
186
|
+
if (recursive) entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
187
|
+
else entries = sortTreeEntries(entries);
|
|
188
|
+
return { code: 0, entries, stderr: '' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function combineDirectAndRecursiveFiles(directEntries: GitTreeEntry[], fileEntries: GitTreeEntry[]): GitTreeEntry[] {
|
|
192
|
+
const seen = new Set(directEntries.map((entry) => entry.path));
|
|
193
|
+
return [
|
|
194
|
+
...directEntries,
|
|
195
|
+
...fileEntries.filter((entry) => !seen.has(entry.path)),
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
|
|
154
199
|
export function worktreeEntries(cwd: string, path: string): GitTreeEntry[] {
|
|
155
200
|
return listTree('worktree', path, cwd).entries;
|
|
156
201
|
}
|
|
@@ -175,41 +220,16 @@ export function listTree(
|
|
|
175
220
|
): { code: number; entries: GitTreeEntry[]; stderr: string } {
|
|
176
221
|
const base = normalizeTreePath(path);
|
|
177
222
|
if (ref === 'worktree') {
|
|
178
|
-
const
|
|
179
|
-
if (!options.recursive)
|
|
180
|
-
|
|
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: '' };
|
|
223
|
+
const directEntries = worktreeDirectChildren(cwd, base);
|
|
224
|
+
if (!options.recursive) return { code: 0, entries: directEntries, stderr: '' };
|
|
225
|
+
return { code: 0, entries: combineDirectAndRecursiveFiles(directEntries, worktreeRecursiveFiles(cwd, base)), stderr: '' };
|
|
192
226
|
}
|
|
193
227
|
|
|
194
|
-
const
|
|
195
|
-
if (options.recursive)
|
|
196
|
-
|
|
197
|
-
if (
|
|
198
|
-
|
|
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: '' };
|
|
228
|
+
const direct = gitTreeEntries(ref, base, cwd, false);
|
|
229
|
+
if (direct.code !== 0 || !options.recursive) return direct;
|
|
230
|
+
const recursive = gitTreeEntries(ref, base, cwd, true);
|
|
231
|
+
if (recursive.code !== 0) return recursive;
|
|
232
|
+
return { code: 0, entries: combineDirectAndRecursiveFiles(direct.entries, recursive.entries), stderr: '' };
|
|
213
233
|
}
|
|
214
234
|
|
|
215
235
|
export function untrackedMeta(cwd: string): GitFileMeta[] {
|
|
@@ -4,6 +4,7 @@ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
|
4
4
|
import { basename, extname, join, normalize, relative } from 'node:path';
|
|
5
5
|
import { APP_ENTRY_PATHS, SPA_PATHS } from '../routes';
|
|
6
6
|
import type { DiffMeta, FileDiffResponse, FileMeta, FileRangeResponse, RepoTreeResponse } from '../types';
|
|
7
|
+
import { cacheFresh, type TimedCacheEntry } from './cache';
|
|
7
8
|
import * as git from './git';
|
|
8
9
|
import { isSameWorktreeRange } from './range';
|
|
9
10
|
|
|
@@ -24,8 +25,8 @@ let listenPort = 0;
|
|
|
24
25
|
|
|
25
26
|
const enc = new TextEncoder();
|
|
26
27
|
const sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
|
|
27
|
-
const fileCache = new Map<string, string
|
|
28
|
-
const metaCache = new Map<string, { body: string; sig: string }
|
|
28
|
+
const fileCache = new Map<string, TimedCacheEntry<{ diffText: string }>>();
|
|
29
|
+
const metaCache = new Map<string, TimedCacheEntry<{ body: string; sig: string }>>();
|
|
29
30
|
|
|
30
31
|
function parseCli() {
|
|
31
32
|
const rest: string[] = [];
|
|
@@ -242,14 +243,14 @@ function handleDiffJson(url: URL) {
|
|
|
242
243
|
fileCache.clear();
|
|
243
244
|
}
|
|
244
245
|
const body = JSON.stringify(payload);
|
|
245
|
-
metaCache.set(key, { body, sig });
|
|
246
|
+
metaCache.set(key, { body, sig, storedAt: Date.now() });
|
|
246
247
|
return new Response(body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
|
|
247
248
|
}
|
|
248
249
|
const cached = metaCache.get(key);
|
|
249
|
-
if (cached) return new Response(cached.body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
|
|
250
|
+
if (cacheFresh(cached)) return new Response(cached.body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
|
|
250
251
|
const payload = computePayload(extras, range);
|
|
251
252
|
const body = JSON.stringify(payload);
|
|
252
|
-
metaCache.set(key, { body, sig: JSON.stringify({ ...payload, generation: undefined }) });
|
|
253
|
+
metaCache.set(key, { body, sig: JSON.stringify({ ...payload, generation: undefined }), storedAt: Date.now() });
|
|
253
254
|
return new Response(body, { headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' } });
|
|
254
255
|
}
|
|
255
256
|
|
|
@@ -262,8 +263,13 @@ function safeRepoPath(path: string) {
|
|
|
262
263
|
return path === '' || safePath(path);
|
|
263
264
|
}
|
|
264
265
|
|
|
266
|
+
function isGitInternalPath(path: string): boolean {
|
|
267
|
+
return path === '.git' || path.startsWith('.git/');
|
|
268
|
+
}
|
|
269
|
+
|
|
265
270
|
function safeWorktreePath(path: string): string | null {
|
|
266
271
|
if (!safePath(path)) return null;
|
|
272
|
+
if (isGitInternalPath(path)) return null;
|
|
267
273
|
const full = join(cwd, path);
|
|
268
274
|
if (!existsSync(full)) return null;
|
|
269
275
|
const realCwd = realpathSync(cwd);
|
|
@@ -296,6 +302,7 @@ function handleTree(url: URL) {
|
|
|
296
302
|
const target = url.searchParams.get('ref') || url.searchParams.get('target') || 'worktree';
|
|
297
303
|
const path = (url.searchParams.get('path') || '').replace(/^\/+|\/+$/g, '');
|
|
298
304
|
if (!safeRepoPath(path)) return text('invalid path', 400);
|
|
305
|
+
if ((target === 'worktree' || target === '') && isGitInternalPath(path)) return text('forbidden', 403);
|
|
299
306
|
if (target !== 'worktree' && !git.verifyTreeRef(target, cwd)) return text('invalid target', 400);
|
|
300
307
|
const recursive = url.searchParams.get('recursive') === '1';
|
|
301
308
|
const entries = git.listTree(target, path, cwd, { recursive }).entries;
|
|
@@ -337,9 +344,12 @@ function handleFileDiff(url: URL) {
|
|
|
337
344
|
const cacheKey = isUntracked
|
|
338
345
|
? `u\0${path}\0${extras.join('\0')}`
|
|
339
346
|
: `t\0${path}\0${oldPath || ''}\0${[...extras, ...args].join('\0')}`;
|
|
340
|
-
|
|
347
|
+
const cached = fileCache.get(cacheKey);
|
|
348
|
+
let diffText: string;
|
|
341
349
|
let errText = '';
|
|
342
|
-
if (
|
|
350
|
+
if (cacheFresh(cached)) {
|
|
351
|
+
diffText = cached.diffText;
|
|
352
|
+
} else {
|
|
343
353
|
if (isUntracked) {
|
|
344
354
|
diffText = git.untrackedFileDiff(extras, path, cwd).stdout || '';
|
|
345
355
|
} else {
|
|
@@ -347,7 +357,7 @@ function handleFileDiff(url: URL) {
|
|
|
347
357
|
diffText = res.stdout || '';
|
|
348
358
|
if (res.code !== 0) errText = res.stderr;
|
|
349
359
|
}
|
|
350
|
-
fileCache.set(cacheKey, diffText);
|
|
360
|
+
fileCache.set(cacheKey, { diffText, storedAt: Date.now() });
|
|
351
361
|
}
|
|
352
362
|
const mode = url.searchParams.get('mode') || 'full';
|
|
353
363
|
const truncated = mode === 'preview'
|