@youtyan/code-viewer 0.1.25 → 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.
package/README.md CHANGED
@@ -108,6 +108,9 @@ Repository scope settings control recursive repository browsing and search scope
108
108
  for the left tree, Ctrl+K file palette, and Ctrl+G grep palette. The in-app Scope
109
109
  Settings popover stores only a browser-local override in localStorage; edit
110
110
  `.code-viewer.json` directly for project defaults shared with the repository.
111
+ Use `scope.omitDirs` for directories that should stay visible as skipped, and
112
+ `scope.excludeNames` for file or directory names that should be hidden entirely.
113
+ `.DS_Store` is hidden by default.
111
114
 
112
115
  ## Development
113
116
 
@@ -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
@@ -568,7 +571,13 @@ function omittedWorktreeDirectoryReason(name, omitDirNames) {
568
571
  return "internal";
569
572
  return omitDirNames.has(name) ? "heavy" : undefined;
570
573
  }
571
- 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
+ };
572
581
  const entryPath = base ? `${base}/${name}` : name;
573
582
  const type = isDirectory ? hasDotGitEntry(join2(dir, name)) ? "commit" : "tree" : "blob";
574
583
  const omittedReason = type === "tree" ? omittedWorktreeDirectoryReason(name, omitDirNames) : undefined;
@@ -580,14 +589,15 @@ function worktreeEntryFromDirent(base, dir, name, isDirectory, omitDirNames) {
580
589
  children_omitted_reason: omittedReason
581
590
  } : { name, path: entryPath, type };
582
591
  }
583
- function worktreeFilesystemEntries(cwd, path, recursive, omitDirNames = DEFAULT_WORKTREE_OMIT_DIR_NAMES) {
592
+ function worktreeFilesystemEntries(cwd, path, recursive, omitDirNames = DEFAULT_WORKTREE_OMIT_DIR_NAMES, excludeNames = []) {
584
593
  const base = normalizeTreePath(path);
585
594
  const root = join2(cwd, base);
586
595
  const omitDirNameSet = new Set(omitDirNames);
596
+ const excludeNameSet = new Set(excludeNames.map((name) => name.toLowerCase()));
587
597
  let directEntries;
588
598
  try {
589
599
  const dirents = readdirSync(root, { withFileTypes: true });
590
- 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));
591
601
  } catch {
592
602
  return [];
593
603
  }
@@ -624,6 +634,8 @@ function worktreeFilesystemEntries(cwd, path, recursive, omitDirNames = DEFAULT_
624
634
  return;
625
635
  }
