@youtyan/code-viewer 0.1.26 → 0.1.28

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/README.md CHANGED
@@ -80,30 +80,21 @@ the full non-virtual view.
80
80
 
81
81
  ## Uploads
82
82
 
83
- File uploads are disabled by default. Enable them only for trusted local
84
- worktrees:
85
-
86
- ```sh
87
- code-viewer --cwd /path/to/repo --allow-upload
88
- ```
83
+ File uploads are available for the local worktree target. Git tree views remain
84
+ read-only.
89
85
 
90
- Or place `.code-viewer.json` at the repository root:
86
+ Place `.code-viewer.json` at the repository root to configure repository scope
87
+ defaults:
91
88
 
92
89
  ```json
93
90
  {
94
91
  "version": 1,
95
- "upload": {
96
- "enabled": true
97
- },
98
92
  "scope": {
99
93
  "omitDirs": ["node_modules", "dist", "build"]
100
94
  }
101
95
  }
102
96
  ```
103
97
 
104
- Uploads are accepted only for the worktree target. Git tree views remain
105
- read-only.
106
-
107
98
  Repository scope settings control recursive repository browsing and search scope
108
99
  for the left tree, Ctrl+K file palette, and Ctrl+G grep palette. The in-app Scope
109
100
  Settings popover stores only a browser-local override in localStorage; edit
@@ -5,7 +5,7 @@ import {
5
5
  closeSync,
6
6
  constants,
7
7
  existsSync as existsSync3,
8
- lstatSync as lstatSync3,
8
+ lstatSync as lstatSync4,
9
9
  mkdirSync,
10
10
  openSync,
11
11
  readFileSync as readFileSync2,
@@ -17,7 +17,24 @@ import {
17
17
  writeFileSync
18
18
  } from "node:fs";
19
19
  import { homedir } from "node:os";
20
- import { basename as basename2, dirname as dirname2, extname, join as join4, relative } from "node:path";
20
+ import { basename as basename2, dirname as dirname2, extname, join as join5, relative as relative2 } from "node:path";
21
+
22
+ // web-src/directory-name.ts
23
+ function normalizeNewDirectoryName(name) {
24
+ if (typeof name !== "string")
25
+ return null;
26
+ const trimmed = name.trim();
27
+ if (!trimmed || trimmed.length > 180)
28
+ return null;
29
+ if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("\x00") || Array.from(trimmed).some((char) => {
30
+ const code = char.charCodeAt(0);
31
+ return code < 32 || code === 127;
32
+ }))
33
+ return null;
34
+ if (trimmed === "." || trimmed === ".." || trimmed.toLowerCase() === ".git")
35
+ return null;
36
+ return trimmed;
37
+ }
21
38
 
22
39
  // web-src/routes.ts
23
40
  var SPA_PATHS = ["/todif", "/todiff", "/file", "/help"];
@@ -1223,9 +1240,150 @@ function parseGitGrepOutput(stdout, ref, max, omitDirNames = [], excludeNames =
1223
1240
  return parseRgOutput(normalized, max, omitDirNames, excludeNames);
1224
1241
  }
1225
1242
 
1243
+ // web-src/server/worktree-watcher.ts
1244
+ import {
1245
+ lstatSync as lstatSync3,
1246
+ readdirSync as nodeReaddirSync,
1247
+ watch as nodeWatch
1248
+ } from "node:fs";
1249
+ import { join as join4, relative } from "node:path";
1250
+ function normalizeRelativePath(path) {
1251
+ return path.replace(/\\/g, "/").replace(/^\/+/, "");
1252
+ }
1253
+ function isInsideRoot(root, path) {
1254
+ const rel = relative(root, path).replace(/\\/g, "/");
1255
+ return rel === "" || !rel.startsWith("..") && !rel.startsWith("/");
1256
+ }
1257
+ function startWorktreeUpdateWatch(options) {
1258
+ const watch = options.watch || nodeWatch;
1259
+ const readDirs = options.readdirSync || ((path) => nodeReaddirSync(path, { withFileTypes: true }));
1260
+ const isDirectory = options.isDirectory || ((path) => {
1261
+ try {
1262
+ return lstatSync3(path).isDirectory();
1263
+ } catch {
1264
+ return false;
1265
+ }
1266
+ });
1267
+ const directorySignature = options.directorySignature || ((path) => {
1268
+ try {
1269
+ const stats = lstatSync3(path);
1270
+ if (!stats.isDirectory())
1271
+ return null;
1272
+ return `${stats.dev}:${stats.ino}`;
1273
+ } catch {
1274
+ return null;
1275
+ }
1276
+ });
1277
+ const setTimer = options.setTimeoutFn || setTimeout;
1278
+ const clearTimer = options.clearTimeoutFn || clearTimeout;
1279
+ const debounceMs = options.debounceMs ?? 250;
1280
+ const watchers = new Map;
1281
+ const signatures = new Map;
1282
+ let timer = null;
1283
+ const ignored = (path) => isSkippableSearchPath(normalizeRelativePath(path), options.omitDirNames, options.excludeNames);
1284
+ const scheduleUpdate = () => {
1285
+ if (timer)
1286
+ clearTimer(timer);
1287
+ timer = setTimer(() => {
1288
+ timer = null;
1289
+ options.onUpdate();
1290
+ }, debounceMs);
1291
+ };
1292
+ const closeSubtree = (dir) => {
1293
+ for (const [watchedDir, watcher] of [...watchers]) {
1294
+ if (watchedDir !== dir && !watchedDir.startsWith(`${dir}/`))
1295
+ continue;
1296
+ try {
1297
+ watcher.close?.();
1298
+ } catch {}
1299
+ watchers.delete(watchedDir);
1300
+ signatures.delete(watchedDir);
1301
+ }
1302
+ };
1303
+ const closeAll = () => {
1304
+ for (const watcher of [...watchers.values()]) {
1305
+ try {
1306
+ watcher.close?.();
1307
+ } catch {}
1308
+ }
1309
+ watchers.clear();
1310
+ signatures.clear();
1311
+ };
1312
+ const watchDirectory = (dir) => {
1313
+ if (watchers.has(dir))
1314
+ return;
1315
+ const rel = normalizeRelativePath(relative(options.root, dir));
1316
+ if (rel && ignored(rel))
1317
+ return;
1318
+ try {
1319
+ const watcher = watch(dir, { persistent: false }, (_event, filename) => {
1320
+ if (!filename) {
1321
+ scheduleUpdate();
1322
+ return;
1323
+ }
1324
+ const changed = normalizeRelativePath(join4(rel, filename.toString()));
1325
+ if (ignored(changed))
1326
+ return;
1327
+ const fullChangedPath = join4(options.root, changed);
1328
+ if (!isInsideRoot(options.root, fullChangedPath))
1329
+ return;
1330
+ const known = watchers.has(fullChangedPath);
1331
+ if (isDirectory(fullChangedPath)) {
1332
+ if (known) {
1333
+ const signature2 = directorySignature(fullChangedPath);
1334
+ if (signature2 && signature2 !== signatures.get(fullChangedPath)) {
1335
+ closeSubtree(fullChangedPath);
1336
+ watchDirectory(fullChangedPath);
1337
+ }
1338
+ scheduleUpdate();
1339
+ return;
1340
+ }
1341
+ watchDirectory(fullChangedPath);
1342
+ } else if (known) {
1343
+ closeSubtree(fullChangedPath);
1344
+ }
1345
+ scheduleUpdate();
1346
+ }) || {};
1347
+ watchers.set(dir, watcher);
1348
+ const signature = directorySignature(dir);
1349
+ if (signature)
1350
+ signatures.set(dir, signature);
1351
+ watcher.on?.("error", () => {
1352
+ if (watchers.get(dir) === watcher) {
1353
+ watchers.delete(dir);
1354
+ signatures.delete(dir);
1355
+ }
1356
+ });
1357
+ watcher.on?.("close", () => {
1358
+ if (watchers.get(dir) === watcher) {
1359
+ watchers.delete(dir);
1360
+ signatures.delete(dir);
1361
+ }
1362
+ });
1363
+ } catch (error) {
1364
+ options.onError?.(error);
1365
+ return;
1366
+ }
1367
+ let entries;
1368
+ try {
1369
+ entries = readDirs(dir);
1370
+ } catch (error) {
1371
+ options.onError?.(error);
1372
+ return;
1373
+ }
1374
+ for (const entry of entries) {
1375
+ if (!entry.isDirectory())
1376
+ continue;
1377
+ watchDirectory(join4(dir, entry.name));
1378
+ }
1379
+ };
1380
+ watchDirectory(options.root);
1381
+ return { started: watchers.size > 0, close: closeAll };
1382
+ }
1383
+
1226
1384
  // web-src/server/preview.ts
