@youtyan/code-viewer 0.1.24 → 0.1.26

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.
@@ -6,14 +6,17 @@ import {
6
6
  constants,
7
7
  existsSync as existsSync3,
8
8
  lstatSync as lstatSync3,
9
+ mkdirSync,
9
10
  openSync,
10
11
  readFileSync as readFileSync2,
11
12
  realpathSync,
13
+ renameSync,
12
14
  statSync,
13
15
  unlinkSync,
14
16
  watch,
15
17
  writeFileSync
16
18
  } from "node:fs";
19
+ import { homedir } from "node:os";
17
20
  import { basename as basename2, dirname as dirname2, extname, join as join4, relative } from "node:path";
18
21
 
19
22
  // web-src/routes.ts
@@ -313,6 +316,13 @@ function objectByteSize(oid, cwd) {
313
316
  stderr: res.stderr
314
317
  };
315
318
  }
319
+ function lastCommitDateForPath(ref, path, cwd) {
320
+ const args = ["git", "log", "-1", "--format=%cI", ref, "--", path];
321
+ const res = run(args, cwd);
322
+ if (res.code !== 0)
323
+ return null;
324
+ return res.stdout.trim() || null;
325
+ }
316
326
  function objectId(ref, path, cwd) {
317
327
  const res = run(["git", "rev-parse", "--verify", `${ref}:${path}`], cwd);
318
328
  const oid = res.stdout.trim();
@@ -561,7 +571,13 @@ function omittedWorktreeDirectoryReason(name, omitDirNames) {
561
571
  return "internal";
562
572
  return omitDirNames.has(name) ? "heavy" : undefined;
563
573
  }
564
- function worktreeEntryFromDirent(base, dir, name, isDirectory, omitDirNames) {
574
+ function worktreeEntryFromDirent(base, dir, name, isDirectory, omitDirNames, excludeNames) {
575
+ if (excludeNames.has(name.toLowerCase()))
576
+ return {
577
+ name,
578
+ path: "",
579
+ type: isDirectory ? "tree" : "blob"
580
+ };
565
581
  const entryPath = base ? `${base}/${name}` : name;
566
582
  const type = isDirectory ? hasDotGitEntry(join2(dir, name)) ? "commit" : "tree" : "blob";
567
583
  const omittedReason = type === "tree" ? omittedWorktreeDirectoryReason(name, omitDirNames) : undefined;
@@ -573,14 +589,15 @@ function worktreeEntryFromDirent(base, dir, name, isDirectory, omitDirNames) {
573
589
  children_omitted_reason: omittedReason
574
590
  } : { name, path: entryPath, type };
575
591
  }
576
- function worktreeFilesystemEntries(cwd, path, recursive, omitDirNames = DEFAULT_WORKTREE_OMIT_DIR_NAMES) {
592
+ function worktreeFilesystemEntries(cwd, path, recursive, omitDirNames = DEFAULT_WORKTREE_OMIT_DIR_NAMES, excludeNames = []) {
577
593
  const base = normalizeTreePath(path);
578
594
  const root = join2(cwd, base);
579
595
  const omitDirNameSet = new Set(omitDirNames);
596
+ const excludeNameSet = new Set(excludeNames.map((name) => name.toLowerCase()));
580
597
  let directEntries;
581
598
  try {
582
599
  const dirents = readdirSync(root, { withFileTypes: true });
583
- directEntries = sortTreeEntries(dirents.map((entry) => worktreeEntryFromDirent(base, root, entry.name, entry.isDirectory(), omitDirNameSet)));
600
+ directEntries = sortTreeEntries(dirents.map((entry) => worktreeEntryFromDirent(base, root, entry.name, entry.isDirectory(), omitDirNameSet, excludeNameSet)).filter((entry) => entry.path));
584
601
  } catch {
585
602
  return [];
586
603
  }
@@ -617,6 +634,8 @@ function worktreeFilesystemEntries(cwd, path, recursive, omitDirNames = DEFAULT_
617
634
  return;
618
635
  }
619
636
  for (const entry of entries) {
637
+ if (excludeNameSet.has(entry.name.toLowerCase()))
638
+ continue;
620
639
  const entryPath = prefix ? `${prefix}/${entry.name}` : entry.name;
621
640
  const full = join2(dir, entry.name);
622
641
  if (entry.isDirectory()) {
@@ -692,15 +711,12 @@ function combineDirectAndRecursiveFiles(directEntries, fileEntries) {
692
711
  ...fileEntries.filter((entry) => !seen.has(entry.path))
693
712
  ];
694
713
  }
695
- function worktreeFiles(cwd) {
696
- return listTree("worktree", "", cwd, { recursive: true }).entries;
697
- }
698
714
  function listTree(ref, path, cwd, options = {}) {
699
715
  const base = normalizeTreePath(path);
700
716
  if (ref === "worktree") {
701
717
  return {
702
718
  code: 0,
703
- entries: worktreeFilesystemEntries(cwd, base, !!options.recursive, options.omitDirNames),
719
+ entries: worktreeFilesystemEntries(cwd, base, !!options.recursive, options.omitDirNames, options.excludeNames),
704
720
  stderr: ""
705
721
  };
706
722
  }
@@ -1092,17 +1108,19 @@ var GREP_DEFAULT_MAX = 200;
1092
1108
  var GREP_ABSOLUTE_MAX = 500;
1093
1109
  var GREP_MAX_FILE_BYTES = 2 * 1024 * 1024;
1094
1110
  var FILE_SEARCH_ABSOLUTE_MAX = 50000;
1111
+ var DEFAULT_EXCLUDE_NAMES = [".DS_Store"];
1095
1112
  function normalizeGrepMax(value) {
1096
1113
  const parsed = Number(value || "");
1097
1114
  if (!Number.isInteger(parsed) || parsed <= 0)
1098
1115
  return GREP_DEFAULT_MAX;
1099
1116
  return Math.min(parsed, GREP_ABSOLUTE_MAX);
1100
1117
  }
1101
- function isSkippableSearchPath(path, omitDirNames = []) {
1118
+ function isSkippableSearchPath(path, omitDirNames = [], excludeNames = []) {
1102
1119
  const omitDirs = new Set(omitDirNames.map((name) => name.toLowerCase()));
1120
+ const excluded = new Set(excludeNames.map((name) => name.toLowerCase()));
1103
1121
  return path.split(/[\\/]+/).some((part) => {
1104
1122
  const lower = part.toLowerCase();
1105
- return lower === ".git" || omitDirs.has(lower);
1123
+ return lower === ".git" || omitDirs.has(lower) || excluded.has(lower);
1106
1124
  });
1107
1125
  }
1108
1126
  function fixedStringLineMatches(path, text, query, max) {
@@ -1135,7 +1153,7 @@ function buildFileSearchList(ref, generation, entries) {
1135
1153
  truncated: entries.length > FILE_SEARCH_ABSOLUTE_MAX
1136
1154
  };
1137
1155
  }
1138
- function buildRgArgs(query, max, paths, regex = false, omitDirNames = []) {
1156
+ function buildRgArgs(query, max, paths, regex = false, omitDirNames = [], excludeNames = []) {
1139
1157
  const safePaths = paths.length ? paths : ["."];
1140
1158
  const omitGlobs = omitDirNames.flatMap((name) => [
1141
1159
  "--glob",
@@ -1143,6 +1161,12 @@ function buildRgArgs(query, max, paths, regex = false, omitDirNames = []) {
1143
1161
  "--glob",
1144
1162
  `!**/${name}/**`
1145
1163
  ]);
1164
+ const excludeGlobs = excludeNames.flatMap((name) => [
1165
+ "--glob",
1166
+ `!${name}`,
1167
+ "--glob",
1168
+ `!**/${name}`
1169
+ ]);
1146
1170
  const args = [
1147
1171
  "rg",
1148
1172
  "--no-config",
@@ -1157,6 +1181,7 @@ function buildRgArgs(query, max, paths, regex = false, omitDirNames = []) {
1157
1181
  "--max-filesize",
1158
1182
  "2M",
1159
1183
  ...omitGlobs,
1184
+ ...excludeGlobs,
1160
1185
  "-e",
1161
1186
  query,
1162
1187
  "--",
@@ -1166,7 +1191,7 @@ function buildRgArgs(query, max, paths, regex = false, omitDirNames = []) {
1166
1191
  args.splice(8, 0, "--fixed-strings");
1167
1192
  return args;
1168
1193
  }
1169
- function parseRgOutput(stdout, max, omitDirNames = []) {
1194
+ function parseRgOutput(stdout, max, omitDirNames = [], excludeNames = []) {
1170
1195
  const matches = [];
1171
1196
  for (const line of stdout.split(`
1172
1197
  `)) {
@@ -1179,7 +1204,7 @@ function parseRgOutput(stdout, max, omitDirNames = []) {
1179
1204
  const lineNo = Number(parsed[2]);
1180
1205
  const column = Number(parsed[3]);
1181
1206
  const preview = parsed[4];
1182
- if (!path || !lineNo || !column || isSkippableSearchPath(path, omitDirNames))
1207
+ if (!path || !lineNo || !column || isSkippableSearchPath(path, omitDirNames, excludeNames))
1183
1208
  continue;
1184
1209
  matches.push({
1185
1210
  path,
@@ -1190,12 +1215,12 @@ function parseRgOutput(stdout, max, omitDirNames = []) {
1190
1215
  }
1191
1216
  return matches;
1192
1217
  }
1193
- function parseGitGrepOutput(stdout, ref, max, omitDirNames = []) {
1218
+ function parseGitGrepOutput(stdout, ref, max, omitDirNames = [], excludeNames = []) {
1194
1219
  const prefix = `${ref}:`;
1195
1220
  const normalized = stdout.split(`
1196
1221
  `).map((line) => line.startsWith(prefix) ? line.slice(prefix.length) : line).join(`
1197
1222
  `);
1198
- return parseRgOutput(normalized, max, omitDirNames);
1223
+ return parseRgOutput(normalized, max, omitDirNames, excludeNames);
1199
1224
  }
1200
1225
 
1201
1226
  // web-src/server/preview.ts
@@ -1248,6 +1273,7 @@ var uploadAllowedByCli = false;
1248
1273
  var openAfterStart = false;
1249
1274
  var scopeOmitDirNames = DEFAULT_WORKTREE_OMIT_DIR_NAMES;
1250
1275
  var scopeOmitDirCliOverride = null;
1276
+ var scopeExcludeNames = DEFAULT_EXCLUDE_NAMES;
1251
1277
  var rgAvailableCache = null;
1252
1278
  var enc = new TextEncoder;
1253
1279
  var sseClients = new Set;
@@ -1322,11 +1348,14 @@ Examples:
1322
1348
  if (!uploadAllowedByCli)
1323
1349
  allowUpload = loadProjectConfigUploadEnabled();
1324
1350
  const configScopeOmitDirs = loadProjectConfigScopeOmitDirs();
1351
+ const configScopeExcludeNames = loadProjectConfigScopeExcludeNames();
1325
1352
  if (scopeOmitDirCliOverride) {
1326
1353
  scopeOmitDirNames = scopeOmitDirCliOverride;
1327
1354
  } else if (configScopeOmitDirs) {
1328
1355
  scopeOmitDirNames = configScopeOmitDirs;
1329
1356
  }
1357
+ if (configScopeExcludeNames)
1358
+ scopeExcludeNames = configScopeExcludeNames;
1330
1359
  }
1331
1360
  function json(data, init = {}) {
1332
1361
  return new Response(JSON.stringify(data), {
@@ -1599,6 +1628,13 @@ function normalizeScopeOmitDirNames(names) {
1599
1628
  ...new Set(names.filter((name) => typeof name === "string").map((name) => name.trim()).filter((name) => name && name.length <= 64 && !name.includes("/") && !name.includes("\\") && !name.includes("\x00") && name !== "." && name !== ".." && name !== ".git"))
1600
1629
  ].sort((a, b) => a.localeCompare(b));
1601
1630
  }
1631
+ function normalizeScopeExcludeNames(names) {
1632
+ if (!Array.isArray(names))
1633
+ return [];
1634
+ return [
1635
+ ...new Set(names.filter((name) => typeof name === "string").map((name) => name.trim()).filter((name) => name && name.length <= 128 && !name.includes("/") && !name.includes("\\") && !name.includes("\x00") && name !== "." && name !== ".." && name !== ".git"))
1636
+ ].sort((a, b) => a.localeCompare(b));
1637
+ }
1602
1638
  function parseScopeOmitDirNamesQuery(value) {
1603
1639
  const names = value ? value.split(",") : [];
1604
1640
  if (names.length > 100)
@@ -1610,6 +1646,17 @@ function parseScopeOmitDirNamesQuery(value) {
1610
1646
  }
1611
1647
  return normalizeScopeOmitDirNames(names);
1612
1648
  }
1649
+ function parseScopeExcludeNamesQuery(value) {
1650
+ const names = value ? value.split(",") : [];
1651
+ if (names.length > 200)
1652
+ return null;
1653
+ for (const raw of names) {
1654
+ const name = raw.trim();
1655
+ if (!name || name.length > 128 || name.includes("/") || name.includes("\\") || name.includes("\x00") || name === "." || name === ".." || name === ".git")
1656
+ return null;
1657
+ }
1658
+ return normalizeScopeExcludeNames(names);
1659
+ }
1613
1660
  function loadProjectConfig() {
1614
1661
  const full = join4(cwd, ".code-viewer.json");
1615
1662
  if (!existsSync3(full))
@@ -1643,14 +1690,31 @@ function loadProjectConfigScopeOmitDirs() {
1643
1690
  return null;
1644
1691
  return normalizeScopeOmitDirNames(config.scope.omitDirs);
1645
1692
  }
1693
+ function loadProjectConfigScopeExcludeNames() {
1694
+ const config = loadProjectConfig();
1695
+ if (!config?.scope || !Array.isArray(config.scope.excludeNames))
1696
+ return null;
1697
+ return normalizeScopeExcludeNames(config.scope.excludeNames);
1698
+ }
1646
1699
  function scopeOmitDirNamesFromQuery(url) {
1647
1700
  if (!url.searchParams.has("omit_dirs"))
1648
1701
  return scopeOmitDirNames;
1649
1702
  return parseScopeOmitDirNamesQuery(url.searchParams.get("omit_dirs") || "") || scopeOmitDirNames;
1650
1703
  }
1704
+ function scopeExcludeNamesFromQuery(url) {
1705
+ if (!url.searchParams.has("exclude_names"))
1706
+ return scopeExcludeNames;
1707
+ return parseScopeExcludeNamesQuery(url.searchParams.get("exclude_names") || "") || scopeExcludeNames;
1708
+ }
1651
1709
  function invalidScopeOmitDirNamesQuery(url) {
1652
1710
  return url.searchParams.has("omit_dirs") && !parseScopeOmitDirNamesQuery(url.searchParams.get("omit_dirs") || "");
1653
1711
  }
1712
+ function invalidScopeExcludeNamesQuery(url) {
1713
+ return url.searchParams.has("exclude_names") && !parseScopeExcludeNamesQuery(url.searchParams.get("exclude_names") || "");
1714
+ }
1715
+ function isExcludedScopePath(path, excludeNames) {
1716
+ return path.split(/[\\/]+/).some((part) => excludeNames.some((name) => part.toLowerCase() === name.toLowerCase()));
1717
+ }
1654
1718
  function isGitInternalPath(path) {
1655
1719
  return path.split(/[\\/]+/).some((part) => part.toLowerCase() === ".git");
1656
1720
  }
@@ -1677,6 +1741,9 @@ function safeWorktreePath(path) {
1677
1741
  return null;
1678
1742
  return realFull;
1679
1743
  }
1744
+ function worktreePath(path) {
1745
+ return join4(cwd, path);
1746
+ }
1680
1747
  function safeOpenWorktreePath(path) {
1681
1748
  if (path === "") {
1682
1749
  try {
@@ -1694,6 +1761,71 @@ function parentRepoPath(path) {
1694
1761
  const parent = dirname2(path);
1695
1762
  return parent === "." ? "" : parent;
1696
1763
  }
1764
+ function isoDate(ms) {
1765
+ return ms && Number.isFinite(ms) ? new Date(ms).toISOString() : undefined;
1766
+ }
1767
+ function worktreeFileMetadata(path, knownSize) {
1768
+ const full = safeWorktreePath(path);
1769
+ if (!full)
1770
+ return {};
1771
+ try {
1772
+ const stat = statSync(full);
1773
+ return {
1774
+ size: knownSize ?? stat.size,
1775
+ created_at: isoDate(stat.birthtimeMs),
1776
+ updated_at: isoDate(stat.mtimeMs)
1777
+ };
1778
+ } catch {
1779
+ return {};
1780
+ }
1781
+ }
1782
+ function gitFileMetadata(ref, path, knownSize) {
1783
+ const size = knownSize ?? rawFileSize(path, ref);
1784
+ const commitUpdatedAt = lastCommitDateForPath(ref, path, cwd) || undefined;
1785
+ return {
1786
+ size: size == null ? undefined : size,
1787
+ updated_at: commitUpdatedAt,
1788
+ commit_updated_at: commitUpdatedAt
1789
+ };
1790
+ }
1791
+ function directoryMetadata(target, path) {
1792
+ if (target === "worktree" || target === "") {
1793
+ const full = path === "" ? safeOpenWorktreePath("") : safeWorktreePath(path);
1794
+ if (!full)
1795
+ return {};
1796
+ try {
1797
+ const stat = statSync(full);
1798
+ return {
1799
+ created_at: isoDate(stat.birthtimeMs),
1800
+ updated_at: isoDate(stat.mtimeMs)
1801
+ };
1802
+ } catch {
1803
+ return {};
1804
+ }
1805
+ }
1806
+ const commitUpdatedAt = lastCommitDateForPath(target, path || ".", cwd) || undefined;
1807
+ return { updated_at: commitUpdatedAt, commit_updated_at: commitUpdatedAt };
1808
+ }
1809
+ function fileMetadataForTarget(target, path) {
1810
+ return target === "worktree" || target === "" ? worktreeFileMetadata(path) : gitFileMetadata(target, path);
1811
+ }
1812
+ function attachTreeEntryMetadata(target, entry) {
1813
+ if (entry.type === "tree")
1814
+ return { ...entry, ...directoryMetadata(target, entry.path) };
1815
+ if (entry.type !== "blob")
1816
+ return entry;
1817
+ return { ...entry, ...fileMetadataForTarget(target, entry.path) };
1818
+ }
1819
+ function fileMetadataHeaders(metadata) {
1820
+ const headers = {};
1821
+ if (metadata.created_at)
1822
+ headers["X-Code-Viewer-Created-At"] = metadata.created_at;
1823
+ if (metadata.updated_at)
1824
+ headers["X-Code-Viewer-Updated-At"] = metadata.updated_at;
1825
+ if (metadata.commit_updated_at)
1826
+ headers["X-Code-Viewer-Commit-Updated-At"] = metadata.commit_updated_at;
1827
+ return headers;
1828
+ }
1697
1829
  function readReadme(target, dirPath) {
1698
1830
  const candidates = ["README.md", "readme.md", "README.markdown", "README"];
1699
1831
  for (const name of candidates) {
@@ -1726,16 +1858,20 @@ function handleTree(url) {
1726
1858
  const recursive = url.searchParams.get("recursive") === "1";
1727
1859
  if (invalidScopeOmitDirNamesQuery(url))
1728
1860
  return text("invalid omit dirs", 400);
1861
+ if (invalidScopeExcludeNamesQuery(url))
1862
+ return text("invalid exclude names", 400);
1863
+ const excludeNames = scopeExcludeNamesFromQuery(url);
1729
1864
  const entries = listTree(target, path, cwd, {
1730
1865
  recursive,
1731
- omitDirNames: scopeOmitDirNamesFromQuery(url)
1732
- }).entries;
1866
+ omitDirNames: scopeOmitDirNamesFromQuery(url),
1867
+ excludeNames
1868
+ }).entries.filter((entry) => !isExcludedScopePath(entry.path, excludeNames));
1733
1869
  return json({
1734
1870
  ref: target,
1735
1871
  path,
1736
1872
  project: basename2(cwd),
1737
1873
  branch: currentBranch(cwd) || undefined,
1738
- entries,
1874
+ entries: recursive ? entries : entries.map((entry) => attachTreeEntryMetadata(target, entry)),
1739
1875
  readme: readReadme(target, path),
1740
1876
  upload_enabled: allowUpload && (target === "worktree" || target === "")
1741
1877
  });
@@ -1746,6 +1882,8 @@ function handleSettings() {
1746
1882
  scope: {
1747
1883
  omit_dirs_effective: scopeOmitDirNames,
1748
1884
  omit_dirs_built_in: DEFAULT_WORKTREE_OMIT_DIR_NAMES,
1885
+ exclude_names_effective: scopeExcludeNames,
1886
+ exclude_names_built_in: DEFAULT_EXCLUDE_NAMES,
1749
1887
  max_entries: WORKTREE_RECURSIVE_ENTRY_LIMIT
1750
1888
  }
1751
1889
  });
@@ -1756,22 +1894,26 @@ function handleFiles(url) {
1756
1894
  return text("invalid target", 400);
1757
1895
  if (invalidScopeOmitDirNamesQuery(url))
1758
1896
  return text("invalid omit dirs", 400);
1897
+ if (invalidScopeExcludeNamesQuery(url))
1898
+ return text("invalid exclude names", 400);
1759
1899
  const omitDirNames = scopeOmitDirNamesFromQuery(url);
1760
- const key = `${target || "worktree"}\x00${omitDirNames.join("\x00")}`;
1900
+ const excludeNames = scopeExcludeNamesFromQuery(url);
1901
+ const key = `${target || "worktree"}\x00${omitDirNames.join("\x00")}\x00${excludeNames.join("\x00")}`;
1761
1902
  const cached = fileListCache.get(key);
1762
1903
  if (cached && cached.generation === generation)
1763
1904
  return json(cached.body);
1764
1905
  const ref = target || "worktree";
1765
1906
  const entries = listTree(ref, "", cwd, {
1766
1907
  recursive: true,
1767
- omitDirNames
1768
- }).entries;
1908
+ omitDirNames,
1909
+ excludeNames
1910
+ }).entries.filter((entry) => !isExcludedScopePath(entry.path, excludeNames));
1769
1911
  const body = buildFileSearchList(ref, generation, entries);
1770
1912
  fileListCache.set(key, { generation, body });
1771
1913
  return json(body);
1772
1914
  }
1773
- function parseGrepPaths(url, omitDirNames) {
1774
- return url.searchParams.getAll("path").filter((path) => safePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, omitDirNames));
1915
+ function parseGrepPaths(url, omitDirNames, excludeNames) {
1916
+ return url.searchParams.getAll("path").filter((path) => safePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, omitDirNames, excludeNames));
1775
1917
  }
1776
1918
  function rgAvailable() {
1777
1919
  if (rgAvailableCache !== null)
@@ -1780,13 +1922,17 @@ function rgAvailable() {
1780
1922
  rgAvailableCache = proc.code === 0;
1781
1923
  return rgAvailableCache;
1782
1924
  }
1783
- function grepWorktreeFallback(query, max, paths, omitDirNames) {
1784
- const candidates = paths.length ? paths : worktreeFiles(cwd).map((entry) => entry.path);
1925
+ function grepWorktreeFallback(query, max, paths, omitDirNames, excludeNames) {
1926
+ const candidates = paths.length ? paths : listTree("worktree", "", cwd, {
1927
+ recursive: true,
1928
+ omitDirNames,
1929
+ excludeNames
1930
+ }).entries.map((entry) => entry.path);
1785
1931
  const matches = [];
1786
1932
  for (const path of candidates) {
1787
1933
  if (matches.length >= max)
1788
1934
  break;
1789
- if (!safePath(path) || isGitInternalPath(path) || isSkippableSearchPath(path, omitDirNames))
1935
+ if (!safePath(path) || isGitInternalPath(path) || isSkippableSearchPath(path, omitDirNames, excludeNames))
1790
1936
  continue;
1791
1937
  const full = safeWorktreePath(path);
1792
1938
  if (!full)
@@ -1811,13 +1957,13 @@ function grepWorktreeFallback(query, max, paths, omitDirNames) {
1811
1957
  }
1812
1958
  return matches;
1813
1959
  }
1814
- function grepWorktree(query, max, paths, regex, omitDirNames) {
1960
+ function grepWorktree(query, max, paths, regex, omitDirNames, excludeNames) {
1815
1961
  if (rgAvailable()) {
1816
- const safePaths = paths.filter((path) => safePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, omitDirNames) && safeWorktreePath(path));
1817
- const args = buildRgArgs(query, max, safePaths, regex, omitDirNames);
1962
+ const safePaths = paths.filter((path) => safePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, omitDirNames, excludeNames) && safeWorktreePath(path));
1963
+ const args = buildRgArgs(query, max, safePaths, regex, omitDirNames, excludeNames);
1818
1964
  const proc = runSync(args, cwd, { timeout: 5000 });
1819
1965
  const stdout = proc.stdout;
1820
- const matches2 = parseRgOutput(stdout, max, omitDirNames).filter((match) => safePath(match.path) && !isGitInternalPath(match.path) && !isSkippableSearchPath(match.path, omitDirNames) && !!safeWorktreePath(match.path));
1966
+ const matches2 = parseRgOutput(stdout, max, omitDirNames, excludeNames).filter((match) => safePath(match.path) && !isGitInternalPath(match.path) && !isSkippableSearchPath(match.path, omitDirNames, excludeNames) && !!safeWorktreePath(match.path));
1821
1967
  return {
1822
1968
  ref: "worktree",
1823
1969
  engine: "rg",
@@ -1832,7 +1978,7 @@ function grepWorktree(query, max, paths, regex, omitDirNames) {
1832
1978
  truncated: false,
1833
1979
  matches: []
1834
1980
  };
1835
- const matches = grepWorktreeFallback(query, max, paths, omitDirNames);
1981
+ const matches = grepWorktreeFallback(query, max, paths, omitDirNames, excludeNames);
1836
1982
  return {
1837
1983
  ref: "worktree",
1838
1984
  engine: "fallback",
@@ -1840,8 +1986,8 @@ function grepWorktree(query, max, paths, regex, omitDirNames) {
1840
1986
  matches
1841
1987
  };
1842
1988
  }
1843
- function grepTreeRef(ref, query, max, paths, regex, omitDirNames) {
1844
- const safePaths = paths.filter((path) => safePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, omitDirNames));
1989
+ function grepTreeRef(ref, query, max, paths, regex, omitDirNames, excludeNames) {
1990
+ const safePaths = paths.filter((path) => safePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, omitDirNames, excludeNames));
1845
1991
  const args = [
1846
1992
  "git",
1847
1993
  "-c",
@@ -1860,7 +2006,7 @@ function grepTreeRef(ref, query, max, paths, regex, omitDirNames) {
1860
2006
  ];
1861
2007
  const proc = runSync(args, cwd, { timeout: 5000 });
1862
2008
  const stdout = proc.stdout;
1863
- const matches = parseGitGrepOutput(stdout, ref, max, omitDirNames).slice(0, max);
2009
+ const matches = parseGitGrepOutput(stdout, ref, max, omitDirNames, excludeNames).slice(0, max);
1864
2010
  return { ref, engine: "git", truncated: matches.length >= max, matches };
1865
2011
  }
1866
2012
  function handleGrep(url) {
@@ -1869,8 +2015,11 @@ function handleGrep(url) {
1869
2015
  const max = normalizeGrepMax(url.searchParams.get("max"));
1870
2016
  if (invalidScopeOmitDirNamesQuery(url))
1871
2017
  return text("invalid omit dirs", 400);
2018
+ if (invalidScopeExcludeNamesQuery(url))
2019
+ return text("invalid exclude names", 400);
1872
2020
  const omitDirNames = scopeOmitDirNamesFromQuery(url);
1873
- const paths = parseGrepPaths(url, omitDirNames);
2021
+ const excludeNames = scopeExcludeNamesFromQuery(url);
2022
+ const paths = parseGrepPaths(url, omitDirNames, excludeNames);
1874
2023
  const regex = url.searchParams.get("regex") === "1";
1875
2024
  if (!query.trim())
1876
2025
  return json({
@@ -1880,10 +2029,10 @@ function handleGrep(url) {
1880
2029
  matches: []
1881
2030
  });
1882
2031
  if (ref === "worktree" || ref === "")
1883
- return json(grepWorktree(query, max, paths, regex, omitDirNames));
2032
+ return json(grepWorktree(query, max, paths, regex, omitDirNames, excludeNames));
1884
2033
  if (!verifyTreeRef(ref, cwd))
1885
2034
  return text("invalid target", 400);
1886
- return json(grepTreeRef(ref, query, max, paths, regex, omitDirNames));
2035
+ return json(grepTreeRef(ref, query, max, paths, regex, omitDirNames, excludeNames));
1887
2036
  }
1888
2037
  function handleRefCommits(url) {
1889
2038
  const query = url.searchParams.get("q") || "";
@@ -2169,13 +2318,18 @@ function handleRawFile(req, url) {
2169
2318
  const size = rawFileSize(path, ref);
2170
2319
  if (size == null)
2171
2320
  return text("not in ref", 404);
2321
+ const metadata = gitFileMetadata(ref, path, size);
2172
2322
  if (req.method === "HEAD")
2173
- return new Response(null, { headers: rawFileHeaders(path, size) });
2323
+ return new Response(null, {
2324
+ headers: rawFileHeaders(path, size, undefined, metadata)
2325
+ });
2174
2326
  const res = showBytes(ref, path, cwd);
2175
2327
  if (res.code !== 0)
2176
2328
  return text("not in ref", 404);
2177
2329
  body = res.stdout.buffer.slice(res.stdout.byteOffset, res.stdout.byteOffset + res.stdout.byteLength);
2178
- return new Response(body, { headers: rawFileHeaders(path, size) });
2330
+ return new Response(body, {
2331
+ headers: rawFileHeaders(path, size, undefined, metadata)
2332
+ });
2179
2333
  } else {
2180
2334
  const full = safeWorktreePath(path);
2181
2335
  if (!full)
@@ -2183,12 +2337,13 @@ function handleRawFile(req, url) {
2183
2337
  const size = rawFileSize(path, ref);
2184
2338
  if (size == null)
2185
2339
  return text("not found", 404);
2340
+ const metadata = worktreeFileMetadata(path, size);
2186
2341
  const rangeResult = req.headers.get("range") ? parseHttpByteRange(req.headers.get("range"), size) : null;
2187
2342
  if (rangeResult?.kind === "unsatisfiable") {
2188
2343
  return new Response(null, {
2189
2344
  status: 416,
2190
2345
  headers: {
2191
- ...rawFileHeaders(path, size),
2346
+ ...rawFileHeaders(path, size, undefined, metadata),
2192
2347
  "Content-Range": `bytes */${size}`,
2193
2348
  "Content-Length": "0"
2194
2349
  }
@@ -2199,18 +2354,20 @@ function handleRawFile(req, url) {
2199
2354
  if (req.method === "HEAD") {
2200
2355
  return new Response(null, {
2201
2356
  status: 206,
2202
- headers: rawFileHeaders(path, size, range)
2357
+ headers: rawFileHeaders(path, size, range, metadata)
2203
2358
  });
2204
2359
  }
2205
2360
  return new Response(fileByteRangeResponseBody(full, range.start, range.end), {
2206
2361
  status: 206,
2207
- headers: rawFileHeaders(path, size, range)
2362
+ headers: rawFileHeaders(path, size, range, metadata)
2208
2363
  });
2209
2364
  }
2210
2365
  if (req.method === "HEAD")
2211
- return new Response(null, { headers: rawFileHeaders(path, size) });
2366
+ return new Response(null, {
2367
+ headers: rawFileHeaders(path, size, undefined, metadata)
2368
+ });
2212
2369
  return new Response(fileReadableStream(full), {
2213
- headers: rawFileHeaders(path, size)
2370
+ headers: rawFileHeaders(path, size, undefined, metadata)
2214
2371
  });
2215
2372
  }
2216
2373
  }
@@ -2230,7 +2387,7 @@ function rawFileSize(path, ref) {
2230
2387
  return null;
2231
2388
  }
2232
2389
  }
2233
- function rawFileHeaders(path, size = null, range) {
2390
+ function rawFileHeaders(path, size = null, range, metadata = {}) {
2234
2391
  const mime = {
2235
2392
  ".png": "image/png",
2236
2393
  ".jpg": "image/jpeg",
@@ -2263,6 +2420,9 @@ function rawFileHeaders(path, size = null, range) {
2263
2420
  } else if (size != null) {
2264
2421
  headers["Content-Length"] = String(size);
2265
2422
  }
2423
+ for (const [key, value] of Object.entries(fileMetadataHeaders(metadata))) {
2424
+ headers[key] = value;
2425
+ }
2266
2426
  return headers;
2267
2427
  }
2268
2428
  function isForbiddenUploadName(name) {
@@ -2385,6 +2545,152 @@ function openOsPath(path) {
2385
2545
  const cmd = process.platform === "darwin" ? ["open", "--", path] : process.platform === "win32" ? ["explorer.exe", path] : ["xdg-open", path];
2386
2546
  spawnDetached(cmd);
2387
2547
  }
2548
+ function windowsTrashScript(path) {
2549
+ const quotedPath = path.replace(/'/g, "''");
2550
+ return [
2551
+ "$ErrorActionPreference = 'Stop';",
2552
+ `$path = '${quotedPath}';`,
2553
+ "Add-Type -TypeDefinition @'",
2554
+ "using System;",
2555
+ "using System.Runtime.InteropServices;",
2556
+ "public static class CodeViewerRecycleBin {",
2557
+ " [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]",
2558
+ " public struct SHFILEOPSTRUCT {",
2559
+ " public IntPtr hwnd;",
2560
+ " public uint wFunc;",
2561
+ " public string pFrom;",
2562
+ " public string pTo;",
2563
+ " public ushort fFlags;",
2564
+ " [MarshalAs(UnmanagedType.Bool)] public bool fAnyOperationsAborted;",
2565
+ " public IntPtr hNameMappings;",
2566
+ " public string lpszProgressTitle;",
2567
+ " }",
2568
+ ' [DllImport("shell32.dll", CharSet = CharSet.Unicode)]',
2569
+ " private static extern int SHFileOperationW(ref SHFILEOPSTRUCT lpFileOp);",
2570
+ " public static void MoveToRecycleBin(string path) {",
2571
+ " const uint FO_DELETE = 0x0003;",
2572
+ " const ushort FOF_SILENT = 0x0004;",
2573
+ " const ushort FOF_NOCONFIRMATION = 0x0010;",
2574
+ " const ushort FOF_ALLOWUNDO = 0x0040;",
2575
+ " const ushort FOF_NOERRORUI = 0x0400;",
2576
+ " var op = new SHFILEOPSTRUCT {",
2577
+ " hwnd = IntPtr.Zero,",
2578
+ " wFunc = FO_DELETE,",
2579
+ ' pFrom = path + "\\0\\0",',
2580
+ " pTo = null,",
2581
+ " fFlags = (ushort)(FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT),",
2582
+ " fAnyOperationsAborted = false,",
2583
+ " hNameMappings = IntPtr.Zero,",
2584
+ " lpszProgressTitle = null",
2585
+ " };",
2586
+ " int result = SHFileOperationW(ref op);",
2587
+ ' if (result != 0) throw new InvalidOperationException("SHFileOperationW failed: " + result);',
2588
+ ' if (op.fAnyOperationsAborted) throw new OperationCanceledException("SHFileOperationW aborted");',
2589
+ " }",
2590
+ "}",
2591
+ "'@;",
2592
+ "[CodeViewerRecycleBin]::MoveToRecycleBin($path);"
2593
+ ].join(" ");
2594
+ }
2595
+ function windowsRestoreTrashScript(originalPath) {
2596
+ const quotedPath = originalPath.replace(/'/g, "''");
2597
+ return [
2598
+ "$ErrorActionPreference = 'Stop';",
2599
+ `$original = '${quotedPath}';`,
2600
+ "$parent = [System.IO.Path]::GetDirectoryName($original);",
2601
+ "$name = [System.IO.Path]::GetFileName($original);",
2602
+ "$shell = New-Object -ComObject Shell.Application;",
2603
+ "$bin = $shell.Namespace(10);",
2604
+ "$restored = $false;",
2605
+ "foreach ($item in $bin.Items()) {",
2606
+ " $deletedFrom = $item.ExtendedProperty('System.Recycle.DeletedFrom');",
2607
+ " if ($item.Name -eq $name -and $deletedFrom -eq $parent) {",
2608
+ " $item.InvokeVerb('ESTORE');",
2609
+ " $restored = $true;",
2610
+ " break;",
2611
+ " }",
2612
+ "}",
2613
+ "if (-not $restored) { throw 'recycle bin item not found'; }"
2614
+ ].join(" ");
2615
+ }
2616
+ function makeUndoId() {
2617
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
2618
+ }
2619
+ function clearMutableCaches() {
2620
+ fileCache.clear();
2621
+ metaCache.clear();
2622
+ fileListCache.clear();
2623
+ }
2624
+ function moveMacPathIntoTrash(path) {
2625
+ const trashDir = join4(homedir(), ".Trash");
2626
+ 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)}`);
2628
+ try {
2629
+ mkdirSync(trashDir, { recursive: true });
2630
+ renameSync(path, target);
2631
+ return { ok: true, trashPath: target };
2632
+ } catch (error) {
2633
+ return { ok: false, error: String(error) };
2634
+ }
2635
+ }
2636
+ function movePathToTrash(path) {
2637
+ lstatSync3(path);
2638
+ if (process.platform === "darwin") {
2639
+ return moveMacPathIntoTrash(path);
2640
+ }
2641
+ if (process.platform === "win32") {
2642
+ const res = runSync([
2643
+ "powershell.exe",
2644
+ "-NoProfile",
2645
+ "-NonInteractive",
2646
+ "-ExecutionPolicy",
2647
+ "Bypass",
2648
+ "-Command",
2649
+ windowsTrashScript(path)
2650
+ ], cwd, { timeout: 60000 });
2651
+ return res.code === 0 ? { ok: true } : { ok: false, error: res.stderr || res.stdout };
2652
+ }
2653
+ return { ok: false, error: "trash unsupported" };
2654
+ }
2655
+ function restoreTrashPath(originalPath, trashPath) {
2656
+ const parent = parentRepoPath(originalPath);
2657
+ const parentFullPath = safeOpenWorktreePath(parent);
2658
+ if (!parentFullPath)
2659
+ return { ok: false, error: "invalid restore target" };
2660
+ const original = worktreePath(originalPath);
2661
+ if (existsSync3(original))
2662
+ return { ok: false, error: "restore target exists" };
2663
+ if (trashPath) {
2664
+ if (process.platform !== "darwin")
2665
+ return { ok: false, error: "invalid trash handle" };
2666
+ if (!existsSync3(trashPath))
2667
+ return { ok: false, error: "trash item not found" };
2668
+ try {
2669
+ const trashRoot = join4(homedir(), ".Trash");
2670
+ const trashRelative = relative(trashRoot, trashPath);
2671
+ if (trashRelative === "" || trashRelative.startsWith("..") || trashRelative.startsWith("/") || trashRelative.startsWith("\\"))
2672
+ return { ok: false, error: "invalid trash handle" };
2673
+ mkdirSync(dirname2(original), { recursive: true });
2674
+ renameSync(trashPath, original);
2675
+ return { ok: true };
2676
+ } catch (error) {
2677
+ return { ok: false, error: String(error) };
2678
+ }
2679
+ }
2680
+ if (process.platform === "win32") {
2681
+ const res = runSync([
2682
+ "powershell.exe",
2683
+ "-NoProfile",
2684
+ "-NonInteractive",
2685
+ "-ExecutionPolicy",
2686
+ "Bypass",
2687
+ "-Command",
2688
+ windowsRestoreTrashScript(original)
2689
+ ], cwd, { timeout: 60000 });
2690
+ return res.code === 0 ? { ok: true } : { ok: false, error: res.stderr || res.stdout };
2691
+ }
2692
+ return { ok: false, error: "undo unavailable for this trash operation" };
2693
+ }
2388
2694
  async function handleOpenPath(req) {
2389
2695
  if (req.method !== "POST")
2390
2696
  return text("method not allowed", 405);
@@ -2425,6 +2731,87 @@ async function handleOpenPath(req) {
2425
2731
  openOsPath(target);
2426
2732
  return json({ ok: true });
2427
2733
  }
2734
+ async function handleTrashPath(req) {
2735
+ if (req.method !== "POST")
2736
+ return text("method not allowed", 405);
2737
+ if (!sideEffectRequestAllowed(req))
2738
+ return text("forbidden", 403);
2739
+ const contentType = req.headers.get("content-type") || "";
2740
+ if (!/^application\/json(?:;|$)/i.test(contentType))
2741
+ return text("unsupported media type", 415);
2742
+ const length = Number(req.headers.get("content-length") || "0");
2743
+ if (length > 1024)
2744
+ return text("payload too large", 413);
2745
+ let body = {};
2746
+ try {
2747
+ const raw = await req.text();
2748
+ if (raw.length > 1024)
2749
+ return text("payload too large", 413);
2750
+ body = JSON.parse(raw);
2751
+ } catch {
2752
+ return text("invalid json", 400);
2753
+ }
2754
+ const path = typeof body.path === "string" ? body.path.replace(/^\/+|\/+$/g, "") : "";
2755
+ if (!path)
2756
+ return text("invalid path", 400);
2757
+ if (!safeRepoPath(path))
2758
+ return text("invalid path", 400);
2759
+ if (isGitInternalPath(path))
2760
+ return text("forbidden", 403);
2761
+ const originalFullPath = safeWorktreePath(path);
2762
+ if (!originalFullPath)
2763
+ return text("not found", 404);
2764
+ const moved = movePathToTrash(worktreePath(path));
2765
+ if (!moved.ok)
2766
+ return text(moved.error || "trash failed", 500);
2767
+ const undo = {
2768
+ id: makeUndoId(),
2769
+ type: "trash",
2770
+ label: `Restore ${path}`,
2771
+ payload: {
2772
+ original_path: path,
2773
+ trashPath: moved.trashPath
2774
+ }
2775
+ };
2776
+ generation++;
2777
+ clearMutableCaches();
2778
+ sendSse("update");
2779
+ return json({ ok: true, generation, undo });
2780
+ }
2781
+ async function handleRestoreTrash(req) {
2782
+ if (req.method !== "POST")
2783
+ return text("method not allowed", 405);
2784
+ if (!sideEffectRequestAllowed(req))
2785
+ return text("forbidden", 403);
2786
+ const contentType = req.headers.get("content-type") || "";
2787
+ if (!/^application\/json(?:;|$)/i.test(contentType))
2788
+ return text("unsupported media type", 415);
2789
+ const length = Number(req.headers.get("content-length") || "0");
2790
+ if (length > 1024)
2791
+ return text("payload too large", 413);
2792
+ let body = {};
2793
+ try {
2794
+ const raw = await req.text();
2795
+ if (raw.length > 1024)
2796
+ return text("payload too large", 413);
2797
+ body = JSON.parse(raw);
2798
+ } catch {
2799
+ return text("invalid json", 400);
2800
+ }
2801
+ const originalPath = typeof body.original_path === "string" ? body.original_path.replace(/^\/+|\/+$/g, "") : "";
2802
+ const trashPath = typeof body.trashPath === "string" ? body.trashPath : "";
2803
+ if (!originalPath || !safeRepoPath(originalPath))
2804
+ return text("invalid restore target", 400);
2805
+ if (isGitInternalPath(originalPath))
2806
+ return text("forbidden", 403);
2807
+ const restored = restoreTrashPath(originalPath, trashPath || undefined);
2808
+ if (!restored.ok)
2809
+ return text(restored.error || "undo failed", 409);
2810
+ generation++;
2811
+ clearMutableCaches();
2812
+ sendSse("update");
2813
+ return json({ ok: true, generation });
2814
+ }
2428
2815
  function sendSse(event, data = "tick") {
2429
2816
  const payload = enc.encode(`event: ${event}
2430
2817
  data: ${data}
@@ -2473,6 +2860,10 @@ var server = await startServer({
2473
2860
  return handleRawFile(req, url);
2474
2861
  if (url.pathname === "/_open_path")
2475
2862
  return handleOpenPath(req);
2863
+ if (url.pathname === "/_trash_path")
2864
+ return handleTrashPath(req);
2865
+ if (url.pathname === "/_restore_trash")
2866
+ return handleRestoreTrash(req);
2476
2867
  if (url.pathname === "/_upload_files")
2477
2868
  return handleUploadFiles(req);
2478
2869
  if (url.pathname === "/_refs")
@@ -2481,9 +2872,7 @@ var server = await startServer({
2481
2872
  if (!sideEffectRequestAllowed(req))
2482
2873
  return text("forbidden", 403);
2483
2874
  generation++;
2484
- fileCache.clear();
2485
- metaCache.clear();
2486
- fileListCache.clear();
2875
+ clearMutableCaches();
2487
2876
  sendSse("update");
2488
2877
  return json({ ok: true, generation });
2489
2878
  }