626
636
  for (const entry of entries) {
637
+ if (excludeNameSet.has(entry.name.toLowerCase()))
638
+ continue;
627
639
  const entryPath = prefix ? `${prefix}/${entry.name}` : entry.name;
628
640
  const full = join2(dir, entry.name);
629
641
  if (entry.isDirectory()) {
@@ -699,15 +711,12 @@ function combineDirectAndRecursiveFiles(directEntries, fileEntries) {
699
711
  ...fileEntries.filter((entry) => !seen.has(entry.path))
700
712
  ];
701
713
  }
702
- function worktreeFiles(cwd) {
703
- return listTree("worktree", "", cwd, { recursive: true }).entries;
704
- }
705
714
  function listTree(ref, path, cwd, options = {}) {
706
715
  const base = normalizeTreePath(path);
707
716
  if (ref === "worktree") {
708
717
  return {
709
718
  code: 0,
710
- entries: worktreeFilesystemEntries(cwd, base, !!options.recursive, options.omitDirNames),
719
+ entries: worktreeFilesystemEntries(cwd, base, !!options.recursive, options.omitDirNames, options.excludeNames),
711
720
  stderr: ""
712
721
  };
713
722
  }
@@ -1099,17 +1108,19 @@ var GREP_DEFAULT_MAX = 200;
1099
1108
  var GREP_ABSOLUTE_MAX = 500;
1100
1109
  var GREP_MAX_FILE_BYTES = 2 * 1024 * 1024;
1101
1110
  var FILE_SEARCH_ABSOLUTE_MAX = 50000;
1111
+ var DEFAULT_EXCLUDE_NAMES = [".DS_Store"];
1102
1112
  function normalizeGrepMax(value) {
1103
1113
  const parsed = Number(value || "");
1104
1114
  if (!Number.isInteger(parsed) || parsed <= 0)
1105
1115
  return GREP_DEFAULT_MAX;
1106
1116
  return Math.min(parsed, GREP_ABSOLUTE_MAX);
1107
1117
  }
1108
- function isSkippableSearchPath(path, omitDirNames = []) {
1118
+ function isSkippableSearchPath(path, omitDirNames = [], excludeNames = []) {
1109
1119
  const omitDirs = new Set(omitDirNames.map((name) => name.toLowerCase()));
1120
+ const excluded = new Set(excludeNames.map((name) => name.toLowerCase()));
1110
1121
  return path.split(/[\\/]+/).some((part) => {
1111
1122
  const lower = part.toLowerCase();
1112
- return lower === ".git" || omitDirs.has(lower);
1123
+ return lower === ".git" || omitDirs.has(lower) || excluded.has(lower);
1113
1124
  });
1114
1125
  }
1115
1126
  function fixedStringLineMatches(path, text, query, max) {
@@ -1142,7 +1153,7 @@ function buildFileSearchList(ref, generation, entries) {
1142
1153
  truncated: entries.length > FILE_SEARCH_ABSOLUTE_MAX
1143
1154
  };
1144
1155
  }
1145
- function buildRgArgs(query, max, paths, regex = false, omitDirNames = []) {
1156
+ function buildRgArgs(query, max, paths, regex = false, omitDirNames = [], excludeNames = []) {
1146
1157
  const safePaths = paths.length ? paths : ["."];
1147
1158
  const omitGlobs = omitDirNames.flatMap((name) => [
1148
1159
  "--glob",
@@ -1150,6 +1161,12 @@ function buildRgArgs(query, max, paths, regex = false, omitDirNames = []) {
1150
1161
  "--glob",
1151
1162
  `!**/${name}/**`
1152
1163
  ]);
1164
+ const excludeGlobs = excludeNames.flatMap((name) => [
1165
+ "--glob",
1166
+ `!${name}`,
1167
+ "--glob",
1168
+ `!**/${name}`
1169
+ ]);
1153
1170
  const args = [
1154
1171
  "rg",
1155
1172
  "--no-config",
@@ -1164,6 +1181,7 @@ function buildRgArgs(query, max, paths, regex = false, omitDirNames = []) {
1164
1181
  "--max-filesize",
1165
1182
  "2M",
1166
1183
  ...omitGlobs,
1184
+ ...excludeGlobs,
1167
1185
  "-e",
1168
1186
  query,
1169
1187
  "--",
@@ -1173,7 +1191,7 @@ function buildRgArgs(query, max, paths, regex = false, omitDirNames = []) {
1173
1191
  args.splice(8, 0, "--fixed-strings");
1174
1192
  return args;
1175
1193
  }
1176
- function parseRgOutput(stdout, max, omitDirNames = []) {
1194
+ function parseRgOutput(stdout, max, omitDirNames = [], excludeNames = []) {
1177
1195
  const matches = [];
1178
1196
  for (const line of stdout.split(`
1179
1197
  `)) {
@@ -1186,7 +1204,7 @@ function parseRgOutput(stdout, max, omitDirNames = []) {
1186
1204
  const lineNo = Number(parsed[2]);
1187
1205
  const column = Number(parsed[3]);
1188
1206
  const preview = parsed[4];
1189
- if (!path || !lineNo || !column || isSkippableSearchPath(path, omitDirNames))
1207
+ if (!path || !lineNo || !column || isSkippableSearchPath(path, omitDirNames, excludeNames))
1190
1208
  continue;
1191
1209
  matches.push({
1192
1210
  path,
@@ -1197,12 +1215,12 @@ function parseRgOutput(stdout, max, omitDirNames = []) {
1197
1215
  }
1198
1216
  return matches;
1199
1217
  }
1200
- function parseGitGrepOutput(stdout, ref, max, omitDirNames = []) {
1218
+ function parseGitGrepOutput(stdout, ref, max, omitDirNames = [], excludeNames = []) {
1201
1219
  const prefix = `${ref}:`;
1202
1220
  const normalized = stdout.split(`
1203
1221
  `).map((line) => line.startsWith(prefix) ? line.slice(prefix.length) : line).join(`
1204
1222
  `);
1205
- return parseRgOutput(normalized, max, omitDirNames);
1223
+ return parseRgOutput(normalized, max, omitDirNames, excludeNames);
1206
1224
  }
1207
1225
 
1208
1226
  // web-src/server/preview.ts
@@ -1255,6 +1273,7 @@ var uploadAllowedByCli = false;
1255
1273
  var openAfterStart = false;
1256
1274
  var scopeOmitDirNames = DEFAULT_WORKTREE_OMIT_DIR_NAMES;
1257
1275
  var scopeOmitDirCliOverride = null;
1276
+ var scopeExcludeNames = DEFAULT_EXCLUDE_NAMES;
1258
1277
  var rgAvailableCache = null;
1259
1278
  var enc = new TextEncoder;
1260
1279
  var sseClients = new Set;
@@ -1329,11 +1348,14 @@ Examples:
1329
1348
  if (!uploadAllowedByCli)
1330
1349
  allowUpload = loadProjectConfigUploadEnabled();
1331
1350
  const configScopeOmitDirs = loadProjectConfigScopeOmitDirs();
1351
+ const configScopeExcludeNames = loadProjectConfigScopeExcludeNames();
1332
1352
  if (scopeOmitDirCliOverride) {
1333
1353
  scopeOmitDirNames = scopeOmitDirCliOverride;
1334
1354
  } else if (configScopeOmitDirs) {
1335
1355
  scopeOmitDirNames = configScopeOmitDirs;
1336
1356
  }
1357
+ if (configScopeExcludeNames)
1358
+ scopeExcludeNames = configScopeExcludeNames;
1337
1359
  }
1338
1360
  function json(data, init = {}) {
1339
1361
  return new Response(JSON.stringify(data), {
@@ -1606,6 +1628,13 @@ function normalizeScopeOmitDirNames(names) {
1606
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"))
1607
1629
  ].sort((a, b) => a.localeCompare(b));
1608
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
+ }
1609
1638
  function parseScopeOmitDirNamesQuery(value) {
1610
1639
  const names = value ? value.split(",") : [];
1611
1640
  if (names.length > 100)
@@ -1617,6 +1646,17 @@ function parseScopeOmitDirNamesQuery(value) {
1617
1646
  }
1618
1647
  return normalizeScopeOmitDirNames(names);
1619
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
+ }
1620
1660
  function loadProjectConfig() {
1621
1661
  const full = join4(cwd, ".code-viewer.json");
1622
1662
  if (!existsSync3(full))
@@ -1650,14 +1690,31 @@ function loadProjectConfigScopeOmitDirs() {
1650
1690
  return null;
1651
1691
  return normalizeScopeOmitDirNames(config.scope.omitDirs);
1652
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
+ }
1653
1699
  function scopeOmitDirNamesFromQuery(url) {
1654
1700
  if (!url.searchParams.has("omit_dirs"))
1655
1701
  return scopeOmitDirNames;
1656
1702
  return parseScopeOmitDirNamesQuery(url.searchParams.get("omit_dirs") || "") || scopeOmitDirNames;
1657
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
+ }
1658
1709
  function invalidScopeOmitDirNamesQuery(url) {
1659
1710
  return url.searchParams.has("omit_dirs") && !parseScopeOmitDirNamesQuery(url.searchParams.get("omit_dirs") || "");
1660
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
+ }
1661
1718
  function isGitInternalPath(path) {
1662
1719
  return path.split(/[\\/]+/).some((part) => part.toLowerCase() === ".git");
1663
1720
  }
@@ -1684,6 +1741,9 @@ function safeWorktreePath(path) {
1684
1741
  return null;
1685
1742
  return realFull;
1686
1743
  }
1744
+ function worktreePath(path) {
1745
+ return join4(cwd, path);
1746
+ }
1687
1747
  function safeOpenWorktreePath(path) {
1688
1748
  if (path === "") {
1689
1749
  try {
@@ -1798,10 +1858,14 @@ function handleTree(url) {
1798
1858
  const recursive = url.searchParams.get("recursive") === "1";
1799
1859
  if (invalidScopeOmitDirNamesQuery(url))
1800
1860
  return text("invalid omit dirs", 400);
1861
+ if (invalidScopeExcludeNamesQuery(url))
1862
+ return text("invalid exclude names", 400);
1863
+ const excludeNames = scopeExcludeNamesFromQuery(url);
1801
1864
  const entries = listTree(target, path, cwd, {
1802
1865
  recursive,
1803
- omitDirNames: scopeOmitDirNamesFromQuery(url)
1804
- }).entries;
1866
+ omitDirNames: scopeOmitDirNamesFromQuery(url),
1867
+ excludeNames
1868
+ }).entries.filter((entry) => !isExcludedScopePath(entry.path, excludeNames));
1805
1869
  return json({
1806
1870
  ref: target,
1807
1871
  path,
@@ -1818,6 +1882,8 @@ function handleSettings() {
1818
1882
  scope: {
1819
1883
  omit_dirs_effective: scopeOmitDirNames,
1820
1884
  omit_dirs_built_in: DEFAULT_WORKTREE_OMIT_DIR_NAMES,
1885
+ exclude_names_effective: scopeExcludeNames,
1886
+ exclude_names_built_in: DEFAULT_EXCLUDE_NAMES,
1821
1887
  max_entries: WORKTREE_RECURSIVE_ENTRY_LIMIT
1822
1888
  }
1823
1889
  });
@@ -1828,22 +1894,26 @@ function handleFiles(url) {
1828
1894
  return text("invalid target", 400);
1829
1895
  if (invalidScopeOmitDirNamesQuery(url))
1830
1896
  return text("invalid omit dirs", 400);
1897
+ if (invalidScopeExcludeNamesQuery(url))
1898
+ return text("invalid exclude names", 400);
1831
1899
  const omitDirNames = scopeOmitDirNamesFromQuery(url);
1832
- 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")}`;
1833
1902
  const cached = fileListCache.get(key);
1834
1903
  if (cached && cached.generation === generation)
1835
1904
  return json(cached.body);
1836
1905
  const ref = target || "worktree";
1837
1906
  const entries = listTree(ref, "", cwd, {
1838
1907
  recursive: true,
1839
- omitDirNames
1840
- }).entries;
1908
+ omitDirNames,
1909
+ excludeNames
1910
+ }).entries.filter((entry) => !isExcludedScopePath(entry.path, excludeNames));
1841
1911
  const body = buildFileSearchList(ref, generation, entries);
1842
1912
  fileListCache.set(key, { generation, body });
1843
1913
  return json(body);
1844
1914
  }
1845
- function parseGrepPaths(url, omitDirNames) {
1846
- 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));
1847
1917
  }
1848
1918
  function rgAvailable() {
1849
1919
  if (rgAvailableCache !== null)
@@ -1852,13 +1922,17 @@ function rgAvailable() {
1852
1922
  rgAvailableCache = proc.code === 0;
1853
1923
  return rgAvailableCache;
1854
1924
  }
1855
- function grepWorktreeFallback(query, max, paths, omitDirNames) {
1856
- 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);
1857
1931
  const matches = [];
1858
1932
  for (const path of candidates) {
1859
1933
  if (matches.length >= max)
1860
1934
  break;
1861
- if (!safePath(path) || isGitInternalPath(path) || isSkippableSearchPath(path, omitDirNames))
1935
+ if (!safePath(path) || isGitInternalPath(path) || isSkippableSearchPath(path, omitDirNames, excludeNames))
1862
1936
  continue;
1863
1937
  const full = safeWorktreePath(path);
1864
1938
  if (!full)
@@ -1883,13 +1957,13 @@ function grepWorktreeFallback(query, max, paths, omitDirNames) {
1883
1957
  }
1884
1958
  return matches;
1885
1959
  }
1886
- function grepWorktree(query, max, paths, regex, omitDirNames) {
1960
+ function grepWorktree(query, max, paths, regex, omitDirNames, excludeNames) {
1887
1961
  if (rgAvailable()) {
1888
- const safePaths = paths.filter((path) => safePath(path) && !isGitInternalPath(path) && !isSkippableSearchPath(path, omitDirNames) && safeWorktreePath(path));
1889
- 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);
1890
1964
  const proc = runSync(args, cwd, { timeout: 5000 });
1891
1965
  const stdout = proc.stdout;
1892
- 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));
1893
1967
  return {
1894
1968
  ref: "worktree",
1895
1969
  engine: "rg",
@@ -1904,7 +1978,7 @@ function grepWorktree(query, max, paths, regex, omitDirNames) {
1904
1978
  truncated: false,
1905
1979
  matches: []
1906
1980
  };
1907
- const matches = grepWorktreeFallback(query, max, paths, omitDirNames);
1981
+ const matches = grepWorktreeFallback(query, max, paths, omitDirNames, excludeNames);
1908
1982
  return {
1909
1983
  ref: "worktree",
1910
1984
  engine: "fallback",
@@ -1912,8 +1986,8 @@ function grepWorktree(query, max, paths, regex, omitDirNames) {
1912
1986
  matches
1913
1987
  };
1914
1988
  }
1915
- function grepTreeRef(ref, query, max, paths, regex, omitDirNames) {
1916
- 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));
1917
1991
  const args = [
1918
1992
  "git",
1919
1993
  "-c",
@@ -1932,7 +2006,7 @@ function grepTreeRef(ref, query, max, paths, regex, omitDirNames) {
1932
2006
  ];
1933
2007
  const proc = runSync(args, cwd, { timeout: 5000 });
1934
2008
  const stdout = proc.stdout;
1935
- const matches = parseGitGrepOutput(stdout, ref, max, omitDirNames).slice(0, max);
2009
+ const matches = parseGitGrepOutput(stdout, ref, max, omitDirNames, excludeNames).slice(0, max);
1936
2010
  return { ref, engine: "git", truncated: matches.length >= max, matches };
1937
2011
  }
1938
2012
  function handleGrep(url) {
@@ -1941,8 +2015,11 @@ function handleGrep(url) {
1941
2015
  const max = normalizeGrepMax(url.searchParams.get("max"));
1942
2016
  if (invalidScopeOmitDirNamesQuery(url))
1943
2017
  return text("invalid omit dirs", 400);
2018
+ if (invalidScopeExcludeNamesQuery(url))
2019
+ return text("invalid exclude names", 400);
1944
2020
  const omitDirNames = scopeOmitDirNamesFromQuery(url);
1945
- const paths = parseGrepPaths(url, omitDirNames);
2021
+ const excludeNames = scopeExcludeNamesFromQuery(url);
2022
+ const paths = parseGrepPaths(url, omitDirNames, excludeNames);
1946
2023
  const regex = url.searchParams.get("regex") === "1";
1947
2024
  if (!query.trim())
1948
2025
  return json({
@@ -1952,10 +2029,10 @@ function handleGrep(url) {
1952
2029
  matches: []
1953
2030
  });
1954
2031
  if (ref === "worktree" || ref === "")
1955
- return json(grepWorktree(query, max, paths, regex, omitDirNames));
2032
+ return json(grepWorktree(query, max, paths, regex, omitDirNames, excludeNames));
1956
2033
  if (!verifyTreeRef(ref, cwd))
1957
2034
  return text("invalid target", 400);
1958
- return json(grepTreeRef(ref, query, max, paths, regex, omitDirNames));
2035
+ return json(grepTreeRef(ref, query, max, paths, regex, omitDirNames, excludeNames));
1959
2036
  }
1960
2037
  function handleRefCommits(url) {
1961
2038
  const query = url.searchParams.get("q") || "";
@@ -2468,6 +2545,152 @@ function openOsPath(path) {
2468
2545
  const cmd = process.platform === "darwin" ? ["open", "--", path] : process.platform === "win32" ? ["explorer.exe", path] : ["xdg-open", path];
2469
2546
  spawnDetached(cmd);
2470
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
+ }
2471
2694
  async function handleOpenPath(req) {
2472
2695
  if (req.method !== "POST")
2473
2696
  return text("method not allowed", 405);
@@ -2508,6 +2731,87 @@ async function handleOpenPath(req) {
2508
2731
  openOsPath(target);
2509
2732
  return json({ ok: true });
2510
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
+ }
2511
2815
  function sendSse(event, data = "tick") {
2512
2816
  const payload = enc.encode(`event: ${event}
2513
2817
  data: ${data}
@@ -2556,6 +2860,10 @@ var server = await startServer({
2556
2860
  return handleRawFile(req, url);
2557
2861
  if (url.pathname === "/_open_path")
2558
2862
  return handleOpenPath(req);
2863
+ if (url.pathname === "/_trash_path")
2864
+ return handleTrashPath(req);
2865
+ if (url.pathname === "/_restore_trash")
2866
+ return handleRestoreTrash(req);
2559
2867
  if (url.pathname === "/_upload_files")
2560
2868
  return handleUploadFiles(req);
2561
2869
  if (url.pathname === "/_refs")
@@ -2564,9 +2872,7 @@ var server = await startServer({
2564
2872
  if (!sideEffectRequestAllowed(req))
2565
2873
  return text("forbidden", 403);
2566
2874
  generation++;
2567
- fileCache.clear();
2568
- metaCache.clear();
2569
- fileListCache.clear();
2875
+ clearMutableCaches();
2570
2876
  sendSse("update");
2571
2877
  return json({ ok: true, generation });
2572
2878
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youtyan/code-viewer",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "description": "Local browser-based code and git diff viewer",
5
5
  "type": "module",
6
6
  "bin": {
package/web/app.js CHANGED
@@ -6947,6 +6947,8 @@ ${frontmatter.yaml}
6947
6947
  direction: "asc"
6948
6948
  };
6949
6949
  let SERVER_SCOPE_OMIT_DIRS_DEFAULT = [];
6950
+ let SERVER_SCOPE_EXCLUDE_NAMES_DEFAULT = [];
6951
+ const UNDO_STACK = [];
6950
6952
  let PENDING_G_SCOPE = null;
6951
6953
  let PENDING_G_UNTIL = 0;
6952
6954
  let SOURCE_CURSOR = null;
@@ -6954,6 +6956,7 @@ ${frontmatter.yaml}
6954
6956
  const HELP_LANGUAGES = ["en", "ja"];
6955
6957
  const HELP_SECTIONS = ["keybindings"];
6956
6958
  const SCOPE_OMIT_DIRS_STORAGE_KEY_PREFIX = "gdp:scope-omit-dirs:";
6959
+ const SCOPE_EXCLUDE_NAMES_STORAGE_KEY_PREFIX = "gdp:scope-exclude-names:";
6957
6960
  const SIDEBAR_FONT_SIZE_STORAGE_KEY = "gdp:sidebar-font-size";
6958
6961
  const CODE_FONT_SIZE_STORAGE_KEY = "gdp:code-font-size";
6959
6962
  const CLIENT_SCOPE_OMIT_DIRS_DEFAULT = [
@@ -6984,6 +6987,7 @@ ${frontmatter.yaml}
6984
6987
  "bin",
6985
6988
  "obj"
6986
6989
  ];
6990
+ const CLIENT_SCOPE_EXCLUDE_NAMES_DEFAULT = [".DS_Store"];
6987
6991
  const HELP_CONTENT = {
6988
6992
  en: {
6989
6993
  languageLabel: "Language",
@@ -7255,9 +7259,18 @@ ${frontmatter.yaml}
7255
7259
  ...new Set(raw.map((item) => item.trim()).filter((item) => item && item.length <= 64 && !item.includes("/") && !item.includes("\\") && item !== "." && item !== ".." && item !== ".git"))
7256
7260
  ].slice(0, 100).sort((a2, b2) => a2.localeCompare(b2));
7257
7261
  }
7262
+ function normalizeScopeExcludeNames(value) {
7263
+ const raw = Array.isArray(value) ? value : value.split(/[\n,]+/);
7264
+ return [
7265
+ ...new Set(raw.map((item) => item.trim()).filter((item) => item && item.length <= 128 && !item.includes("/") && !item.includes("\\") && item !== "." && item !== ".." && item !== ".git"))
7266
+ ].slice(0, 200).sort((a2, b2) => a2.localeCompare(b2));
7267
+ }
7258
7268
  function scopeOmitDirsStorageKey() {
7259
7269
  return SCOPE_OMIT_DIRS_STORAGE_KEY_PREFIX + (PROJECT_NAME || "default");
7260
7270
  }
7271
+ function scopeExcludeNamesStorageKey() {
7272
+ return SCOPE_EXCLUDE_NAMES_STORAGE_KEY_PREFIX + (PROJECT_NAME || "default");
7273
+ }
7261
7274
  function setProjectName(project) {
7262
7275
  if (!project)
7263
7276
  return;
@@ -7275,16 +7288,36 @@ ${frontmatter.yaml}
7275
7288
  return normalizeScopeOmitDirs(raw);
7276
7289
  }
7277
7290
  }
7291
+ function savedScopeExcludeNames() {
7292
+ const raw = localStorage.getItem(scopeExcludeNamesStorageKey());
7293
+ if (raw == null)
7294
+ return null;
7295
+ try {
7296
+ const parsed = JSON.parse(raw);
7297
+ return normalizeScopeExcludeNames(Array.isArray(parsed) ? parsed : []);
7298
+ } catch {
7299
+ return normalizeScopeExcludeNames(raw);
7300
+ }
7301
+ }
7278
7302
  function serverScopeOmitDirsDefault() {
7279
7303
  return SERVER_SCOPE_OMIT_DIRS_DEFAULT.length ? SERVER_SCOPE_OMIT_DIRS_DEFAULT : CLIENT_SCOPE_OMIT_DIRS_DEFAULT;
7280
7304
  }
7305
+ function serverScopeExcludeNamesDefault() {
7306
+ return SERVER_SCOPE_EXCLUDE_NAMES_DEFAULT.length ? SERVER_SCOPE_EXCLUDE_NAMES_DEFAULT : CLIENT_SCOPE_EXCLUDE_NAMES_DEFAULT;
7307
+ }
7281
7308
  function effectiveScopeOmitDirs() {
7282
7309
  return savedScopeOmitDirs() ?? serverScopeOmitDirsDefault();
7283
7310
  }
7284
- function appendScopeOmitDirsParam(params) {
7285
- const saved = savedScopeOmitDirs();
7286
- if (saved != null)
7287
- params.set("omit_dirs", saved.join(","));
7311
+ function effectiveScopeExcludeNames() {
7312
+ return savedScopeExcludeNames() ?? serverScopeExcludeNamesDefault();
7313
+ }
7314
+ function appendScopeParams(params) {
7315
+ const omit = savedScopeOmitDirs();
7316
+ if (omit != null)
7317
+ params.set("omit_dirs", omit.join(","));
7318
+ const exclude = savedScopeExcludeNames();
7319
+ if (exclude != null)
7320
+ params.set("exclude_names", exclude.join(","));
7288
7321
  }
7289
7322
  function normalizeViewerFontSize(value) {
7290
7323
  return value === "compact" || value === "large" || value === "xlarge" ? value : "regular";
@@ -7319,8 +7352,9 @@ ${frontmatter.yaml}
7319
7352
  syncSidebarHeaderHeight();
7320
7353
  }
7321
7354
  function repoFileCacheKey(ref) {
7322
- const saved = savedScopeOmitDirs();
7323
- return `${ref}\x00${saved ? saved.join("\x00") : "server"}`;
7355
+ const omit = savedScopeOmitDirs();
7356
+ const exclude = savedScopeExcludeNames();
7357
+ return `${ref}\x00${omit ? omit.join("\x00") : "server"}\x00${exclude ? exclude.join("\x00") : "server"}`;
7324
7358
  }
7325
7359
  async function loadSettings() {
7326
7360
  try {
@@ -7330,6 +7364,7 @@ ${frontmatter.yaml}
7330
7364
  const settings = await res.json();
7331
7365
  setProjectName(settings.project || "");
7332
7366
  SERVER_SCOPE_OMIT_DIRS_DEFAULT = normalizeScopeOmitDirs(settings.scope.omit_dirs_effective);
7367
+ SERVER_SCOPE_EXCLUDE_NAMES_DEFAULT = normalizeScopeExcludeNames(settings.scope.exclude_names_effective);
7333
7368
  return settings;
7334
7369
  } catch {
7335
7370
  return null;
@@ -7671,7 +7706,7 @@ ${frontmatter.yaml}
7671
7706
  applySidebarHidden(!STATE.sidebarHidden);
7672
7707
  }
7673
7708
  function scopeOmitSourceLabel() {
7674
- return savedScopeOmitDirs() != null ? "Browser override" : "Server default";
7709
+ return savedScopeOmitDirs() != null || savedScopeExcludeNames() != null ? "Browser override" : "Server default";
7675
7710
  }
7676
7711
  function refreshRepositoryTreeAfterSettings() {
7677
7712
  REPO_FILE_CACHE.clear();
@@ -7687,15 +7722,18 @@ ${frontmatter.yaml}
7687
7722
  async function openScopeSettings() {
7688
7723
  const pop = document.querySelector("#scope-settings-popover");
7689
7724
  const input = document.querySelector("#scope-omit-dirs");
7725
+ const excludeInput = document.querySelector("#scope-exclude-names");
7690
7726
  const sidebarFontSize = document.querySelector("#sidebar-font-size");
7691
7727
  const codeFontSize = document.querySelector("#code-font-size");
7692
7728
  const source = document.querySelector("#scope-omit-source");
7693
- if (!pop || !input || !sidebarFontSize || !codeFontSize || !source)
7729
+ if (!pop || !input || !excludeInput || !sidebarFontSize || !codeFontSize || !source)
7694
7730
  return;
7695
7731
  await loadSettings();
7696
7732
  sidebarFontSize.value = savedSidebarFontSize();
7697
7733
  codeFontSize.value = savedCodeFontSize();
7698
7734
  input.value = effectiveScopeOmitDirs().join(`
7735
+ `);
7736
+ excludeInput.value = effectiveScopeExcludeNames().join(`
7699
7737
  `);
7700
7738
  source.textContent = 'Saved for project "' + (PROJECT_NAME || "default") + '" in this browser. Source: ' + scopeOmitSourceLabel() + ". Used by tree, Ctrl+K, and Ctrl+G. Reset removes the browser override.";
7701
7739
  pop.hidden = false;
@@ -7708,15 +7746,17 @@ ${frontmatter.yaml}
7708
7746
  }
7709
7747
  function saveScopeSettings() {
7710
7748
  const input = document.querySelector("#scope-omit-dirs");
7749
+ const excludeInput = document.querySelector("#scope-exclude-names");
7711
7750
  const sidebarFontSize = document.querySelector("#sidebar-font-size");
7712
7751
  const codeFontSize = document.querySelector("#code-font-size");
7713
- if (!input || !sidebarFontSize || !codeFontSize)
7752
+ if (!input || !excludeInput || !sidebarFontSize || !codeFontSize)
7714
7753
  return;
7715
7754
  localStorage.setItem(SIDEBAR_FONT_SIZE_STORAGE_KEY, normalizeViewerFontSize(sidebarFontSize.value));
7716
7755
  localStorage.setItem(CODE_FONT_SIZE_STORAGE_KEY, normalizeViewerFontSize(codeFontSize.value));
7717
7756
  applySidebarFontSize();
7718
7757
  applyCodeFontSize();
7719
7758
  localStorage.setItem(scopeOmitDirsStorageKey(), JSON.stringify(normalizeScopeOmitDirs(input.value)));
7759
+ localStorage.setItem(scopeExcludeNamesStorageKey(), JSON.stringify(normalizeScopeExcludeNames(excludeInput.value)));
7720
7760
  closeScopeSettings();
7721
7761
  refreshRepositoryTreeAfterSettings();
7722
7762
  }
@@ -7726,6 +7766,7 @@ ${frontmatter.yaml}
7726
7766
  applySidebarFontSize("regular");
7727
7767
  applyCodeFontSize("regular");
7728
7768
  localStorage.removeItem(scopeOmitDirsStorageKey());
7769
+ localStorage.removeItem(scopeExcludeNamesStorageKey());
7729
7770
  closeScopeSettings();
7730
7771
  refreshRepositoryTreeAfterSettings();
7731
7772
  }
@@ -7809,6 +7850,8 @@ ${frontmatter.yaml}
7809
7850
  li.className = "tree-dir";
7810
7851
  li.tabIndex = -1;
7811
7852
  li.dataset.dirpath = dir.path;
7853
+ if (dir.children_omitted_reason)
7854
+ li.dataset.childrenOmittedReason = dir.children_omitted_reason;
7812
7855
  if (dir.explicit)
7813
7856
  li.dataset.explicit = "true";
7814
7857
  if (dir.children_omitted) {
@@ -7952,6 +7995,8 @@ ${frontmatter.yaml}
7952
7995
  li.className = "tree-dir";
7953
7996
  li.tabIndex = -1;
7954
7997
  li.dataset.dirpath = dir.path;
7998
+ if (dir.children_omitted_reason)
7999
+ li.dataset.childrenOmittedReason = dir.children_omitted_reason;
7955
8000
  if (dir.explicit)
7956
8001
  li.dataset.explicit = "true";
7957
8002
  if (dir.children_omitted) {
@@ -8944,6 +8989,224 @@ ${frontmatter.yaml}
8944
8989
  button.disabled = false;
8945
8990
  }
8946
8991
  }
8992
+ function closeRepoContextMenu() {
8993
+ document.querySelector(".gdp-context-menu")?.remove();
8994
+ }
8995
+ function closeTrashDialog() {
8996
+ document.querySelector(".gdp-trash-dialog-backdrop")?.remove();
8997
+ }
8998
+ function createTrashDialog(title, body, actions) {
8999
+ closeTrashDialog();
9000
+ const backdrop = document.createElement("div");
9001
+ backdrop.className = "gdp-trash-dialog-backdrop";
9002
+ const dialog = document.createElement("div");
9003
+ dialog.className = "gdp-trash-dialog";
9004
+ const titleId = "gdp-trash-dialog-title";
9005
+ const bodyId = "gdp-trash-dialog-body";
9006
+ dialog.setAttribute("role", "dialog");
9007
+ dialog.setAttribute("aria-modal", "true");
9008
+ dialog.setAttribute("aria-labelledby", titleId);
9009
+ dialog.setAttribute("aria-describedby", bodyId);
9010
+ const heading2 = document.createElement("div");
9011
+ heading2.id = titleId;
9012
+ heading2.className = "gdp-trash-dialog-title";
9013
+ heading2.textContent = title;
9014
+ const message = document.createElement("div");
9015
+ message.id = bodyId;
9016
+ message.className = "gdp-trash-dialog-body";
9017
+ message.textContent = body;
9018
+ const actionRow = document.createElement("div");
9019
+ actionRow.className = "gdp-trash-dialog-actions";
9020
+ actionRow.append(...actions);
9021
+ dialog.append(heading2, message, actionRow);
9022
+ backdrop.appendChild(dialog);
9023
+ document.body.appendChild(backdrop);
9024
+ return backdrop;
9025
+ }
9026
+ function confirmMoveToTrash(path, focusReturnTarget) {
9027
+ return new Promise((resolve) => {
9028
+ const previousFocus = focusReturnTarget || document.activeElement;
9029
+ const cancel = document.createElement("button");
9030
+ cancel.type = "button";
9031
+ cancel.className = "gdp-btn gdp-btn-sm";
9032
+ cancel.textContent = "Cancel";
9033
+ const move = document.createElement("button");
9034
+ move.type = "button";
9035
+ move.className = "gdp-btn gdp-btn-sm gdp-trash-dialog-danger";
9036
+ move.textContent = "Move to Trash";
9037
+ const done = (ok) => {
9038
+ document.removeEventListener("keydown", onKeydown);
9039
+ closeTrashDialog();
9040
+ previousFocus?.focus?.();
9041
+ resolve(ok);
9042
+ };
9043
+ const onKeydown = (event) => {
9044
+ if (event.key === "Escape") {
9045
+ event.preventDefault();
9046
+ event.stopPropagation();
9047
+ done(false);
9048
+ return;
9049
+ }
9050
+ if (event.key !== "Tab")
9051
+ return;
9052
+ const focusables = [cancel, move];
9053
+ const index = focusables.indexOf(document.activeElement);
9054
+ if (index < 0) {
9055
+ event.preventDefault();
9056
+ focusables[0].focus();
9057
+ return;
9058
+ }
9059
+ if (event.shiftKey && index <= 0) {
9060
+ event.preventDefault();
9061
+ focusables[focusables.length - 1].focus();
9062
+ } else if (!event.shiftKey && index === focusables.length - 1) {
9063
+ event.preventDefault();
9064
+ focusables[0].focus();
9065
+ }
9066
+ };
9067
+ cancel.addEventListener("click", () => done(false));
9068
+ move.addEventListener("click", () => done(true));
9069
+ const backdrop = createTrashDialog("Move to Trash?", `Move "${path}" to Trash?`, [cancel, move]);
9070
+ backdrop.addEventListener("pointerdown", (event) => {
9071
+ if (event.target === backdrop)
9072
+ done(false);
9073
+ });
9074
+ document.addEventListener("keydown", onKeydown);
9075
+ cancel.focus();
9076
+ });
9077
+ }
9078
+ function showTrashError(message) {
9079
+ const ok = document.createElement("button");
9080
+ ok.type = "button";
9081
+ ok.className = "gdp-btn gdp-btn-sm";
9082
+ ok.textContent = "OK";
9083
+ ok.addEventListener("click", closeTrashDialog);
9084
+ createTrashDialog("Trash failed", message, [ok]);
9085
+ ok.focus();
9086
+ }
9087
+ async function moveRepoPathToTrash(path) {
9088
+ const res = await fetch("/_trash_path", {
9089
+ method: "POST",
9090
+ headers: {
9091
+ "Content-Type": "application/json",
9092
+ "X-Code-Viewer-Action": "1"
9093
+ },
9094
+ body: JSON.stringify({ path })
9095
+ });
9096
+ if (!res.ok) {
9097
+ showTrashError(`Failed to move "${path}" to Trash: ${await res.text()}`);
9098
+ return false;
9099
+ }
9100
+ const body = await res.json();
9101
+ if (body.undo)
9102
+ UNDO_STACK.unshift(body.undo);
9103
+ return true;
9104
+ }
9105
+ async function runUndoAction(action) {
9106
+ if (action.type !== "trash")
9107
+ return false;
9108
+ const res = await fetch("/_restore_trash", {
9109
+ method: "POST",
9110
+ headers: {
9111
+ "Content-Type": "application/json",
9112
+ "X-Code-Viewer-Action": "1"
9113
+ },
9114
+ body: JSON.stringify(action.payload)
9115
+ });
9116
+ if (!res.ok) {
9117
+ showTrashError(`Failed to undo "${action.label}": ${await res.text()}`);
9118
+ return false;
9119
+ }
9120
+ return true;
9121
+ }
9122
+ async function undoLastAction() {
9123
+ const action = UNDO_STACK.shift();
9124
+ if (!action)
9125
+ return false;
9126
+ if (!await runUndoAction(action)) {
9127
+ UNDO_STACK.unshift(action);
9128
+ return true;
9129
+ }
9130
+ invalidateRepoSidebar();
9131
+ await load();
9132
+ return true;
9133
+ }
9134
+ async function requestMoveToTrash(path, onMoved, options = {}) {
9135
+ if (!await confirmMoveToTrash(path, options.focusReturnTarget))
9136
+ return;
9137
+ if (await moveRepoPathToTrash(path))
9138
+ onMoved();
9139
+ }
9140
+ function canTrashWorktreeRef(ref) {
9141
+ return ref === "worktree" || ref === "";
9142
+ }
9143
+ function showRepoContextMenu(event, entry, ref, onDeleted) {
9144
+ if (document.querySelector(".gdp-trash-dialog-backdrop"))
9145
+ return false;
9146
+ if (!canTrashWorktreeRef(ref))
9147
+ return false;
9148
+ if (entry.children_omitted_reason === "internal")
9149
+ return false;
9150
+ event.preventDefault();
9151
+ closeRepoContextMenu();
9152
+ const menu = document.createElement("div");
9153
+ menu.className = "gdp-context-menu";
9154
+ const anchor = event.target;
9155
+ const focusReturnTarget = anchor?.closest("li, .gdp-repo-row");
9156
+ const anchorRect = anchor?.closest("li, .gdp-repo-row")?.getBoundingClientRect();
9157
+ const anchorX = event.clientX > 0 ? event.clientX : anchorRect?.left || window.innerWidth / 2;
9158
+ const anchorY = event.clientY > 0 ? event.clientY : anchorRect?.bottom || window.innerHeight / 2;
9159
+ menu.style.left = `${anchorX}px`;
9160
+ menu.style.top = `${anchorY}px`;
9161
+ const trash = document.createElement("button");
9162
+ trash.type = "button";
9163
+ trash.className = "danger";
9164
+ trash.textContent = "Move to Trash...";
9165
+ trash.addEventListener("click", async () => {
9166
+ closeRepoContextMenu();
9167
+ await requestMoveToTrash(entry.path, onDeleted, { focusReturnTarget });
9168
+ });
9169
+ menu.appendChild(trash);
9170
+ document.body.appendChild(menu);
9171
+ const rect = menu.getBoundingClientRect();
9172
+ const left = Math.min(anchorX, window.innerWidth - rect.width - 8);
9173
+ const top = Math.min(anchorY, window.innerHeight - rect.height - 8);
9174
+ menu.style.left = `${Math.max(8, left)}px`;
9175
+ menu.style.top = `${Math.max(8, top)}px`;
9176
+ return true;
9177
+ }
9178
+ function sidebarTrashEntryFromEvent(event) {
9179
+ if (!isRepositorySidebarMode())
9180
+ return null;
9181
+ const row = event.target?.closest("#filelist li");
9182
+ if (!row)
9183
+ return null;
9184
+ const path = row.dataset.path || row.dataset.dirpath || "";
9185
+ if (!path)
9186
+ return null;
9187
+ return {
9188
+ path,
9189
+ children_omitted_reason: row.dataset.childrenOmittedReason
9190
+ };
9191
+ }
9192
+ function handleSidebarContextMenu(event) {
9193
+ const entry = sidebarTrashEntryFromEvent(event);
9194
+ if (!entry)
9195
+ return;
9196
+ if (showRepoContextMenu(event, entry, REPO_SIDEBAR_REF || "worktree", () => loadRepo()))
9197
+ markActive(entry.path);
9198
+ }
9199
+ function createMoveToTrashButton(path, onDeleted) {
9200
+ const button = document.createElement("button");
9201
+ button.type = "button";
9202
+ button.className = "gdp-btn gdp-btn-sm gdp-trash-path";
9203
+ button.textContent = "Move to Trash";
9204
+ button.addEventListener("click", async (event) => {
9205
+ event.stopPropagation();
9206
+ await requestMoveToTrash(path, onDeleted, { focusReturnTarget: button });
9207
+ });
9208
+ return button;
9209
+ }
8947
9210
  function createOpenPathButton(path, kind, title = "open folder in OS") {
8948
9211
  const button = document.createElement("button");
8949
9212
  button.type = "button";
@@ -9198,6 +9461,7 @@ ${frontmatter.yaml}
9198
9461
  renderStandaloneSource({ path: entry.path, ref: meta.ref });
9199
9462
  }
9200
9463
  });
9464
+ row.addEventListener("contextmenu", (event) => showRepoContextMenu(event, entry, meta.ref, () => loadRepo()));
9201
9465
  list2.appendChild(row);
9202
9466
  });
9203
9467
  if (!meta.entries.length) {
@@ -9271,7 +9535,7 @@ ${frontmatter.yaml}
9271
9535
  const params = new URLSearchParams;
9272
9536
  params.set("ref", normalizedRef);
9273
9537
  params.set("recursive", "1");
9274
- appendScopeOmitDirsParam(params);
9538
+ appendScopeParams(params);
9275
9539
  REPO_SIDEBAR_LOAD_REF = normalizedRef;
9276
9540
  const load2 = trackLoad(fetch(`/_tree?${params.toString()}`).then((r2) => {
9277
9541
  if (!r2.ok)
@@ -9289,6 +9553,7 @@ ${frontmatter.yaml}
9289
9553
  children_omitted: entry.children_omitted,
9290
9554
  children_omitted_reason: entry.children_omitted_reason
9291
9555
  }));
9556
+ REPO_SIDEBAR_REF = normalizedRef;
9292
9557
  renderSidebar(files, (file) => {
9293
9558
  if (file.type === "tree") {
9294
9559
  setRoute(repoRoute(normalizedRef, file.path));
@@ -9304,7 +9569,6 @@ ${frontmatter.yaml}
9304
9569
  });
9305
9570
  renderStandaloneSource({ path: file.path, ref: normalizedRef });
9306
9571
  });
9307
- REPO_SIDEBAR_REF = normalizedRef;
9308
9572
  activateRepoSidebarPath(currentPath);
9309
9573
  }).catch(() => {
9310
9574
  REPO_SIDEBAR_REF = null;
@@ -11579,6 +11843,13 @@ ${frontmatter.yaml}
11579
11843
  name.appendChild(copy);
11580
11844
  name.appendChild(createOpenPathButton(target.path, "file-parent", "open parent folder in OS"));
11581
11845
  header.appendChild(name);
11846
+ if (repoTarget && canTrashWorktreeRef(repoTarget)) {
11847
+ header.appendChild(createMoveToTrashButton(target.path, () => {
11848
+ const parent = target.path.split("/").slice(0, -1).join("/");
11849
+ setRoute(repoRoute(repoTarget, parent));
11850
+ loadRepo();
11851
+ }));
11852
+ }
11582
11853
  loadRawFileInfo(target).then((meta) => {
11583
11854
  if (req !== SOURCE_REQ_SEQ || !sourceTargetsEqual(sourceTargetFromRoute(), target))
11584
11855
  return;
@@ -12781,7 +13052,7 @@ ${frontmatter.yaml}
12781
13052
  return cached;
12782
13053
  const params = new URLSearchParams;
12783
13054
  params.set("ref", ref);
12784
- appendScopeOmitDirsParam(params);
13055
+ appendScopeParams(params);
12785
13056
  const res = await trackLoad(fetch(`/_files?${params.toString()}`).then((r2) => {
12786
13057
  if (!r2.ok)
12787
13058
  throw new Error("failed to load files");
@@ -12885,7 +13156,7 @@ ${frontmatter.yaml}
12885
13156
  params.set("max", "200");
12886
13157
  if (state.grepRegex)
12887
13158
  params.set("regex", "1");
12888
- appendScopeOmitDirsParam(params);
13159
+ appendScopeParams(params);
12889
13160
  if (source === "diff") {
12890
13161
  for (const file of state.diffSnapshot)
12891
13162
  params.append("path", file.path);
@@ -13176,10 +13447,19 @@ ${frontmatter.yaml}
13176
13447
  document.addEventListener("keydown", handleVirtualSourcePagingKeydown, {
13177
13448
  capture: true
13178
13449
  });
13179
- document.addEventListener("keydown", (e2) => {
13450
+ document.addEventListener("click", closeRepoContextMenu);
13451
+ $("#filelist").addEventListener("contextmenu", handleSidebarContextMenu);
13452
+ document.addEventListener("keydown", async (e2) => {
13453
+ if (e2.key === "Escape")
13454
+ closeRepoContextMenu();
13180
13455
  if (e2.__gdpVirtualSourcePagingHandled)
13181
13456
  return;
13182
13457
  const targetEl = e2.target;
13458
+ if ((e2.ctrlKey || e2.metaKey) && !e2.shiftKey && !e2.altKey && e2.key.toLowerCase() === "z" && !isEditableKeyTarget(targetEl)) {
13459
+ if (await undoLastAction())
13460
+ e2.preventDefault();
13461
+ return;
13462
+ }
13183
13463
  if ((e2.ctrlKey || e2.metaKey) && e2.key.toLowerCase() === "f" && !isEditableKeyTarget(targetEl)) {
13184
13464
  if (openVirtualSourceSearchFromKeyboard(targetEl)) {
13185
13465
  e2.preventDefault();
@@ -13214,7 +13494,7 @@ ${frontmatter.yaml}
13214
13494
  params.set("ref", STATE.route.ref || "worktree");
13215
13495
  if (STATE.route.path)
13216
13496
  params.set("path", STATE.route.path);
13217
- appendScopeOmitDirsParam(params);
13497
+ appendScopeParams(params);
13218
13498
  return trackLoad(fetch(`/_tree?${params.toString()}`).then((r2) => {
13219
13499
  if (!r2.ok)
13220
13500
  throw new Error("failed to load repository tree");
package/web/index.html CHANGED
@@ -124,7 +124,9 @@
124
124
  <div class="scope-settings-section">
125
125
  <strong class="scope-settings-section-title">Excluded directories</strong>
126
126
  <label for="scope-omit-dirs">Skip these directory names while browsing and searching</label>
127
- <textarea id="scope-omit-dirs" rows="9" spellcheck="false"></textarea>
127
+ <textarea id="scope-omit-dirs" rows="6" spellcheck="false"></textarea>
128
+ <label for="scope-exclude-names">Hide these file or directory names completely</label>
129
+ <textarea id="scope-exclude-names" rows="4" spellcheck="false"></textarea>
128
130
  <p id="scope-omit-source"></p>
129
131
  <div class="scope-settings-actions">
130
132
  <button id="scope-omit-reset" type="button">Reset</button>
package/web/style.css CHANGED
@@ -1171,10 +1171,11 @@ body.gdp-resizing * { user-select: none !important; }
1171
1171
  font: inherit;
1172
1172
  font-size: 12.5px;
1173
1173
  }
1174
- #scope-omit-dirs {
1174
+ #scope-omit-dirs,
1175
+ #scope-exclude-names {
1175
1176
  box-sizing: border-box;
1176
1177
  width: 100%;
1177
- min-height: 210px;
1178
+ min-height: 110px;
1178
1179
  resize: vertical;
1179
1180
  border: 1px solid var(--border-muted);
1180
1181
  border-radius: 6px;
@@ -2590,6 +2591,10 @@ table.d2h-diff-table tr.gdp-diff-line-target > td:first-child {
2590
2591
  flex-shrink: 0;
2591
2592
  color: var(--fg-muted);
2592
2593
  }
2594
+ .gdp-file-detail-header .gdp-trash-path {
2595
+ flex-shrink: 0;
2596
+ color: var(--danger);
2597
+ }
2593
2598
  .gdp-file-detail-meta {
2594
2599
  display: flex;
2595
2600
  align-items: center;
@@ -3257,6 +3262,82 @@ body.gdp-file-detail-page #empty {
3257
3262
  font-size: 12px;
3258
3263
  white-space: nowrap;
3259
3264
  }
3265
+ .gdp-context-menu {
3266
+ position: fixed;
3267
+ z-index: 1000;
3268
+ min-width: 180px;
3269
+ padding: 6px;
3270
+ border: 1px solid var(--border);
3271
+ border-radius: 6px;
3272
+ background: var(--bg);
3273
+ box-shadow: 0 8px 24px rgba(31, 35, 40, 0.18);
3274
+ }
3275
+ .gdp-context-menu button {
3276
+ width: 100%;
3277
+ min-height: 30px;
3278
+ padding: 5px 8px;
3279
+ border: 0;
3280
+ border-radius: 4px;
3281
+ background: transparent;
3282
+ color: var(--fg);
3283
+ font: inherit;
3284
+ font-size: 13px;
3285
+ text-align: left;
3286
+ cursor: pointer;
3287
+ }
3288
+ .gdp-context-menu button:hover {
3289
+ background: var(--bg-soft);
3290
+ }
3291
+ .gdp-context-menu .danger {
3292
+ color: var(--danger);
3293
+ }
3294
+ .gdp-trash-dialog-backdrop {
3295
+ position: fixed;
3296
+ inset: 0;
3297
+ z-index: 1100;
3298
+ display: flex;
3299
+ align-items: flex-start;
3300
+ justify-content: center;
3301
+ padding-top: min(14vh, 120px);
3302
+ background: rgba(31, 35, 40, 0.18);
3303
+ }
3304
+ [data-theme="dark"] .gdp-trash-dialog-backdrop {
3305
+ background: rgba(1, 4, 9, 0.45);
3306
+ }
3307
+ .gdp-trash-dialog {
3308
+ width: min(420px, calc(100vw - 32px));
3309
+ border: 1px solid var(--border);
3310
+ border-radius: 8px;
3311
+ background: var(--bg);
3312
+ box-shadow: 0 16px 48px rgba(31, 35, 40, 0.28);
3313
+ }
3314
+ .gdp-trash-dialog-title {
3315
+ padding: 14px 16px 0;
3316
+ color: var(--fg);
3317
+ font-size: 15px;
3318
+ font-weight: 600;
3319
+ }
3320
+ .gdp-trash-dialog-body {
3321
+ padding: 8px 16px 14px;
3322
+ color: var(--fg-muted);
3323
+ font-size: 13px;
3324
+ line-height: 1.5;
3325
+ overflow-wrap: anywhere;
3326
+ }
3327
+ .gdp-trash-dialog-actions {
3328
+ display: flex;
3329
+ justify-content: flex-end;
3330
+ gap: 8px;
3331
+ padding: 12px 16px;
3332
+ border-top: 1px solid var(--border-muted);
3333
+ }
3334
+ .gdp-trash-dialog-danger {
3335
+ border-color: var(--danger);
3336
+ color: var(--danger);
3337
+ }
3338
+ .gdp-trash-dialog-danger:hover {
3339
+ background: var(--diff-del-bg);
3340
+ }
3260
3341
  .gdp-repo-empty {
3261
3342
  padding: 32px;
3262
3343
  color: var(--fg-muted);