1227
- var WEB_ROOT = join4(ROOT, "web");
1228
- var VERSION = JSON.parse(readFileSync2(join4(ROOT, "package.json"), "utf8")).version;
1385
+ var WEB_ROOT = join5(ROOT, "web");
1386
+ var VERSION = JSON.parse(readFileSync2(join5(ROOT, "package.json"), "utf8")).version;
1229
1387
  var DEFAULT_ARGS = ["HEAD"];
1230
1388
  var PREVIEW_HUNKS_DEFAULT = 3;
1231
1389
  var PREVIEW_LINES_DEFAULT = 1200;
@@ -1236,8 +1394,8 @@ var SIZE_LARGE = 20000;
1236
1394
  var LINE_INDEX_MIN_START = 1e4;
1237
1395
  var LINE_INDEX_MAX_FILE_BYTES = 256 * 1024 * 1024;
1238
1396
  var BLOB_LINE_CACHE_MAX_BYTES = 128 * 1024 * 1024;
1239
- var MAX_UPLOAD_FILE_BYTES = 10 * 1024 * 1024;
1240
- var MAX_UPLOAD_TOTAL_BYTES = 50 * 1024 * 1024;
1397
+ var MAX_UPLOAD_FILE_BYTES = 512 * 1024 * 1024;
1398
+ var MAX_UPLOAD_TOTAL_BYTES = 1024 * 1024 * 1024;
1241
1399
  var MAX_UPLOAD_BODY_BYTES = MAX_UPLOAD_TOTAL_BYTES + 1024 * 1024;
1242
1400
  var MAX_UPLOAD_FILES = 50;
