@youtyan/code-viewer 0.1.4 → 0.1.6
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/style.css +5 -5
- package/web-src/server/git.ts +70 -50
- package/web-src/server/preview.ts +6 -0
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() });
|
package/web/style.css
CHANGED
|
@@ -714,10 +714,11 @@ html, body {
|
|
|
714
714
|
|
|
715
715
|
/* ===== Sidebar resizer (drag right edge to resize) ===== */
|
|
716
716
|
#sidebar-resizer {
|
|
717
|
-
position:
|
|
718
|
-
top:
|
|
719
|
-
|
|
720
|
-
|
|
717
|
+
position: fixed;
|
|
718
|
+
top: var(--chrome-h);
|
|
719
|
+
bottom: 0;
|
|
720
|
+
left: calc(var(--sidebar-w) - 4px);
|
|
721
|
+
width: 8px;
|
|
721
722
|
cursor: col-resize;
|
|
722
723
|
z-index: 31;
|
|
723
724
|
background: transparent;
|
|
@@ -741,7 +742,6 @@ body.gdp-resizing #sidebar-resizer {
|
|
|
741
742
|
pointer-events: none;
|
|
742
743
|
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 25%, transparent);
|
|
743
744
|
}
|
|
744
|
-
#sidebar { position: fixed; } /* ensure relative ancestor for resizer */
|
|
745
745
|
body.gdp-resizing { cursor: col-resize !important; user-select: none; }
|
|
746
746
|
body.gdp-resizing * { user-select: none !important; }
|
|
747
747
|
|
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[] {
|
|
@@ -262,8 +262,13 @@ function safeRepoPath(path: string) {
|
|
|
262
262
|
return path === '' || safePath(path);
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
function isGitInternalPath(path: string): boolean {
|
|
266
|
+
return path === '.git' || path.startsWith('.git/');
|
|
267
|
+
}
|
|
268
|
+
|
|
265
269
|
function safeWorktreePath(path: string): string | null {
|
|
266
270
|
if (!safePath(path)) return null;
|
|
271
|
+
if (isGitInternalPath(path)) return null;
|
|
267
272
|
const full = join(cwd, path);
|
|
268
273
|
if (!existsSync(full)) return null;
|
|
269
274
|
const realCwd = realpathSync(cwd);
|
|
@@ -296,6 +301,7 @@ function handleTree(url: URL) {
|
|
|
296
301
|
const target = url.searchParams.get('ref') || url.searchParams.get('target') || 'worktree';
|
|
297
302
|
const path = (url.searchParams.get('path') || '').replace(/^\/+|\/+$/g, '');
|
|
298
303
|
if (!safeRepoPath(path)) return text('invalid path', 400);
|
|
304
|
+
if ((target === 'worktree' || target === '') && isGitInternalPath(path)) return text('forbidden', 403);
|
|
299
305
|
if (target !== 'worktree' && !git.verifyTreeRef(target, cwd)) return text('invalid target', 400);
|
|
300
306
|
const recursive = url.searchParams.get('recursive') === '1';
|
|
301
307
|
const entries = git.listTree(target, path, cwd, { recursive }).entries;
|