@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youtyan/code-viewer",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Local browser-based git diff viewer",
5
5
  "type": "module",
6
6
  "bin": {
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
- for (let i = 0;i < parts.length - 1; i++) {
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.classList.toggle("hidden", !anyVisible);
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.filter((entry) => entry.type !== "tree").map((entry, index) => ({
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: absolute;
718
- top: 0; right: -3px;
719
- width: 6px;
720
- height: 100%;
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
 
@@ -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 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) => {
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 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: '' };
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 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: '' };
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;