1243
1401
  var SAFE_UPLOAD_EXTENSIONS = new Set([
@@ -1255,7 +1413,19 @@ var SAFE_UPLOAD_EXTENSIONS = new Set([
1255
1413
  ".jpeg",
1256
1414
  ".gif",
1257
1415
  ".webp",
1416
+ ".svg",
1258
1417
  ".pdf",
1418
+ ".mp4",
1419
+ ".mov",
1420
+ ".m4v",
1421
+ ".webm",
1422
+ ".mp3",
1423
+ ".wav",
1424
+ ".m4a",
1425
+ ".aac",
1426
+ ".flac",
1427
+ ".ogg",
1428
+ ".zip",
1259
1429
  ".ts",
1260
1430
  ".tsx",
1261
1431
  ".js",
@@ -1268,12 +1438,11 @@ var generation = 1;
1268
1438
  var cwd = repoRoot(process.cwd()) || process.cwd();
1269
1439
  var cliArgs = DEFAULT_ARGS;
1270
1440
  var listenPort = 0;
1271
- var allowUpload = false;
1272
- var uploadAllowedByCli = false;
1273
1441
  var openAfterStart = false;
1274
1442
  var scopeOmitDirNames = DEFAULT_WORKTREE_OMIT_DIR_NAMES;
1275
1443
  var scopeOmitDirCliOverride = null;
1276
1444
  var scopeExcludeNames = DEFAULT_EXCLUDE_NAMES;
1445
+ var uploadDisabledByConfig = false;
1277
1446
  var rgAvailableCache = null;
1278
1447
  var enc = new TextEncoder;
1279
1448
  var sseClients = new Set;
@@ -1326,10 +1495,7 @@ Examples:
1326
1495
  listenPort = parsed;
1327
1496
  } else if (arg === "--open") {
1328
1497
  openAfterStart = true;
1329
- } else if (arg === "--allow-upload") {
1330
- allowUpload = true;
1331
- uploadAllowedByCli = true;
1332
- } else if (arg === "--scope-omit-dir") {
1498
+ } else if (arg === "--allow-upload") {} else if (arg === "--scope-omit-dir") {
1333
1499
  const next = process.argv[++i];
1334
1500
  if (!next) {
1335
1501
  console.error("--scope-omit-dir requires a directory name");
@@ -1345,10 +1511,9 @@ Examples:
1345
1511
  }
1346
1512
  if (rest.length)
1347
1513
  cliArgs = rest;
1348
- if (!uploadAllowedByCli)
1349
- allowUpload = loadProjectConfigUploadEnabled();
1350
1514
  const configScopeOmitDirs = loadProjectConfigScopeOmitDirs();
1351
1515
  const configScopeExcludeNames = loadProjectConfigScopeExcludeNames();
1516
+ uploadDisabledByConfig = loadProjectConfigUploadDisabled();
1352
1517
  if (scopeOmitDirCliOverride) {
1353
1518
  scopeOmitDirNames = scopeOmitDirCliOverride;
1354
1519
  } else if (configScopeOmitDirs) {
@@ -1424,7 +1589,7 @@ function staticFile(pathname) {
1424
1589
  const spec = map[pathname];
1425
1590
  if (!spec)
1426
1591
  return null;
1427
- const full = join4(WEB_ROOT, spec[0]);
1592
+ const full = join5(WEB_ROOT, spec[0]);
1428
1593
  if (!existsSync3(full))
1429
1594
  return text("not found", 404);
1430
1595
  return new Response(readFileSync2(full), {
@@ -1658,7 +1823,7 @@ function parseScopeExcludeNamesQuery(value) {
1658
1823
  return normalizeScopeExcludeNames(names);
1659
1824
  }
1660
1825
  function loadProjectConfig() {
1661
- const full = join4(cwd, ".code-viewer.json");
1826
+ const full = join5(cwd, ".code-viewer.json");
1662
1827
  if (!existsSync3(full))
1663
1828
  return null;
1664
1829
  let realCwd;
@@ -1680,9 +1845,9 @@ function loadProjectConfig() {
1680
1845
  return null;
1681
1846
  }
1682
1847
  }
1683
- function loadProjectConfigUploadEnabled() {
1848
+ function loadProjectConfigUploadDisabled() {
1684
1849
  const config = loadProjectConfig();
1685
- return config?.upload?.enabled === true;
1850
+ return config?.upload?.enabled === false;
1686
1851
  }
1687
1852
  function loadProjectConfigScopeOmitDirs() {
1688
1853
  const config = loadProjectConfig();
@@ -1723,7 +1888,7 @@ function safeWorktreePath(path) {
1723
1888
  return null;
1724
1889
  if (isGitInternalPath(path))
1725
1890
  return null;
1726
- const full = join4(cwd, path);
1891
+ const full = join5(cwd, path);
1727
1892
  if (!existsSync3(full))
1728
1893
  return null;
1729
1894
  let realCwd;
@@ -1734,7 +1899,7 @@ function safeWorktreePath(path) {
1734
1899
  } catch {
1735
1900
  return null;
1736
1901
  }
1737
- const rel = relative(realCwd, realFull);
1902
+ const rel = relative2(realCwd, realFull);
1738
1903
  if (rel === "" || rel.startsWith("..") || rel.startsWith("/") || rel.startsWith("\\"))
1739
1904
  return null;
1740
1905
  if (isGitInternalPath(rel))
@@ -1742,7 +1907,7 @@ function safeWorktreePath(path) {
1742
1907
  return realFull;
1743
1908
  }
1744
1909
  function worktreePath(path) {
1745
- return join4(cwd, path);
1910
+ return join5(cwd, path);
1746
1911
  }
1747
1912
  function safeOpenWorktreePath(path) {
1748
1913
  if (path === "") {
@@ -1873,7 +2038,7 @@ function handleTree(url) {
1873
2038
  branch: currentBranch(cwd) || undefined,
1874
2039
  entries: recursive ? entries : entries.map((entry) => attachTreeEntryMetadata(target, entry)),
1875
2040
  readme: readReadme(target, path),
1876
- upload_enabled: allowUpload && (target === "worktree" || target === "")
2041
+ upload_enabled: !uploadDisabledByConfig && (target === "worktree" || target === "")
1877
2042
  });
1878
2043
  }
1879
2044
  function handleSettings() {
@@ -1939,7 +2104,7 @@ function grepWorktreeFallback(query, max, paths, omitDirNames, excludeNames) {
1939
2104
  continue;
1940
2105
  let stat;
1941
2106
  try {
1942
- stat = lstatSync3(full);
2107
+ stat = lstatSync4(full);
1943
2108
  } catch {
1944
2109
  continue;
1945
2110
  }
@@ -2430,24 +2595,26 @@ function isForbiddenUploadName(name) {
2430
2595
  return lower.startsWith(".") || lower === "package.json" || lower === "package-lock.json" || lower === "bun.lock" || lower === "bun.lockb" || lower === "yarn.lock" || lower === "pnpm-lock.yaml" || lower === "makefile" || lower === "dockerfile" || lower.endsWith(".dockerfile") || /^(tsconfig|jsconfig|bunfig|vercel|netlify|wrangler|next|vite|webpack|rollup|esbuild|astro|svelte|tailwind|postcss|babel|prettier|eslint)\./.test(lower) || lower.endsWith(".config.js") || lower.endsWith(".config.jsx") || lower.endsWith(".config.ts") || lower.endsWith(".config.tsx") || lower.endsWith(".config.mjs") || lower.endsWith(".config.cjs") || lower.includes("credential") || lower.includes("secret") || lower.endsWith(".exe") || lower.endsWith(".dll") || lower.endsWith(".dylib") || lower.endsWith(".so") || lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh") || lower.endsWith(".fish") || lower.endsWith(".ps1") || lower.endsWith(".bat") || lower.endsWith(".cmd");
2431
2596
  }
2432
2597
  function safeUploadFileName(name) {
2433
- if (!name || name.includes("\x00") || name.includes("/") || name.includes("\\"))
2598
+ const trimmed = name.trim();
2599
+ if (!trimmed || trimmed.length > 180 || trimmed.includes("\x00") || trimmed.includes("/") || trimmed.includes("\\") || Array.from(trimmed).some((char) => {
2600
+ const code = char.charCodeAt(0);
2601
+ return code < 32 || code === 127;
2602
+ }))
2434
2603
  return null;
2435
- if (name === "." || name === "..")
2604
+ if (trimmed === "." || trimmed === "..")
2436
2605
  return null;
2437
- if (!/^[A-Za-z0-9][A-Za-z0-9._ -]{0,180}$/.test(name))
2606
+ if (isGitInternalPath(trimmed) || isForbiddenUploadName(trimmed))
2438
2607
  return null;
2439
- if (isGitInternalPath(name) || isForbiddenUploadName(name))
2608
+ if (!SAFE_UPLOAD_EXTENSIONS.has(extname(trimmed).toLowerCase()))
2440
2609
  return null;
2441
- if (!SAFE_UPLOAD_EXTENSIONS.has(extname(name).toLowerCase()))
2442
- return null;
2443
- return name;
2610
+ return trimmed;
2444
2611
  }
2445
2612
  function uploadOpenFlags() {
2446
2613
  return constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0);
2447
2614
  }
2448
2615
  async function handleUploadFiles(req) {
2449
- if (!allowUpload)
2450
- return text("upload disabled", 403);
2616
+ if (uploadDisabledByConfig)
2617
+ return text("upload disabled by project config", 403);
2451
2618
  if (req.method !== "POST")
2452
2619
  return text("method not allowed", 405);
2453
2620
  if (!sideEffectRequestAllowed(req))
@@ -2503,8 +2670,8 @@ async function handleUploadFiles(req) {
2503
2670
  total += file.size;
2504
2671
  if (total > MAX_UPLOAD_TOTAL_BYTES)
2505
2672
  return text("upload too large", 413);
2506
- const target = join4(realDir, safeName);
2507
- if (relative(realDir, dirname2(target)) !== "")
2673
+ const target = join5(realDir, safeName);
2674
+ if (relative2(realDir, dirname2(target)) !== "")
2508
2675
  return text("invalid filename", 400);
2509
2676
  if (existsSync3(target))
2510
2677
  return text("file exists", 409);
@@ -2531,10 +2698,7 @@ async function handleUploadFiles(req) {
2531
2698
  return text("file exists", 409);
2532
2699
  return text("upload failed", 500);
2533
2700
  }
2534
- generation++;
2535
- fileCache.clear();
2536
- metaCache.clear();
2537
- sendSse("update");
2701
+ triggerUpdate();
2538
2702
  return json({
2539
2703
  ok: true,
2540
2704
  files: uploads.map((upload) => upload.name),
@@ -2621,10 +2785,15 @@ function clearMutableCaches() {
2621
2785
  metaCache.clear();
2622
2786
  fileListCache.clear();
2623
2787
  }
2788
+ function triggerUpdate() {
2789
+ generation++;
2790
+ clearMutableCaches();
2791
+ sendSse("update");
2792
+ }
2624
2793
  function moveMacPathIntoTrash(path) {
2625
- const trashDir = join4(homedir(), ".Trash");
2794
+ const trashDir = join5(homedir(), ".Trash");
2626
2795
  const base = basename2(path) || "code-viewer-trash-item";
2627
- const target = join4(trashDir, `${base}-${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`);
2796
+ const target = join5(trashDir, `${base}-${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`);
2628
2797
  try {
2629
2798
  mkdirSync(trashDir, { recursive: true });
2630
2799
  renameSync(path, target);
@@ -2634,7 +2803,7 @@ function moveMacPathIntoTrash(path) {
2634
2803
  }
2635
2804
  }
2636
2805
  function movePathToTrash(path) {
2637
- lstatSync3(path);
2806
+ lstatSync4(path);
2638
2807
  if (process.platform === "darwin") {
2639
2808
  return moveMacPathIntoTrash(path);
2640
2809
  }
@@ -2666,8 +2835,8 @@ function restoreTrashPath(originalPath, trashPath) {
2666
2835
  if (!existsSync3(trashPath))
2667
2836
  return { ok: false, error: "trash item not found" };
2668
2837
  try {
2669
- const trashRoot = join4(homedir(), ".Trash");
2670
- const trashRelative = relative(trashRoot, trashPath);
2838
+ const trashRoot = join5(homedir(), ".Trash");
2839
+ const trashRelative = relative2(trashRoot, trashPath);
2671
2840
  if (trashRelative === "" || trashRelative.startsWith("..") || trashRelative.startsWith("/") || trashRelative.startsWith("\\"))
2672
2841
  return { ok: false, error: "invalid trash handle" };
2673
2842
  mkdirSync(dirname2(original), { recursive: true });
@@ -2773,11 +2942,62 @@ async function handleTrashPath(req) {
2773
2942
  trashPath: moved.trashPath
2774
2943
  }
2775
2944
  };
2776
- generation++;
2777
- clearMutableCaches();
2778
- sendSse("update");
2945
+ triggerUpdate();
2779
2946
  return json({ ok: true, generation, undo });
2780
2947
  }
2948
+ async function handleCreateDirectory(req) {
2949
+ if (req.method !== "POST")
2950
+ return text("method not allowed", 405);
2951
+ if (!sideEffectRequestAllowed(req))
2952
+ return text("forbidden", 403);
2953
+ const contentType = req.headers.get("content-type") || "";
2954
+ if (!/^application\/json(?:;|$)/i.test(contentType))
2955
+ return text("unsupported media type", 415);
2956
+ const lengthHeader = req.headers.get("content-length");
2957
+ const length = Number(lengthHeader || "0");
2958
+ if (lengthHeader && (!Number.isFinite(length) || length < 0))
2959
+ return text("invalid content length", 400);
2960
+ if (length > 2048)
2961
+ return text("payload too large", 413);
2962
+ let body = {};
2963
+ try {
2964
+ const raw = await req.text();
2965
+ if (raw.length > 2048)
2966
+ return text("payload too large", 413);
2967
+ body = JSON.parse(raw);
2968
+ } catch {
2969
+ return text("invalid json", 400);
2970
+ }
2971
+ const dir = typeof body.dir === "string" ? body.dir.trim().replace(/^\/+|\/+$/g, "") : "";
2972
+ const name = normalizeNewDirectoryName(body.name);
2973
+ if (!safeRepoPath(dir))
2974
+ return text("invalid dir", 400);
2975
+ if (dir && isGitInternalPath(dir))
2976
+ return text("forbidden", 403);
2977
+ if (!name)
2978
+ return text("invalid name", 400);
2979
+ const parent = safeOpenWorktreePath(dir);
2980
+ if (!parent)
2981
+ return text("not found", 404);
2982
+ const stats = statSync(parent);
2983
+ if (!stats.isDirectory())
2984
+ return text("not a directory", 400);
2985
+ const targetPath = dir ? `${dir}/${name}` : name;
2986
+ if (!safeRepoPath(targetPath) || isGitInternalPath(targetPath))
2987
+ return text("invalid target", 400);
2988
+ const target = join5(parent, name);
2989
+ if (existsSync3(target))
2990
+ return text("already exists", 409);
2991
+ try {
2992
+ mkdirSync(target, { recursive: false });
2993
+ } catch (error) {
2994
+ if (error.code === "EEXIST")
2995
+ return text("already exists", 409);
2996
+ return text("create failed", 500);
2997
+ }
2998
+ triggerUpdate();
2999
+ return json({ ok: true, path: targetPath, generation });
3000
+ }
2781
3001
  async function handleRestoreTrash(req) {
2782
3002
  if (req.method !== "POST")
2783
3003
  return text("method not allowed", 405);
@@ -2807,9 +3027,7 @@ async function handleRestoreTrash(req) {
2807
3027
  const restored = restoreTrashPath(originalPath, trashPath || undefined);
2808
3028
  if (!restored.ok)
2809
3029
  return text(restored.error || "undo failed", 409);
2810
- generation++;
2811
- clearMutableCaches();
2812
- sendSse("update");
3030
+ triggerUpdate();
2813
3031
  return json({ ok: true, generation });
2814
3032
  }
2815
3033
  function sendSse(event, data = "tick") {
@@ -2864,6 +3082,8 @@ var server = await startServer({
2864
3082
  return handleTrashPath(req);
2865
3083
  if (url.pathname === "/_restore_trash")
2866
3084
  return handleRestoreTrash(req);
3085
+ if (url.pathname === "/_create_directory")
3086
+ return handleCreateDirectory(req);
2867
3087
  if (url.pathname === "/_upload_files")
2868
3088
  return handleUploadFiles(req);
2869
3089
  if (url.pathname === "/_refs")
@@ -2871,9 +3091,7 @@ var server = await startServer({
2871
3091
  if (url.pathname === "/refresh" && req.method === "POST") {
2872
3092
  if (!sideEffectRequestAllowed(req))
2873
3093
  return text("forbidden", 403);
2874
- generation++;
2875
- clearMutableCaches();
2876
- sendSse("update");
3094
+ triggerUpdate();
2877
3095
  return json({ ok: true, generation });
2878
3096
  }
2879
3097
  if (url.pathname === "/events") {
@@ -2924,5 +3142,16 @@ startDevAssetReload({
2924
3142
  watch,
2925
3143
  sendReload: () => sendSse("reload")
2926
3144
  });
3145
+ startWorktreeUpdateWatch({
3146
+ root: cwd,
3147
+ omitDirNames: scopeOmitDirNames,
3148
+ excludeNames: scopeExcludeNames,
3149
+ watch,
3150
+ onUpdate: triggerUpdate,
3151
+ onError: (error) => {
3152
+ const message = error instanceof Error ? error.message : String(error);
3153
+ console.warn(`code-viewer worktree watch skipped: ${message}`);
3154
+ }
3155
+ });
2927
3156
  console.log(`GDP_LISTEN_URL=http://127.0.0.1:${server.port}/`);
2928
3157
  console.log(`git-diff-preview serving ${cwd}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youtyan/code-viewer",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "description": "Local browser-based code and git diff viewer",
5
5
  "type": "module",
6
6
  "bin": {
package/web/app.js CHANGED
@@ -36,6 +36,23 @@
36
36
  };
37
37
  }
38
38
 
39
+ // web-src/directory-name.ts
40
+ function normalizeNewDirectoryName(name) {
41
+ if (typeof name !== "string")
42
+ return null;
43
+ const trimmed = name.trim();
44
+ if (!trimmed || trimmed.length > 180)
45
+ return null;
46
+ if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("\x00") || Array.from(trimmed).some((char) => {
47
+ const code = char.charCodeAt(0);
48
+ return code < 32 || code === 127;
49
+ }))
50
+ return null;
51
+ if (trimmed === "." || trimmed === ".." || trimmed.toLowerCase() === ".git")
52
+ return null;
53
+ return trimmed;
54
+ }
55
+
39
56
  // web-src/expand-logic.ts
40
57
  function initExpandState(prevHunkEndNew, hunkNewStart) {
41
58
  return {
@@ -145,6 +162,12 @@
145
162
  function filePathClipboardText(path) {
146
163
  return path || "";
147
164
  }
165
+ function fileNameClipboardText(path) {
166
+ if (!path)
167
+ return "";
168
+ const parts = path.split("/").filter(Boolean);
169
+ return parts[parts.length - 1] || "";
170
+ }
148
171
 
149
172
  // web-src/focus-scope.ts
150
173
  function isEditableKeyTarget(target) {
@@ -6578,8 +6601,8 @@ ${frontmatter.yaml}
6578
6601
  if (!section)
6579
6602
  return;
6580
6603
  e2.preventDefault();
6581
- section.scrollIntoView({ block: "start", behavior: "smooth" });
6582
6604
  history.replaceState(history.state, "", `#${encodeURIComponent(section.id)}`);
6605
+ scrollMarkdownSectionIntoView(section, "smooth");
6583
6606
  });
6584
6607
  const controller = new AbortController;
6585
6608
  const scrollRoot = document.scrollingElement || document.documentElement;
@@ -6596,8 +6619,9 @@ ${frontmatter.yaml}
6596
6619
  return;
6597
6620
  }
6598
6621
  let active = entries[0];
6622
+ const activeThreshold = markdownAnchorOffset() + 40;
6599
6623
  for (const entry of entries) {
6600
- if (entry.target.getBoundingClientRect().top <= 96)
6624
+ if (entry.target.getBoundingClientRect().top <= activeThreshold)
6601
6625
  active = entry;
6602
6626
  else
6603
6627
  break;
@@ -6622,9 +6646,43 @@ ${frontmatter.yaml}
6622
6646
  setTimeout(() => {
6623
6647
  if (!root.isConnected)
6624
6648
  return;
6649
+ scrollInitialMarkdownHash(root);
6625
6650
  update();
6626
6651
  }, 0);
6627
6652
  }
6653
+ function scrollInitialMarkdownHash(root) {
6654
+ if (!location.hash)
6655
+ return;
6656
+ const id = decodeHashFragment(location.hash);
6657
+ const section = root.querySelector(`#${CSS.escape(id)}`);
6658
+ if (!section)
6659
+ return;
6660
+ scrollMarkdownSectionIntoView(section, "auto");
6661
+ }
6662
+ function decodeHashFragment(hash) {
6663
+ const value = hash.startsWith("#") ? hash.slice(1) : hash;
6664
+ try {
6665
+ return decodeURIComponent(value);
6666
+ } catch {
6667
+ return value;
6668
+ }
6669
+ }
6670
+ function scrollMarkdownSectionIntoView(section, behavior) {
6671
+ const top = section.getBoundingClientRect().top + window.scrollY - markdownAnchorOffset() - 12;
6672
+ window.scrollTo({ top: Math.max(0, top), behavior });
6673
+ }
6674
+ function markdownAnchorOffset() {
6675
+ const bottoms = Array.from(document.querySelectorAll("#global-header, .gdp-file-detail-sticky")).map((element) => {
6676
+ const style = getComputedStyle(element);
6677
+ if (style.display === "none" || style.visibility === "hidden")
6678
+ return 0;
6679
+ const rect = element.getBoundingClientRect();
6680
+ if (rect.height <= 0)
6681
+ return 0;
6682
+ return rect.bottom > 0 ? rect.bottom : 0;
6683
+ }).filter((bottom) => Number.isFinite(bottom));
6684
+ return Math.max(0, ...bottoms);
6685
+ }
6628
6686
  function keepTocLinkVisible(toc, link2) {
6629
6687
  if (toc.scrollHeight <= toc.clientHeight)
6630
6688
  return;
@@ -6894,6 +6952,8 @@ ${frontmatter.yaml}
6894
6952
  ];
6895
6953
  const FILE_16_PATH = "M2 1.75C2 .784 2.784 0 3.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 12.25 16h-8.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 8 4.25V1.5Zm5.75.062V4.25c0 .138.112.25.25.25h2.688Z";
6896
6954
  const OPEN_EXTERNAL_16_PATH = "M3.75 2A1.75 1.75 0 0 0 2 3.75v8.5C2 13.216 2.784 14 3.75 14h8.5A1.75 1.75 0 0 0 14 12.25v-3.5a.75.75 0 0 0-1.5 0v3.5a.25.25 0 0 1-.25.25h-8.5a.25.25 0 0 1-.25-.25v-8.5a.25.25 0 0 1 .25-.25h3.5a.75.75 0 0 0 0-1.5h-3.5Zm6.5 0a.75.75 0 0 0 0 1.5h1.19L7.72 7.22a.749.749 0 1 0 1.06 1.06l3.72-3.72v1.19a.75.75 0 0 0 1.5 0v-3A.75.75 0 0 0 13.25 2h-3Z";
6955
+ const PLUS_16_PATH = "M7.75 2a.75.75 0 0 1 .75.75V7.5h4.75a.75.75 0 0 1 0 1.5H8.5v4.75a.75.75 0 0 1-1.5 0V9H2.25a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z";
6956
+ const TRASH_16_PATH = "M6.5 1.75A1.75 1.75 0 0 1 8.25 0h1.5A1.75 1.75 0 0 1 11.5 1.75V2h3.75a.75.75 0 0 1 0 1.5h-.75v10.75A1.75 1.75 0 0 1 12.75 16h-9.5A1.75 1.75 0 0 1 1.5 14.25V3.5H.75a.75.75 0 0 1 0-1.5H4.5v-.25ZM6 2h4v-.25a.25.25 0 0 0-.25-.25h-1.5a.25.25 0 0 0-.25.25V2Zm-3 1.5v10.75c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V3.5Zm3 2.25a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0v-5.5A.75.75 0 0 1 6 5.75Zm4 0a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0v-5.5A.75.75 0 0 1 10 5.75Z";
6897
6957
  const GIT_BRANCH_16_PATH = "M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z";
6898
6958
  const TRIANGLE_DOWN_16_PATH = "m4.427 7.427 3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 7H4.604a.25.25 0 0 0-.177.427Z";
6899
6959
  const SIDEBAR_SHOW_16_PATHS = [
@@ -6931,6 +6991,7 @@ ${frontmatter.yaml}
6931
6991
  let sourceShikiLoadPromise = null;
6932
6992
  let highlightConfigured = false;
6933
6993
  let PROJECT_NAME = "";
6994
+ let creatingDirectory = false;
6934
6995
  let REPO_SIDEBAR_REF = null;
6935
6996
  let REPO_SIDEBAR_LOAD_REF = null;
6936
6997
  let REPO_SIDEBAR_LOAD = null;
@@ -7276,6 +7337,11 @@ ${frontmatter.yaml}
7276
7337
  return;
7277
7338
  PROJECT_NAME = project;
7278
7339
  document.title = `${project} - code viewer`;
7340
+ const projectTitle = document.querySelector("#project-title");
7341
+ if (projectTitle) {
7342
+ projectTitle.textContent = project;
7343
+ projectTitle.title = project;
7344
+ }
7279
7345
  }
7280
7346
  function savedScopeOmitDirs() {
7281
7347
  const raw = localStorage.getItem(scopeOmitDirsStorageKey());
@@ -7850,6 +7916,7 @@ ${frontmatter.yaml}
7850
7916
  li.className = "tree-dir";
7851
7917
  li.tabIndex = -1;
7852
7918
  li.dataset.dirpath = dir.path;
7919
+ li.dataset.type = "tree";
7853
7920
  if (dir.children_omitted_reason)
7854
7921
  li.dataset.childrenOmittedReason = dir.children_omitted_reason;
7855
7922
  if (dir.explicit)
@@ -7937,6 +8004,7 @@ ${frontmatter.yaml}
7937
8004
  li.className = "tree-file";
7938
8005
  li.tabIndex = -1;
7939
8006
  li.dataset.path = f2.path;
8007
+ li.dataset.type = "blob";
7940
8008
  li.classList.toggle("viewed", !onFileClick && STATE.viewedFiles.has(f2.path));
7941
8009
  li.style.setProperty("--lvl-pad", `${12 + depth * 14}px`);
7942
8010
  const spacer = document.createElement("span");
@@ -7995,6 +8063,7 @@ ${frontmatter.yaml}
7995
8063
  li.className = "tree-dir";
7996
8064
  li.tabIndex = -1;
7997
8065
  li.dataset.dirpath = dir.path;
8066
+ li.dataset.type = "tree";
7998
8067
  if (dir.children_omitted_reason)
7999
8068
  li.dataset.childrenOmittedReason = dir.children_omitted_reason;
8000
8069
  if (dir.explicit)
@@ -8077,6 +8146,7 @@ ${frontmatter.yaml}
8077
8146
  li.className = "tree-file";
8078
8147
  li.tabIndex = -1;
8079
8148
  li.dataset.path = f2.path;
8149
+ li.dataset.type = "blob";
8080
8150
  li.classList.toggle("viewed", !onFileClick && STATE.viewedFiles.has(f2.path));
8081
8151
  li.classList.toggle("hidden-by-tests", STATE.hideTests && TEST_RE.test(f2.path || ""));
8082
8152
  li.style.setProperty("--lvl-pad", `${12 + depth * 14}px`);
@@ -9084,6 +9154,102 @@ ${frontmatter.yaml}
9084
9154
  createTrashDialog("Trash failed", message, [ok]);
9085
9155
  ok.focus();
9086
9156
  }
9157
+ function showCreateDirectoryError(message) {
9158
+ const ok = document.createElement("button");
9159
+ ok.type = "button";
9160
+ ok.className = "gdp-btn gdp-btn-sm";
9161
+ ok.textContent = "OK";
9162
+ ok.addEventListener("click", closeTrashDialog);
9163
+ createTrashDialog("New folder failed", message, [ok]);
9164
+ ok.focus();
9165
+ }
9166
+ function askNewDirectoryName(path, focusReturnTarget) {
9167
+ return new Promise((resolve) => {
9168
+ const previousFocus = focusReturnTarget || document.activeElement;
9169
+ const cancel = document.createElement("button");
9170
+ cancel.type = "button";
9171
+ cancel.className = "gdp-btn gdp-btn-sm";
9172
+ cancel.textContent = "Cancel";
9173
+ const create = document.createElement("button");
9174
+ create.type = "button";
9175
+ create.className = "gdp-btn gdp-btn-sm";
9176
+ create.textContent = "Create";
9177
+ const input = document.createElement("input");
9178
+ input.className = "gdp-create-dir-input";
9179
+ input.type = "text";
9180
+ input.autocomplete = "off";
9181
+ input.placeholder = "Folder name";
9182
+ input.setAttribute("aria-label", "Folder name");
9183
+ const error2 = document.createElement("div");
9184
+ error2.className = "gdp-create-dir-error";
9185
+ error2.setAttribute("role", "alert");
9186
+ const syncValidity = () => {
9187
+ const valid = !!normalizeNewDirectoryName(input.value);
9188
+ create.disabled = !valid;
9189
+ error2.textContent = input.value && !valid ? "Use a folder name without slashes, control characters, . or .." : "";
9190
+ return valid;
9191
+ };
9192
+ const done = (name) => {
9193
+ document.removeEventListener("keydown", onKeydown);
9194
+ closeTrashDialog();
9195
+ previousFocus?.focus?.();
9196
+ resolve(name);
9197
+ };
9198
+ const submit = () => {
9199
+ const name = normalizeNewDirectoryName(input.value);
9200
+ if (!name) {
9201
+ syncValidity();
9202
+ input.focus();
9203
+ return;
9204
+ }
9205
+ done(name);
9206
+ };
9207
+ const onKeydown = (event) => {
9208
+ if (event.key === "Escape") {
9209
+ event.preventDefault();
9210
+ event.stopPropagation();
9211
+ done(null);
9212
+ return;
9213
+ }
9214
+ if (event.isComposing || event.keyCode === 229)
9215
+ return;
9216
+ if (event.key === "Enter") {
9217
+ event.preventDefault();
9218
+ submit();
9219
+ return;
9220
+ }
9221
+ if (event.key !== "Tab")
9222
+ return;
9223
+ const focusables = [input, cancel, create];
9224
+ const index = focusables.indexOf(document.activeElement);
9225
+ if (index < 0) {
9226
+ event.preventDefault();
9227
+ focusables[0].focus();
9228
+ return;
9229
+ }
9230
+ if (event.shiftKey && index <= 0) {
9231
+ event.preventDefault();
9232
+ focusables[focusables.length - 1].focus();
9233
+ } else if (!event.shiftKey && index === focusables.length - 1) {
9234
+ event.preventDefault();
9235
+ focusables[0].focus();
9236
+ }
9237
+ };
9238
+ cancel.addEventListener("click", () => done(null));
9239
+ create.addEventListener("click", submit);
9240
+ input.addEventListener("input", syncValidity);
9241
+ create.disabled = true;
9242
+ const backdrop = createTrashDialog("New Folder", `Create a folder in "${path || PROJECT_NAME || "repository"}".`, [cancel, create]);
9243
+ const body = backdrop.querySelector(".gdp-trash-dialog-body");
9244
+ body?.append(input, error2);
9245
+ backdrop.addEventListener("pointerdown", (event) => {
9246
+ if (event.target === backdrop)
9247
+ done(null);
9248
+ });
9249
+ document.addEventListener("keydown", onKeydown);
9250
+ input.focus();
9251
+ });
9252
+ }
9087
9253
  async function moveRepoPathToTrash(path) {
9088
9254
  const res = await fetch("/_trash_path", {
9089
9255
  method: "POST",
@@ -9102,6 +9268,32 @@ ${frontmatter.yaml}
9102
9268
  UNDO_STACK.unshift(body.undo);
9103
9269
  return true;
9104
9270
  }
9271
+ async function requestCreateDirectory(path, onCreated, options = {}) {
9272
+ if (creatingDirectory)
9273
+ return;
9274
+ const name = await askNewDirectoryName(path, options.focusReturnTarget);
9275
+ if (!name)
9276
+ return;
9277
+ creatingDirectory = true;
9278
+ try {
9279
+ const res = await fetch("/_create_directory", {
9280
+ method: "POST",
9281
+ headers: {
9282
+ "Content-Type": "application/json",
9283
+ "X-Code-Viewer-Action": "1"
9284
+ },
9285
+ body: JSON.stringify({ dir: path, name })
9286
+ });
9287
+ if (!res.ok) {
9288
+ showCreateDirectoryError(`Failed to create "${name}": ${await res.text()}`);
9289
+ return;
9290
+ }
9291
+ const body = await res.json();
9292
+ onCreated(body.path || (path ? `${path}/${name}` : name));
9293
+ } finally {
9294
+ creatingDirectory = false;
9295
+ }
9296
+ }
9105
9297
  async function runUndoAction(action) {
9106
9298
  if (action.type !== "trash")
9107
9299
  return false;
@@ -9140,13 +9332,47 @@ ${frontmatter.yaml}
9140
9332
  function canTrashWorktreeRef(ref) {
9141
9333
  return ref === "worktree" || ref === "";
9142
9334
  }
9143
- function showRepoContextMenu(event, entry, ref, onDeleted) {
9335
+ function parentRepoPath(path) {
9336
+ return path.split("/").slice(0, -1).join("/");
9337
+ }
9338
+ async function copyRepoContextText(text2) {
9339
+ if (!text2)
9340
+ return;
9341
+ await navigator.clipboard.writeText(text2);
9342
+ }
9343
+ function createCopyPathButton(path) {
9344
+ const button = document.createElement("button");
9345
+ button.type = "button";
9346
+ button.className = "gdp-file-header-icon gdp-copy-path";
9347
+ button.title = "copy folder path";
9348
+ button.setAttribute("aria-label", "copy folder path");
9349
+ button.innerHTML = iconSvg("octicon-copy", COPY_16_PATHS);
9350
+ button.addEventListener("click", async (event) => {
9351
+ event.stopPropagation();
9352
+ try {
9353
+ await navigator.clipboard.writeText(filePathClipboardText(path));
9354
+ button.classList.add("copied");
9355
+ setTimeout(() => {
9356
+ button.classList.remove("copied");
9357
+ }, 1200);
9358
+ } catch {
9359
+ button.classList.add("failed");
9360
+ setTimeout(() => {
9361
+ button.classList.remove("failed");
9362
+ }, 1200);
9363
+ }
9364
+ });
9365
+ return button;
9366
+ }
9367
+ function showRepoContextMenu(event, entry, ref, onChanged) {
9144
9368
  if (document.querySelector(".gdp-trash-dialog-backdrop"))
9145
9369
  return false;
9146
9370
  if (!canTrashWorktreeRef(ref))
9147
9371
  return false;
9148
9372
  if (entry.children_omitted_reason === "internal")
9149
9373
  return false;
9374
+ if (entry.type !== "tree" && entry.type !== "blob")
9375
+ return false;
9150
9376
  event.preventDefault();
9151
9377
  closeRepoContextMenu();
9152
9378
  const menu = document.createElement("div");
@@ -9158,15 +9384,39 @@ ${frontmatter.yaml}
9158
9384
  const anchorY = event.clientY > 0 ? event.clientY : anchorRect?.bottom || window.innerHeight / 2;
9159
9385
  menu.style.left = `${anchorX}px`;
9160
9386
  menu.style.top = `${anchorY}px`;
9387
+ const copyPath = document.createElement("button");
9388
+ copyPath.type = "button";
9389
+ copyPath.textContent = "Copy Path";
9390
+ copyPath.addEventListener("click", async () => {
9391
+ closeRepoContextMenu();
9392
+ await copyRepoContextText(filePathClipboardText(entry.path));
9393
+ });
9394
+ const copyName = document.createElement("button");
9395
+ copyName.type = "button";
9396
+ copyName.textContent = "Copy Name";
9397
+ copyName.addEventListener("click", async () => {
9398
+ closeRepoContextMenu();
9399
+ await copyRepoContextText(fileNameClipboardText(entry.path));
9400
+ });
9401
+ const createDir = document.createElement("button");
9402
+ createDir.type = "button";
9403
+ createDir.textContent = "New Folder...";
9404
+ createDir.addEventListener("click", async () => {
9405
+ closeRepoContextMenu();
9406
+ const targetPath = entry.type === "blob" ? parentRepoPath(entry.path) : entry.path;
9407
+ await requestCreateDirectory(targetPath, onChanged, {
9408
+ focusReturnTarget
9409
+ });
9410
+ });
9161
9411
  const trash = document.createElement("button");
9162
9412
  trash.type = "button";
9163
9413
  trash.className = "danger";
9164
9414
  trash.textContent = "Move to Trash...";
9165
9415
  trash.addEventListener("click", async () => {
9166
9416
  closeRepoContextMenu();
9167
- await requestMoveToTrash(entry.path, onDeleted, { focusReturnTarget });
9417
+ await requestMoveToTrash(entry.path, onChanged, { focusReturnTarget });
9168
9418
  });
9169
- menu.appendChild(trash);
9419
+ menu.append(copyPath, copyName, createDir, trash);
9170
9420
  document.body.appendChild(menu);
9171
9421
  const rect = menu.getBoundingClientRect();
9172
9422
  const left = Math.min(anchorX, window.innerWidth - rect.width - 8);
@@ -9186,6 +9436,7 @@ ${frontmatter.yaml}
9186
9436
  return null;
9187
9437
  return {
9188
9438
  path,
9439
+ type: row.dataset.type,
9189
9440
  children_omitted_reason: row.dataset.childrenOmittedReason
9190
9441
  };
9191
9442
  }
@@ -9199,14 +9450,31 @@ ${frontmatter.yaml}
9199
9450
  function createMoveToTrashButton(path, onDeleted) {
9200
9451
  const button = document.createElement("button");
9201
9452
  button.type = "button";
9202
- button.className = "gdp-btn gdp-btn-sm gdp-trash-path";
9203
- button.textContent = "Move to Trash";
9453
+ button.className = "gdp-file-header-icon gdp-trash-path";
9454
+ button.title = "move folder to Trash";
9455
+ button.setAttribute("aria-label", "move folder to Trash");
9456
+ button.innerHTML = iconSvg("octicon-trash", TRASH_16_PATH);
9204
9457
  button.addEventListener("click", async (event) => {
9205
9458
  event.stopPropagation();
9206
9459
  await requestMoveToTrash(path, onDeleted, { focusReturnTarget: button });
9207
9460
  });
9208
9461
  return button;
9209
9462
  }
9463
+ function createNewFolderButton(path, onCreated) {
9464
+ const button = document.createElement("button");
9465
+ button.type = "button";
9466
+ button.className = "gdp-file-header-icon gdp-create-dir";
9467
+ button.title = "new folder";
9468
+ button.setAttribute("aria-label", "new folder");
9469
+ button.innerHTML = iconSvg("octicon-plus", PLUS_16_PATH);
9470
+ button.addEventListener("click", async (event) => {
9471
+ event.stopPropagation();
9472
+ await requestCreateDirectory(path, onCreated, {
9473
+ focusReturnTarget: button
9474
+ });
9475
+ });
9476
+ return button;
9477
+ }
9210
9478
  function createOpenPathButton(path, kind, title = "open folder in OS") {
9211
9479
  const button = document.createElement("button");
9212
9480
  button.type = "button";
@@ -9257,7 +9525,10 @@ ${frontmatter.yaml}
9257
9525
  button.className = "gdp-btn gdp-btn-sm";
9258
9526
  button.textContent = "Upload files";
9259
9527
  button.addEventListener("click", () => input.click());
9260
- const fail = () => {
9528
+ const error2 = document.createElement("div");
9529
+ error2.className = "gdp-upload-error";
9530
+ const fail = (message = "Upload failed") => {
9531
+ error2.textContent = message;
9261
9532
  dropPanel.classList.add("failed");
9262
9533
  setTimeout(() => dropPanel.classList.remove("failed"), 1600);
9263
9534
  };
@@ -9265,8 +9536,9 @@ ${frontmatter.yaml}
9265
9536
  try {
9266
9537
  if (input.files?.length)
9267
9538
  await uploadFiles(path, input.files);
9268
- } catch {
9269
- fail();
9539
+ error2.textContent = "";
9540
+ } catch (uploadError) {
9541
+ fail(uploadError instanceof Error ? uploadError.message : "Upload failed");
9270
9542
  } finally {
9271
9543
  input.value = "";
9272
9544
  }
@@ -9283,11 +9555,12 @@ ${frontmatter.yaml}
9283
9555
  const files = event.dataTransfer?.files;
9284
9556
  if (files?.length)
9285
9557
  await uploadFiles(path, files);
9286
- } catch {
9287
- fail();
9558
+ error2.textContent = "";
9559
+ } catch (uploadError) {
9560
+ fail(uploadError instanceof Error ? uploadError.message : "Upload failed");
9288
9561
  }
9289
9562
  });
9290
- dropPanel.append(copy, button, input);
9563
+ dropPanel.append(copy, button, input, error2);
9291
9564
  return dropPanel;
9292
9565
  }
9293
9566
  function repoRoute(ref, path) {
@@ -9362,39 +9635,31 @@ ${frontmatter.yaml}
9362
9635
  const target = $("#diff");
9363
9636
  const shell = document.createElement("section");
9364
9637
  shell.className = "gdp-repo-shell";
9365
- const { wrap: targetPickerWrap, input: targetPicker } = createRefSelectorInput({
9366
- id: "repo-ref",
9367
- placeholder: "ref...",
9368
- title: "repository ref",
9369
- value: meta.ref || "worktree"
9370
- });
9371
- wireRefSelectorInput(targetPicker, (ref) => {
9372
- setRoute(repoRoute(ref, ""));
9373
- loadRepo();
9374
- });
9375
9638
  const toolbar = document.createElement("div");
9376
9639
  toolbar.className = "gdp-file-detail-header gdp-repo-toolbar";
9377
- toolbar.append(targetPickerWrap, createRepoBreadcrumb(meta.ref, meta.path || ""), createOpenPathButton(meta.path || "", "directory", "open this folder in OS"));
9640
+ const pathHeader = document.createElement("div");
9641
+ pathHeader.className = "gdp-file-detail-path";
9642
+ pathHeader.appendChild(createRepoBreadcrumb(meta.ref, meta.path || ""));
9643
+ if (meta.path)
9644
+ pathHeader.appendChild(createCopyPathButton(meta.path));
9645
+ pathHeader.appendChild(createOpenPathButton(meta.path || "", "directory", "open this folder in OS"));
9646
+ toolbar.appendChild(pathHeader);
9647
+ if (canTrashWorktreeRef(meta.ref)) {
9648
+ toolbar.appendChild(createNewFolderButton(meta.path || "", () => loadRepo()));
9649
+ if (meta.path) {
9650
+ toolbar.appendChild(createMoveToTrashButton(meta.path, () => {
9651
+ const parent = meta.path.split("/").slice(0, -1).join("/");
9652
+ setRoute(repoRoute(meta.ref, parent));
9653
+ loadRepo();
9654
+ }));
9655
+ }
9656
+ }
9378
9657
  shell.appendChild(toolbar);
9379
9658
  const listCard = document.createElement("section");
9380
9659
  listCard.className = "gdp-file-shell loaded gdp-repo-list-shell";
9381
9660
  const listWrapper = document.createElement("div");
9382
9661
  listWrapper.className = "d2h-file-wrapper";
9383
- const listHeader = document.createElement("div");
9384
- listHeader.className = "d2h-file-header";
9385
- const listName = document.createElement("div");
9386
- listName.className = "d2h-file-name-wrapper";
9387
- const listIcon = document.createElement("span");
9388
- listIcon.className = "dir-icon";
9389
- setFolderIcon(listIcon, false);
9390
- const listTitle = document.createElement("span");
9391
- listTitle.className = "d2h-file-name";
9392
- listTitle.textContent = meta.path || meta.project || "Files";
9393
- listName.append(listIcon, listTitle);
9394
- listHeader.appendChild(listName);
9395
- listHeader.appendChild(createOpenPathButton(meta.path || "", "directory", "open this folder in OS"));
9396
- listWrapper.appendChild(listHeader);
9397
- if (meta.upload_enabled && (meta.ref === "worktree" || meta.ref === "")) {
9662
+ if (meta.ref === "worktree" || meta.ref === "") {
9398
9663
  listWrapper.appendChild(createRepoUploadPanel(meta.path || ""));
9399
9664
  }
9400
9665
  const sortHost = document.createElement("div");
package/web/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
  <header id="global-header">
22
22
  <a class="brand" href="/" aria-label="Repository home">
23
23
  <img class="brand-icon" src="/favicon.png" alt="" width="20" height="20" />
24
- <span class="title">code viewer</span>
24
+ <span class="title" id="project-title">repository</span>
25
25
  </a>
26
26
  <nav class="app-menu" aria-label="Views">
27
27
  <a class="app-menu-item active" data-route="repo" href="/">Repository</a>
@@ -32,6 +32,7 @@
32
32
  <button id="theme" title="toggle theme">🌗</button>
33
33
  <span id="status" class="status"></span>
34
34
  <a class="global-help-link" data-route="help" href="/help">Help</a>
35
+ <span class="product-label" aria-hidden="true">code viewer</span>
35
36
  </div>
36
37
  </header>
37
38
  <header id="topbar">
package/web/style.css CHANGED
@@ -101,6 +101,7 @@ html, body {
101
101
  margin: 0; padding: 0;
102
102
  background: var(--bg); color: var(--fg);
103
103
  --code-font-size: 12px;
104
+ --markdown-font-size: calc(var(--code-font-size) + 2px);
104
105
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans",
105
106
  Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
106
107
  font-size: 14px;
@@ -282,9 +283,11 @@ body[data-code-font-size="xlarge"] { --code-font-size: 15px; }
282
283
  .brand {
283
284
  display: flex; align-items: center; gap: 9px;
284
285
  font-weight: 600;
285
- font-size: 14px;
286
+ font-size: 15px;
286
287
  color: var(--fg);
287
- flex-shrink: 0;
288
+ min-width: 0;
289
+ max-width: min(34vw, 420px);
290
+ flex: 0 1 auto;
288
291
  text-decoration: none;
289
292
  }
290
293
  .brand:hover { color: var(--fg); text-decoration: none; }
@@ -294,7 +297,12 @@ body[data-code-font-size="xlarge"] { --code-font-size: 15px; }
294
297
  display: block;
295
298
  border-radius: 5px;
296
299
  }
297
- .brand .title { letter-spacing: -0.005em; }
300
+ .brand .title {
301
+ min-width: 0;
302
+ overflow: hidden;
303
+ text-overflow: ellipsis;
304
+ white-space: nowrap;
305
+ }
298
306
 
299
307
  .app-menu {
300
308
  display: flex;
@@ -366,6 +374,13 @@ body[data-code-font-size="xlarge"] { --code-font-size: 15px; }
366
374
  gap: 8px;
367
375
  flex: 0 0 auto;
368
376
  }
377
+ .product-label {
378
+ color: var(--fg-muted);
379
+ font-size: 12px;
380
+ font-weight: 600;
381
+ line-height: 1;
382
+ white-space: nowrap;
383
+ }
369
384
  .global-actions #theme,
370
385
  .global-icon-action {
371
386
  display: inline-flex;
@@ -2298,6 +2313,12 @@ table.d2h-diff-table tr.gdp-diff-line-target > td:first-child {
2298
2313
  .gdp-upload-panel.failed {
2299
2314
  border-color: var(--danger);
2300
2315
  }
2316
+ .gdp-upload-error {
2317
+ min-height: 18px;
2318
+ color: var(--danger);
2319
+ font-size: 12px;
2320
+ line-height: 18px;
2321
+ }
2301
2322
  .gdp-upload-copy {
2302
2323
  min-width: 0;
2303
2324
  color: var(--fg-muted);
@@ -2542,6 +2563,7 @@ table.d2h-diff-table tr.gdp-diff-line-target > td:first-child {
2542
2563
  display: flex;
2543
2564
  align-items: center;
2544
2565
  min-width: 0;
2566
+ flex: 0 1 auto;
2545
2567
  gap: 4px;
2546
2568
  padding-top: 2px;
2547
2569
  overflow: hidden;
@@ -2595,6 +2617,9 @@ table.d2h-diff-table tr.gdp-diff-line-target > td:first-child {
2595
2617
  flex-shrink: 0;
2596
2618
  color: var(--danger);
2597
2619
  }
2620
+ .gdp-file-detail-header .gdp-create-dir {
2621
+ flex-shrink: 0;
2622
+ }
2598
2623
  .gdp-file-detail-meta {
2599
2624
  display: flex;
2600
2625
  align-items: center;
@@ -2634,7 +2659,7 @@ table.d2h-diff-table tr.gdp-diff-line-target > td:first-child {
2634
2659
  padding: 24px 32px 40px;
2635
2660
  color: var(--fg);
2636
2661
  overflow-wrap: break-word;
2637
- font-size: 16px;
2662
+ font-size: var(--markdown-font-size);
2638
2663
  line-height: 1.75;
2639
2664
  }
2640
2665
  .gdp-html-preview {
@@ -2697,7 +2722,7 @@ table.d2h-diff-table tr.gdp-diff-line-target > td:first-child {
2697
2722
  margin: 0;
2698
2723
  }
2699
2724
  .gdp-markdown-toc a {
2700
- display: -webkit-box;
2725
+ display: block;
2701
2726
  min-height: 28px;
2702
2727
  padding: 5px 10px 5px 12px;
2703
2728
  border-left: 3px solid transparent;
@@ -2706,8 +2731,6 @@ table.d2h-diff-table tr.gdp-diff-line-target > td:first-child {
2706
2731
  overflow: hidden;
2707
2732
  overflow-wrap: anywhere;
2708
2733
  text-decoration: none;
2709
- -webkit-box-orient: vertical;
2710
- -webkit-line-clamp: 2;
2711
2734
  transition:
2712
2735
  background-color 0.06s ease,
2713
2736
  border-color 0.06s ease,
@@ -2741,6 +2764,14 @@ table.d2h-diff-table tr.gdp-diff-line-target > td:first-child {
2741
2764
  font-weight: 600;
2742
2765
  }
2743
2766
  .gdp-markdown-preview h1,
2767
+ .gdp-markdown-preview h2,
2768
+ .gdp-markdown-preview h3,
2769
+ .gdp-markdown-preview h4,
2770
+ .gdp-markdown-preview h5,
2771
+ .gdp-markdown-preview h6 {
2772
+ scroll-margin-top: calc(var(--global-header-h) + 120px);
2773
+ }
2774
+ .gdp-markdown-preview h1,
2744
2775
  .gdp-markdown-preview h2 {
2745
2776
  padding-bottom: 0.3em;
2746
2777
  border-bottom: 1px solid var(--border-muted);
@@ -3141,9 +3172,6 @@ body.gdp-file-detail-page #empty {
3141
3172
  padding-left: 0;
3142
3173
  padding-right: 0;
3143
3174
  }
3144
- .gdp-repo-breadcrumb {
3145
- flex: 1;
3146
- }
3147
3175
  .gdp-repo-breadcrumb button {
3148
3176
  max-width: 260px;
3149
3177
  border: 0;
@@ -3324,6 +3352,29 @@ body.gdp-file-detail-page #empty {
3324
3352
  line-height: 1.5;
3325
3353
  overflow-wrap: anywhere;
3326
3354
  }
3355
+ .gdp-create-dir-input {
3356
+ display: block;
3357
+ width: 100%;
3358
+ height: 32px;
3359
+ margin-top: 10px;
3360
+ padding: 0 10px;
3361
+ border: 1px solid var(--border);
3362
+ border-radius: 6px;
3363
+ background: var(--bg);
3364
+ color: var(--fg);
3365
+ font: inherit;
3366
+ }
3367
+ .gdp-create-dir-input:focus {
3368
+ outline: 2px solid var(--accent-subtle);
3369
+ border-color: var(--accent);
3370
+ }
3371
+ .gdp-create-dir-error {
3372
+ min-height: 18px;
3373
+ margin-top: 6px;
3374
+ color: var(--danger);
3375
+ font-size: 12px;
3376
+ line-height: 18px;
3377
+ }
3327
3378
  .gdp-trash-dialog-actions {
3328
3379
  display: flex;
3329
3380
  justify-content: flex-end;