colonynote 1.0.0-beta.8 → 1.0.0

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.
Files changed (144) hide show
  1. package/README.md +6 -6
  2. package/README.zh.md +6 -6
  3. package/bin/colonynote.js +48 -81
  4. package/dist/client/assets/arc-Ezd9GmKE.js +1 -0
  5. package/dist/client/assets/architecture-YZFGNWBL-Dns9u0e5.js +1 -0
  6. package/dist/client/assets/architectureDiagram-Q4EWVU46-BmLJotd1.js +36 -0
  7. package/dist/client/assets/array-C0548cPn.js +1 -0
  8. package/dist/client/assets/blockDiagram-DXYQGD6D-ZqrNO8HM.js +132 -0
  9. package/dist/client/assets/c4Diagram-AHTNJAMY-BJWyymhT.js +10 -0
  10. package/dist/client/assets/channel-C-zEXS3V.js +1 -0
  11. package/dist/client/assets/chunk-2KRD3SAO-B1G2AY6H.js +1 -0
  12. package/dist/client/assets/chunk-4BX2VUAB-mLNpdiNK.js +1 -0
  13. package/dist/client/assets/chunk-4TB4RGXK-BsRi3qra.js +206 -0
  14. package/dist/client/assets/chunk-55IACEB6-BkCqG_Mc.js +1 -0
  15. package/dist/client/assets/chunk-67CJDMHE-Cz-mqdi4.js +1 -0
  16. package/dist/client/assets/chunk-7N4EOEYR-BFd0Oy6w.js +1 -0
  17. package/dist/client/assets/chunk-AA7GKIK3-D77gJjH7.js +1 -0
  18. package/dist/client/assets/chunk-CIAEETIT-DxQTGSBH.js +1 -0
  19. package/dist/client/assets/chunk-Dlc7tRH4.js +1 -0
  20. package/dist/client/assets/chunk-EDXVE4YY-DglU8MxC.js +1 -0
  21. package/dist/client/assets/{chunk-FMBD7UC4-aAnHvYnB.js → chunk-FMBD7UC4-B_1_v8GP.js} +2 -2
  22. package/dist/client/assets/chunk-FOC6F5B3-CEMl5H_U.js +1 -0
  23. package/dist/client/assets/chunk-K5T4RW27-Dzh5Xx8O.js +94 -0
  24. package/dist/client/assets/chunk-KGLVRYIC-Be2dMs_W.js +1 -0
  25. package/dist/client/assets/chunk-LIHQZDEY-BGBWPKdn.js +1 -0
  26. package/dist/client/assets/chunk-ORNJ4GCN-W_NT507t.js +1 -0
  27. package/dist/client/assets/chunk-OYMX7WX6-BV6RCL_j.js +231 -0
  28. package/dist/client/assets/chunk-QZHKN3VN-B0CMV9NG.js +1 -0
  29. package/dist/client/assets/chunk-YZCP3GAM-CDitFgw3.js +1 -0
  30. package/dist/client/assets/classDiagram-6PBFFD2Q-XeqY_hie.js +1 -0
  31. package/dist/client/assets/classDiagram-v2-HSJHXN6E-D5yY32qd.js +1 -0
  32. package/dist/client/assets/clone-AnN0418d.js +1 -0
  33. package/dist/client/assets/cose-bilkent-S5V4N54A-YQtZwRYb.js +1 -0
  34. package/dist/client/assets/cytoscape.esm-DT0IEibP.js +321 -0
  35. package/dist/client/assets/dagre-Cs_hK0RA.js +1 -0
  36. package/dist/client/assets/dagre-KV5264BT-DvMqAy8J.js +4 -0
  37. package/dist/client/assets/defaultLocale-ClcAPJ5U.js +1 -0
  38. package/dist/client/assets/diagram-5BDNPKRD-BwrN8bUG.js +10 -0
  39. package/dist/client/assets/diagram-G4DWMVQ6-Cqaj-jnn.js +24 -0
  40. package/dist/client/assets/diagram-MMDJMWI5-DNA312HK.js +43 -0
  41. package/dist/client/assets/diagram-TYMM5635-BujcYOHW.js +24 -0
  42. package/dist/client/assets/erDiagram-SMLLAGMA-ZjZZH-zN.js +85 -0
  43. package/dist/client/assets/flatten-DA7ZzNkq.js +1 -0
  44. package/dist/client/assets/flowDiagram-DWJPFMVM-CeIGHswO.js +162 -0
  45. package/dist/client/assets/ganttDiagram-T4ZO3ILL-BeGMFmHq.js +292 -0
  46. package/dist/client/assets/gitGraph-7Q5UKJZL-Dib3-KZp.js +1 -0
  47. package/dist/client/assets/gitGraphDiagram-UUTBAWPF-2fnUKQRp.js +106 -0
  48. package/dist/client/assets/graphlib-DRri47Ms.js +1 -0
  49. package/dist/client/assets/identity-CQcu21F9.js +1 -0
  50. package/dist/client/assets/index-DKyd5iCm.css +2 -0
  51. package/dist/client/assets/index-Ts5WhtRB.js +574 -0
  52. package/dist/client/assets/info-OMHHGYJF-oQ5--hGK.js +1 -0
  53. package/dist/client/assets/infoDiagram-42DDH7IO-KL_uq6JQ.js +2 -0
  54. package/dist/client/assets/init-TU6eJ00_.js +1 -0
  55. package/dist/client/assets/ishikawaDiagram-UXIWVN3A-DYWG2VdA.js +70 -0
  56. package/dist/client/assets/journeyDiagram-VCZTEJTY-DT7IVz7z.js +139 -0
  57. package/dist/client/assets/kanban-definition-6JOO6SKY-CHPPLj8n.js +89 -0
  58. package/dist/client/assets/katex-HOUACuRw.js +257 -0
  59. package/dist/client/assets/linear-CizMozZt.js +1 -0
  60. package/dist/client/assets/mermaid-parser.core-Bpqvb2jv.js +4 -0
  61. package/dist/client/assets/mindmap-definition-QFDTVHPH-Dov77gi8.js +96 -0
  62. package/dist/client/assets/ordinal-DCsgWfZW.js +1 -0
  63. package/dist/client/assets/packet-4T2RLAQJ-Cuyyn0tM.js +1 -0
  64. package/dist/client/assets/path-yo4Xej8w.js +1 -0
  65. package/dist/client/assets/pie-ZZUOXDRM-DajTPYmY.js +1 -0
  66. package/dist/client/assets/pieDiagram-DEJITSTG-DwmqzTu7.js +30 -0
  67. package/dist/client/assets/quadrantDiagram-34T5L4WZ-CdCBXbvb.js +7 -0
  68. package/dist/client/assets/radar-PYXPWWZC-DWe78GFc.js +1 -0
  69. package/dist/client/assets/reduce-DV5GRzs4.js +1 -0
  70. package/dist/client/assets/requirementDiagram-MS252O5E-32eCbPFl.js +84 -0
  71. package/dist/client/assets/rough.esm-DulVNktb.js +1 -0
  72. package/dist/client/assets/sankeyDiagram-XADWPNL6-BBDL_wPZ.js +10 -0
  73. package/dist/client/assets/sequenceDiagram-FGHM5R23-Ck7ukfb7.js +157 -0
  74. package/dist/client/assets/stateDiagram-FHFEXIEX-BKJrixhp.js +1 -0
  75. package/dist/client/assets/stateDiagram-v2-QKLJ7IA2-Bio1QBYF.js +1 -0
  76. package/dist/client/assets/timeline-definition-GMOUNBTQ-B_D-q7Kc.js +120 -0
  77. package/dist/client/assets/treeView-SZITEDCU-BQ27_5HI.js +1 -0
  78. package/dist/client/assets/treemap-W4RFUUIX-D2TpEq--.js +1 -0
  79. package/dist/client/assets/vennDiagram-DHZGUBPP-CvHqv3h2.js +34 -0
  80. package/dist/client/assets/wardley-RL74JXVD-DoAmhKVb.js +1 -0
  81. package/dist/client/assets/wardleyDiagram-NUSXRM2D-CYRpN4xk.js +20 -0
  82. package/dist/client/assets/xychartDiagram-5P7HB3ND-Dh0MaaMp.js +7 -0
  83. package/dist/client/index.html +7 -2
  84. package/dist/config.js +122 -52
  85. package/dist/server/api.js +637 -31
  86. package/dist/server/ignore.js +221 -0
  87. package/dist/server/index.js +78 -24
  88. package/dist/server/search-ignore.test.js +51 -0
  89. package/dist/server/watcher.js +54 -18
  90. package/package.json +16 -16
  91. package/dist/client/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  92. package/dist/client/assets/_basePickBy-COW6_o5l.js +0 -1
  93. package/dist/client/assets/_baseUniq-BZLZ0Cq5.js +0 -1
  94. package/dist/client/assets/arc-ChrJd4LX.js +0 -1
  95. package/dist/client/assets/architectureDiagram-2XIMDMQ5-DUvtAUqm.js +0 -36
  96. package/dist/client/assets/blockDiagram-WCTKOSBZ-CZjHlzLw.js +0 -132
  97. package/dist/client/assets/c4Diagram-IC4MRINW-Bb4QcjBn.js +0 -10
  98. package/dist/client/assets/channel-Kq606-yO.js +0 -1
  99. package/dist/client/assets/chunk-4BX2VUAB-U2gnuzht.js +0 -1
  100. package/dist/client/assets/chunk-55IACEB6-DDf5FyZA.js +0 -1
  101. package/dist/client/assets/chunk-JSJVCQXG-95g2NCsz.js +0 -1
  102. package/dist/client/assets/chunk-KX2RTZJC-Uw77DOxq.js +0 -1
  103. package/dist/client/assets/chunk-NQ4KR5QH-CEtk7AxS.js +0 -220
  104. package/dist/client/assets/chunk-QZHKN3VN-CxHNihbm.js +0 -1
  105. package/dist/client/assets/chunk-WL4C6EOR-YWnktay4.js +0 -189
  106. package/dist/client/assets/classDiagram-VBA2DB6C-Bgw-akZn.js +0 -1
  107. package/dist/client/assets/classDiagram-v2-RAHNMMFH-Bgw-akZn.js +0 -1
  108. package/dist/client/assets/clone-DXEvmIA_.js +0 -1
  109. package/dist/client/assets/cose-bilkent-S5V4N54A-BcT_Pp_t.js +0 -1
  110. package/dist/client/assets/cytoscape.esm-BQaXIfA_.js +0 -331
  111. package/dist/client/assets/dagre-KLK3FWXG-DfkYqpYp.js +0 -4
  112. package/dist/client/assets/defaultLocale-DX6XiGOO.js +0 -1
  113. package/dist/client/assets/diagram-E7M64L7V-kzGsyL8B.js +0 -24
  114. package/dist/client/assets/diagram-IFDJBPK2-DK7bXX4F.js +0 -43
  115. package/dist/client/assets/diagram-P4PSJMXO-z9xCBdYx.js +0 -24
  116. package/dist/client/assets/erDiagram-INFDFZHY-D7a5C5o_.js +0 -70
  117. package/dist/client/assets/flowDiagram-PKNHOUZH-DqBsAGYI.js +0 -162
  118. package/dist/client/assets/ganttDiagram-A5KZAMGK-CytDHso0.js +0 -292
  119. package/dist/client/assets/gitGraphDiagram-K3NZZRJ6-C_xN53WG.js +0 -65
  120. package/dist/client/assets/graph-CZtfE55r.js +0 -1
  121. package/dist/client/assets/index-AcpT_uDS.css +0 -1
  122. package/dist/client/assets/index-Ci71-3A2.js +0 -705
  123. package/dist/client/assets/infoDiagram-LFFYTUFH-JmJVtb4-.js +0 -2
  124. package/dist/client/assets/init-Gi6I4Gst.js +0 -1
  125. package/dist/client/assets/ishikawaDiagram-PHBUUO56-BZLBgU5l.js +0 -70
  126. package/dist/client/assets/journeyDiagram-4ABVD52K-Bso62bBS.js +0 -139
  127. package/dist/client/assets/kanban-definition-K7BYSVSG-D9jsFbIS.js +0 -89
  128. package/dist/client/assets/katex-B1X10hvy.js +0 -261
  129. package/dist/client/assets/layout-Db-5vCEZ.js +0 -1
  130. package/dist/client/assets/linear-DdXT-poy.js +0 -1
  131. package/dist/client/assets/mindmap-definition-YRQLILUH-DUpkjOn4.js +0 -68
  132. package/dist/client/assets/ordinal-Cboi1Yqb.js +0 -1
  133. package/dist/client/assets/pieDiagram-SKSYHLDU-BTseg5zx.js +0 -30
  134. package/dist/client/assets/quadrantDiagram-337W2JSQ-Cf86XD1_.js +0 -7
  135. package/dist/client/assets/requirementDiagram-Z7DCOOCP-BK2yRQcz.js +0 -73
  136. package/dist/client/assets/sankeyDiagram-WA2Y5GQK-jBbEn6rj.js +0 -10
  137. package/dist/client/assets/sequenceDiagram-2WXFIKYE-qg69rBRP.js +0 -145
  138. package/dist/client/assets/stateDiagram-RAJIS63D-BFIxDKEY.js +0 -1
  139. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-DYqLkNfp.js +0 -1
  140. package/dist/client/assets/timeline-definition-YZTLITO2-B17QFyRY.js +0 -61
  141. package/dist/client/assets/treemap-KZPCXAKY-CGSXpgHF.js +0 -162
  142. package/dist/client/assets/vennDiagram-LZ73GAT5-mbh453Lq.js +0 -34
  143. package/dist/client/assets/xychartDiagram-JWTSCODW-COWgeBYx.js +0 -7
  144. package/dist/server/app.js +0 -14
@@ -1,45 +1,100 @@
1
1
  import { Hono } from 'hono';
2
2
  import fs from 'fs/promises';
3
+ import { existsSync } from 'fs';
3
4
  import path from 'path';
4
- import { saveUserConfig } from '../config.js';
5
+ import os from 'os';
6
+ import { execFile } from 'child_process';
7
+ import { saveConfig, DEFAULT_SENSITIVE_PATHS } from '../config.js';
8
+ import { minimatch } from 'minimatch';
9
+ import fuzzysort from 'fuzzysort';
5
10
  function isAllowed(pathStr, config) {
6
11
  const resolved = path.resolve(pathStr);
7
- return resolved.startsWith(path.resolve(config.root));
12
+ return config.dirs.some(dir => {
13
+ const dirPath = path.resolve(dir.path);
14
+ return resolved === dirPath || resolved.startsWith(dirPath + path.sep);
15
+ });
16
+ }
17
+ function validateRoot(dirPath, config) {
18
+ const resolved = path.resolve(dirPath);
19
+ const dir = config.dirs.find(r => path.resolve(r.path) === resolved);
20
+ return dir ? path.resolve(dir.path) : null;
21
+ }
22
+ function checkSensitivePath(inputPath) {
23
+ const basename = path.basename(inputPath);
24
+ for (const pattern of DEFAULT_SENSITIVE_PATHS) {
25
+ if (minimatch(basename, pattern, { nocase: true, dot: true }))
26
+ return true;
27
+ }
28
+ return false;
29
+ }
30
+ function checkNestedPath(newPath, existingDirs) {
31
+ const resolved = path.resolve(newPath);
32
+ for (const dir of existingDirs) {
33
+ const existing = path.resolve(dir.path);
34
+ if (resolved === existing)
35
+ return { isNested: true, conflictWith: dir.path, reason: 'duplicate' };
36
+ if (resolved.startsWith(existing + path.sep))
37
+ return { isNested: true, conflictWith: dir.path, reason: 'child' };
38
+ if (existing.startsWith(resolved + path.sep))
39
+ return { isNested: true, conflictWith: dir.path, reason: 'parent' };
40
+ }
41
+ return { isNested: false };
42
+ }
43
+ function findRootForPath(filePath, config) {
44
+ for (const dir of config.dirs) {
45
+ const dirPath = path.resolve(dir.path);
46
+ const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
47
+ const fullPath = path.join(dirPath, relativePath);
48
+ if ((fullPath.startsWith(dirPath + path.sep) || fullPath === dirPath) && existsSync(fullPath)) {
49
+ return dirPath;
50
+ }
51
+ }
52
+ return null;
8
53
  }
9
54
  function hasAllowedExtension(filename, extensions) {
10
55
  const ext = path.extname(filename).toLowerCase();
11
56
  return extensions.includes(ext);
12
57
  }
13
- async function walkDirectory(dir, config) {
58
+ async function walkDirectory(dir, dirPath, config, matcher) {
14
59
  const nodes = [];
15
60
  try {
16
61
  const entries = await fs.readdir(dir, { withFileTypes: true });
17
62
  for (const entry of entries) {
63
+ const fullPath = path.join(dir, entry.name);
64
+ const isDir = entry.isDirectory();
18
65
  if (!config.showHiddenFiles && entry.name.startsWith('.'))
19
66
  continue;
20
- const fullPath = path.join(dir, entry.name);
21
- if (entry.isDirectory()) {
22
- const children = await walkDirectory(fullPath, config);
67
+ if (matcher.isIgnored(fullPath, isDir))
68
+ continue;
69
+ if (isDir) {
70
+ const children = await walkDirectory(fullPath, dirPath, config, matcher);
71
+ const relativePath = path.relative(dirPath, fullPath).replace(/\\/g, '/');
23
72
  nodes.push({
24
73
  name: entry.name,
25
- path: fullPath.replace(config.root, '').replace(/\\/g, '/'),
74
+ path: relativePath ? '/' + relativePath : '/',
26
75
  type: 'directory',
76
+ rootPath: dirPath,
27
77
  children,
28
78
  });
29
79
  }
30
80
  else if (entry.isFile()) {
31
81
  if (hasAllowedExtension(entry.name, config.allowedExtensions)) {
82
+ const relativePath = path.relative(dirPath, fullPath).replace(/\\/g, '/');
32
83
  nodes.push({
33
84
  name: entry.name,
34
- path: fullPath.replace(config.root, '').replace(/\\/g, '/'),
85
+ path: relativePath ? '/' + relativePath : '/',
35
86
  type: 'file',
87
+ rootPath: dirPath,
36
88
  });
37
89
  }
38
90
  }
39
91
  }
40
92
  }
41
93
  catch (e) {
42
- // ignore errors
94
+ // Re-throw error if this is the root directory, otherwise ignore
95
+ if (dir === dirPath) {
96
+ throw e;
97
+ }
43
98
  }
44
99
  nodes.sort((a, b) => {
45
100
  if (a.type === 'directory' && b.type === 'file')
@@ -50,48 +105,366 @@ async function walkDirectory(dir, config) {
50
105
  });
51
106
  return nodes;
52
107
  }
53
- export function createFileRouter(config) {
108
+ export function createMutableConfigHolder(initialConfig, initialMatcher) {
109
+ let config = initialConfig;
110
+ let matcher = initialMatcher;
111
+ return {
112
+ get config() { return config; },
113
+ get matcher() { return matcher; },
114
+ setConfig(c) { config = c; },
115
+ setMatcher(m) { matcher = m; },
116
+ };
117
+ }
118
+ export function createFileRouter(holder, env = 'production') {
54
119
  const router = new Hono();
120
+ function getConfig() { return holder.config; }
121
+ function getMatcher() { return holder.matcher; }
122
+ let treeCache = null;
123
+ const CACHE_TTL_MS = 3000;
124
+ function computeConfigHash() {
125
+ const config = getConfig();
126
+ return JSON.stringify({
127
+ dirs: config.dirs.map(d => ({ path: d.path, name: d.name })),
128
+ showHiddenFiles: config.showHiddenFiles,
129
+ allowedExtensions: config.allowedExtensions,
130
+ ignore: config.ignore,
131
+ });
132
+ }
133
+ function invalidateTreeCache() {
134
+ treeCache = null;
135
+ }
136
+ async function getFileGroups() {
137
+ const config = getConfig();
138
+ const matcher = getMatcher();
139
+ const now = Date.now();
140
+ const hash = computeConfigHash();
141
+ if (treeCache && (now - treeCache.timestamp) < CACHE_TTL_MS && treeCache.configHash === hash) {
142
+ return treeCache.groups;
143
+ }
144
+ const groups = await Promise.all(config.dirs.map(async (dir) => {
145
+ try {
146
+ return {
147
+ root: dir,
148
+ files: await walkDirectory(dir.path, dir.path, config, matcher)
149
+ };
150
+ }
151
+ catch (e) {
152
+ return {
153
+ root: dir,
154
+ files: [],
155
+ error: e instanceof Error ? e.message : 'Failed to read directory'
156
+ };
157
+ }
158
+ }));
159
+ treeCache = { groups, timestamp: now, configHash: hash };
160
+ return groups;
161
+ }
55
162
  router.get('/config', async (c) => {
163
+ const config = getConfig();
56
164
  return c.json({
57
165
  showHiddenFiles: config.showHiddenFiles,
58
166
  allowedExtensions: config.allowedExtensions,
167
+ ignore: config.ignore,
59
168
  });
60
169
  });
61
170
  router.patch('/config', async (c) => {
171
+ const config = getConfig();
172
+ const matcher = getMatcher();
62
173
  try {
63
174
  const body = await c.req.json();
64
- const allowedFields = ['showHiddenFiles', 'allowedExtensions'];
175
+ const allowedFields = ['showHiddenFiles', 'allowedExtensions', 'ignore'];
65
176
  const updates = {};
66
177
  for (const key of allowedFields) {
67
178
  if (key in body) {
68
179
  updates[key] = body[key];
69
180
  }
70
181
  }
71
- saveUserConfig(config.root, updates);
182
+ saveConfig(config, env);
183
+ invalidateTreeCache();
72
184
  if (typeof updates.showHiddenFiles === 'boolean') {
73
185
  config.showHiddenFiles = updates.showHiddenFiles;
74
186
  }
75
187
  if (Array.isArray(updates.allowedExtensions)) {
76
188
  config.allowedExtensions = updates.allowedExtensions;
77
189
  }
78
- return c.json({ success: true, config: { showHiddenFiles: config.showHiddenFiles, allowedExtensions: config.allowedExtensions } });
190
+ if (updates.ignore) {
191
+ if (Array.isArray(updates.ignore.patterns)) {
192
+ config.ignore.patterns = updates.ignore.patterns;
193
+ matcher.updateGlobalPatterns(updates.ignore.patterns);
194
+ }
195
+ }
196
+ return c.json({ success: true, config: { showHiddenFiles: config.showHiddenFiles, allowedExtensions: config.allowedExtensions, ignore: config.ignore } });
79
197
  }
80
198
  catch (e) {
81
199
  console.error('Failed to update config:', e);
82
200
  return c.json({ error: 'Failed to update config' }, 500);
83
201
  }
84
202
  });
203
+ // Dir management routes
204
+ router.get('/dirs', async (c) => {
205
+ const config = getConfig();
206
+ return c.json({ dirs: config.dirs });
207
+ });
208
+ router.post('/dirs', async (c) => {
209
+ const config = getConfig();
210
+ try {
211
+ const body = await c.req.json();
212
+ const newPath = body.path;
213
+ if (!newPath)
214
+ return c.json({ error: 'Path is required' }, 400);
215
+ try {
216
+ const stat = await fs.stat(newPath);
217
+ if (!stat.isDirectory())
218
+ return c.json({ error: 'Path must be a directory' }, 400);
219
+ }
220
+ catch {
221
+ return c.json({ error: 'Path does not exist' }, 400);
222
+ }
223
+ if (checkSensitivePath(newPath))
224
+ return c.json({ error: 'Sensitive path not allowed' }, 400);
225
+ const nested = checkNestedPath(newPath, config.dirs);
226
+ if (nested.isNested)
227
+ return c.json({ error: 'Nested path not allowed', conflictWith: nested.conflictWith, reason: nested.reason }, 400);
228
+ const newDir = { path: path.resolve(newPath), exclude: body.exclude, name: body.name };
229
+ config.dirs.push(newDir);
230
+ saveConfig(config, env);
231
+ invalidateTreeCache();
232
+ return c.json({ success: true, dir: newDir });
233
+ }
234
+ catch (e) {
235
+ return c.json({ error: 'Failed to add dir' }, 500);
236
+ }
237
+ });
238
+ router.delete('/dirs', async (c) => {
239
+ const config = getConfig();
240
+ const pathParam = c.req.query('path');
241
+ if (!pathParam)
242
+ return c.json({ error: 'path parameter required' }, 400);
243
+ const idx = config.dirs.findIndex(r => path.resolve(r.path) === path.resolve(pathParam));
244
+ if (idx === -1)
245
+ return c.json({ error: 'Dir not found' }, 404);
246
+ config.dirs.splice(idx, 1);
247
+ saveConfig(config, env);
248
+ invalidateTreeCache();
249
+ return c.json({ success: true });
250
+ });
251
+ router.patch('/dirs', async (c) => {
252
+ const config = getConfig();
253
+ try {
254
+ const body = await c.req.json();
255
+ const { path: dirPath, exclude, name } = body;
256
+ if (!dirPath)
257
+ return c.json({ error: 'Path is required' }, 400);
258
+ const dir = config.dirs.find(r => path.resolve(r.path) === path.resolve(dirPath));
259
+ if (!dir)
260
+ return c.json({ error: 'Dir not found' }, 404);
261
+ if (exclude !== undefined)
262
+ dir.exclude = exclude;
263
+ if (name !== undefined)
264
+ dir.name = name;
265
+ saveConfig(config, env);
266
+ invalidateTreeCache();
267
+ return c.json({ success: true, dir });
268
+ }
269
+ catch (e) {
270
+ return c.json({ error: 'Failed to update dir' }, 500);
271
+ }
272
+ });
273
+ router.get('/dirs/browse', async (c) => {
274
+ const rawPath = c.req.query('path') || '';
275
+ if (!rawPath.trim())
276
+ return c.json({ dirs: [], currentPath: '' });
277
+ let resolvedPath;
278
+ if (rawPath === '~' || rawPath.startsWith('~/')) {
279
+ resolvedPath = path.join(os.homedir(), rawPath.slice(1));
280
+ }
281
+ else {
282
+ resolvedPath = path.resolve(rawPath);
283
+ }
284
+ try {
285
+ const stat = await fs.stat(resolvedPath);
286
+ if (!stat.isDirectory()) {
287
+ return c.json({ dirs: [], currentPath: rawPath });
288
+ }
289
+ const entries = await fs.readdir(resolvedPath, { withFileTypes: true });
290
+ const dirs = [];
291
+ for (const entry of entries) {
292
+ if (entry.name.startsWith('.'))
293
+ continue;
294
+ const fullPath = path.join(resolvedPath, entry.name);
295
+ if (entry.isDirectory() && !checkSensitivePath(fullPath)) {
296
+ dirs.push(fullPath);
297
+ }
298
+ }
299
+ dirs.sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
300
+ return c.json({ dirs, currentPath: rawPath });
301
+ }
302
+ catch {
303
+ return c.json({ dirs: [], currentPath: rawPath });
304
+ }
305
+ });
306
+ router.get('/dirs/search', async (c) => {
307
+ const config = getConfig();
308
+ const query = c.req.query('q') || '';
309
+ const rawRoot = c.req.query('root');
310
+ const mode = c.req.query('mode') || 'fuzzy';
311
+ let searchRoot;
312
+ if (rawRoot === '~' || rawRoot === '') {
313
+ searchRoot = os.homedir();
314
+ }
315
+ else if (rawRoot === '/') {
316
+ searchRoot = '/';
317
+ }
318
+ else if (rawRoot && rawRoot.startsWith('~/')) {
319
+ searchRoot = path.join(os.homedir(), rawRoot.slice(2));
320
+ }
321
+ else if (rawRoot && path.isAbsolute(rawRoot)) {
322
+ searchRoot = path.normalize(rawRoot);
323
+ }
324
+ else if (rawRoot) {
325
+ // Relative path like 'projects' → resolve relative to home
326
+ searchRoot = path.join(os.homedir(), rawRoot);
327
+ }
328
+ else {
329
+ searchRoot = os.homedir();
330
+ }
331
+ // Browse mode: list direct children directories
332
+ if (mode === 'browse') {
333
+ try {
334
+ const stat = await fs.stat(searchRoot);
335
+ if (!stat.isDirectory()) {
336
+ return c.json({ matches: [] });
337
+ }
338
+ const entries = await fs.readdir(searchRoot, { withFileTypes: true });
339
+ const dirs = [];
340
+ for (const entry of entries) {
341
+ if (entry.name.startsWith('.'))
342
+ continue;
343
+ const fullPath = path.join(searchRoot, entry.name);
344
+ if (entry.isDirectory() && !checkSensitivePath(fullPath)) {
345
+ dirs.push({ path: fullPath, score: 0, indexes: [] });
346
+ }
347
+ }
348
+ dirs.sort((a, b) => path.basename(a.path).localeCompare(path.basename(b.path)));
349
+ return c.json({ matches: dirs });
350
+ }
351
+ catch {
352
+ return c.json({ matches: [] });
353
+ }
354
+ }
355
+ // Search mode requires non-empty query
356
+ if (!query.trim())
357
+ return c.json({ matches: [] });
358
+ const MAX_DEPTH = 5;
359
+ const MAX_CANDIDATES = 10000;
360
+ const MAX_RESULTS = 100;
361
+ const candidates = [];
362
+ async function traverse(dir, depth) {
363
+ if (depth > MAX_DEPTH || candidates.length >= MAX_CANDIDATES)
364
+ return;
365
+ try {
366
+ const entries = await fs.readdir(dir, { withFileTypes: true });
367
+ const subDirs = [];
368
+ for (const entry of entries) {
369
+ if (entry.name.startsWith('.'))
370
+ continue;
371
+ const fullPath = path.join(dir, entry.name);
372
+ // Check if path matches any ignore pattern
373
+ const isIgnored = config.ignore?.patterns?.some(pattern => {
374
+ const normalizedPattern = pattern.endsWith('/') ? pattern.slice(0, -1) : pattern;
375
+ return minimatch(fullPath, normalizedPattern, { dot: true, matchBase: true });
376
+ });
377
+ if (isIgnored)
378
+ continue;
379
+ if (entry.isDirectory() && !checkSensitivePath(fullPath)) {
380
+ subDirs.push(fullPath);
381
+ }
382
+ }
383
+ for (const subDir of subDirs) {
384
+ if (candidates.length >= MAX_CANDIDATES)
385
+ break;
386
+ candidates.push({ name: path.basename(subDir), fullPath: subDir });
387
+ }
388
+ for (const subDir of subDirs) {
389
+ if (candidates.length >= MAX_CANDIDATES)
390
+ break;
391
+ await traverse(subDir, depth + 1);
392
+ }
393
+ }
394
+ catch { }
395
+ }
396
+ await traverse(searchRoot, 0);
397
+ let results;
398
+ if (mode === 'prefix') {
399
+ const lowerQuery = query.toLowerCase();
400
+ results = candidates
401
+ .filter(c => c.name.toLowerCase().startsWith(lowerQuery))
402
+ .slice(0, MAX_RESULTS)
403
+ .map(c => ({
404
+ obj: c,
405
+ score: 0,
406
+ indexes: Array.from({ length: query.length }, (_, i) => i)
407
+ }));
408
+ }
409
+ else {
410
+ results = fuzzysort.go(query, candidates, {
411
+ key: 'name',
412
+ limit: MAX_RESULTS,
413
+ threshold: 0.5,
414
+ });
415
+ }
416
+ const matches = results.map(r => ({
417
+ path: r.obj.fullPath,
418
+ score: r.score,
419
+ indexes: r.indexes,
420
+ }));
421
+ return c.json({ matches });
422
+ });
423
+ // Lazy-load directory children on demand (performance optimization)
424
+ router.get('/children', async (c) => {
425
+ const config = getConfig();
426
+ const matcher = getMatcher();
427
+ const dirPathParam = c.req.query('dirPath');
428
+ const rootParam = c.req.query('root');
429
+ if (!dirPathParam)
430
+ return c.json({ error: 'dirPath is required' }, 400);
431
+ let dirPath;
432
+ if (rootParam) {
433
+ dirPath = validateRoot(rootParam, config);
434
+ if (!dirPath)
435
+ return c.json({ error: 'Invalid root' }, 400);
436
+ }
437
+ else {
438
+ dirPath = findRootForPath(dirPathParam, config);
439
+ if (!dirPath)
440
+ return c.json({ error: 'Access denied' }, 403);
441
+ }
442
+ const relativePath = dirPathParam.startsWith('/') ? dirPathParam.slice(1) : dirPathParam;
443
+ const fullPath = path.join(dirPath, relativePath);
444
+ if (!isAllowed(fullPath, config))
445
+ return c.json({ error: 'Access denied' }, 403);
446
+ try {
447
+ const stat = await fs.stat(fullPath);
448
+ if (!stat.isDirectory())
449
+ return c.json({ error: 'Not a directory' }, 400);
450
+ const children = await walkDirectory(fullPath, dirPath, config, matcher);
451
+ return c.json({ children });
452
+ }
453
+ catch (e) {
454
+ return c.json({ error: 'Failed to read directory' }, 500);
455
+ }
456
+ });
85
457
  router.get('/', async (c) => {
86
458
  try {
87
- const files = await walkDirectory(config.root, config);
88
- return c.json({ files });
459
+ const groups = await getFileGroups();
460
+ return c.json({ groups });
89
461
  }
90
462
  catch (e) {
91
463
  return c.json({ error: 'Failed to read directory' }, 500);
92
464
  }
93
465
  });
94
466
  router.get('/content', async (c) => {
467
+ const config = getConfig();
95
468
  const pathsParam = c.req.query('paths');
96
469
  if (!pathsParam) {
97
470
  return c.json({ error: 'paths parameter is required' }, 400);
@@ -99,7 +472,21 @@ export function createFileRouter(config) {
99
472
  const paths = pathsParam.split(',').filter(Boolean);
100
473
  const results = [];
101
474
  for (const filePath of paths) {
102
- const fullPath = path.join(config.root, filePath);
475
+ const rootParam = c.req.query('root');
476
+ let dirPath;
477
+ if (rootParam) {
478
+ dirPath = path.resolve(rootParam);
479
+ if (!config.dirs.some(r => path.resolve(r.path) === dirPath)) {
480
+ return c.json({ error: 'Invalid root' }, 400);
481
+ }
482
+ }
483
+ else {
484
+ dirPath = findRootForPath(filePath, config);
485
+ }
486
+ if (!dirPath)
487
+ return c.json({ error: 'Access denied' }, 403);
488
+ const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
489
+ const fullPath = path.join(dirPath, relativePath);
103
490
  if (!isAllowed(fullPath, config)) {
104
491
  continue;
105
492
  }
@@ -120,16 +507,132 @@ export function createFileRouter(config) {
120
507
  }
121
508
  return c.json({ files: results });
122
509
  });
510
+ router.get('/search', async (c) => {
511
+ const config = getConfig();
512
+ const query = c.req.query('q');
513
+ if (!query || !query.trim()) {
514
+ return c.json({ results: [] });
515
+ }
516
+ const limit = parseInt(c.req.query('limit') || '50', 10);
517
+ const rootParam = c.req.query('root');
518
+ let targetDirs;
519
+ if (rootParam) {
520
+ const validatedRoot = validateRoot(rootParam, config);
521
+ if (!validatedRoot) {
522
+ return c.json({ error: 'Invalid root' }, 400);
523
+ }
524
+ targetDirs = config.dirs.filter(d => path.resolve(d.path) === validatedRoot);
525
+ }
526
+ else {
527
+ targetDirs = config.dirs;
528
+ }
529
+ const results = [];
530
+ for (const dir of targetDirs) {
531
+ if (results.length >= limit)
532
+ break;
533
+ const dirPath = path.resolve(dir.path);
534
+ const dirName = dir.name || path.basename(dirPath);
535
+ try {
536
+ const { stdout, stderr } = await new Promise((resolve, reject) => {
537
+ execFile('rg', [
538
+ '--json',
539
+ '--ignore-case',
540
+ '--max-count', String(Math.min(limit, 5)),
541
+ '--line-number',
542
+ '--context', '1',
543
+ '-g', '*.md',
544
+ query,
545
+ dirPath,
546
+ ], { timeout: 5000, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
547
+ if (error && error.code !== 1) {
548
+ reject(error);
549
+ }
550
+ else {
551
+ resolve({ stdout, stderr });
552
+ }
553
+ });
554
+ });
555
+ if (stderr.includes('no matches'))
556
+ continue;
557
+ let currentPath = '';
558
+ let currentRelativePath = '';
559
+ let lines = [];
560
+ for (const rawLine of stdout.split('\n')) {
561
+ if (!rawLine.trim())
562
+ continue;
563
+ try {
564
+ const obj = JSON.parse(rawLine);
565
+ if (obj.type === 'begin') {
566
+ currentPath = obj.data.path.text;
567
+ currentRelativePath = path.relative(dirPath, currentPath).replace(/\\/g, '/');
568
+ lines = [];
569
+ }
570
+ else if (obj.type === 'match') {
571
+ const lineText = obj.data.lines.text;
572
+ lines.push({ line: lineText, lineNumber: obj.data.line_number, subMatches: obj.data.submatches });
573
+ }
574
+ else if (obj.type === 'end') {
575
+ if (lines.length > 0) {
576
+ const firstMatch = lines[0];
577
+ const snippet = firstMatch.line.trim();
578
+ results.push({
579
+ path: '/' + currentRelativePath,
580
+ name: path.basename(currentRelativePath),
581
+ rootPath: dirPath,
582
+ rootName: dirName,
583
+ matchedLine: snippet,
584
+ });
585
+ }
586
+ }
587
+ }
588
+ catch {
589
+ // skip parse errors
590
+ }
591
+ }
592
+ }
593
+ catch {
594
+ // skip directories where rg fails
595
+ }
596
+ }
597
+ return c.json({ results: results.slice(0, limit) });
598
+ });
123
599
  router.get('/*', async (c) => {
600
+ const config = getConfig();
601
+ const matcher = getMatcher();
124
602
  const filePath = c.req.path.replace(/^\/api\/files/, '') || '/';
125
- const fullPath = path.join(config.root, filePath);
603
+ // Handle root path - return grouped file tree
604
+ if (filePath === '/' || filePath === '') {
605
+ try {
606
+ const groups = await getFileGroups();
607
+ return c.json({ groups });
608
+ }
609
+ catch (e) {
610
+ return c.json({ error: 'Failed to read directory' }, 500);
611
+ }
612
+ }
613
+ // Handle root parameter from query string
614
+ const rootParam = c.req.query('root');
615
+ let dirPath;
616
+ if (rootParam) {
617
+ dirPath = path.resolve(rootParam);
618
+ if (!config.dirs.some(r => path.resolve(r.path) === dirPath)) {
619
+ return c.json({ error: 'Invalid root' }, 400);
620
+ }
621
+ }
622
+ else {
623
+ dirPath = findRootForPath(filePath, config);
624
+ }
625
+ if (!dirPath)
626
+ return c.json({ error: 'Access denied' }, 403);
627
+ const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
628
+ const fullPath = path.join(dirPath, relativePath);
126
629
  if (!isAllowed(fullPath, config)) {
127
630
  return c.json({ error: 'Access denied' }, 403);
128
631
  }
129
632
  try {
130
633
  const stat = await fs.stat(fullPath);
131
634
  if (stat.isDirectory()) {
132
- const files = await walkDirectory(fullPath, config);
635
+ const files = await walkDirectory(fullPath, dirPath, config, matcher);
133
636
  return c.json({ files });
134
637
  }
135
638
  }
@@ -144,9 +647,64 @@ export function createFileRouter(config) {
144
647
  return c.json({ error: 'File not found' }, 404);
145
648
  }
146
649
  });
650
+ router.post('/copy', async (c) => {
651
+ const config = getConfig();
652
+ try {
653
+ const body = await c.req.json();
654
+ let { sourcePath, targetPath, sourceRoot, targetRoot } = body;
655
+ if (!sourcePath || !targetPath) {
656
+ return c.json({ error: 'sourcePath and targetPath required' }, 400);
657
+ }
658
+ if (!sourceRoot || !targetRoot) {
659
+ return c.json({ error: 'sourceRoot and targetRoot required' }, 400);
660
+ }
661
+ const validatedSourceRoot = validateRoot(sourceRoot, config);
662
+ const validatedTargetRoot = validateRoot(targetRoot, config);
663
+ if (!validatedSourceRoot)
664
+ return c.json({ error: 'Invalid source root' }, 400);
665
+ if (!validatedTargetRoot)
666
+ return c.json({ error: 'Invalid target root' }, 400);
667
+ const srcFullPath = path.join(validatedSourceRoot, sourcePath);
668
+ let tgtFullPath = path.join(validatedTargetRoot, targetPath);
669
+ const stat = await fs.stat(srcFullPath);
670
+ const targetExists = await fs.access(tgtFullPath).then(() => true).catch(() => false);
671
+ if (targetExists) {
672
+ const ext = path.extname(targetPath);
673
+ const base = path.basename(targetPath, ext);
674
+ const dir = path.dirname(targetPath);
675
+ targetPath = `${dir}/${base} (copy)${ext}`;
676
+ tgtFullPath = path.join(validatedTargetRoot, targetPath);
677
+ }
678
+ if (stat.isDirectory()) {
679
+ await fs.cp(srcFullPath, tgtFullPath, { recursive: true });
680
+ }
681
+ else {
682
+ await fs.copyFile(srcFullPath, tgtFullPath);
683
+ }
684
+ return c.json({ success: true, newPath: targetPath });
685
+ }
686
+ catch (e) {
687
+ console.error('Copy error:', e);
688
+ return c.json({ error: 'Failed to copy' }, 500);
689
+ }
690
+ });
147
691
  router.post('/*', async (c) => {
692
+ const config = getConfig();
148
693
  const filePath = c.req.path.replace(/^\/api\/files/, '') || '/';
149
- const fullPath = path.join(config.root, filePath);
694
+ const rootParam = c.req.query('root');
695
+ let dirPath;
696
+ if (rootParam) {
697
+ dirPath = validateRoot(rootParam, config);
698
+ if (!dirPath)
699
+ return c.json({ error: 'Invalid root' }, 400);
700
+ }
701
+ else {
702
+ dirPath = findRootForPath(filePath, config);
703
+ if (!dirPath)
704
+ return c.json({ error: 'Access denied' }, 403);
705
+ }
706
+ const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
707
+ const fullPath = path.join(dirPath, relativePath);
150
708
  if (!isAllowed(fullPath, config)) {
151
709
  return c.json({ error: 'Access denied' }, 403);
152
710
  }
@@ -156,7 +714,19 @@ export function createFileRouter(config) {
156
714
  if (body.type === 'create') {
157
715
  const parentPath = body.parentPath || '';
158
716
  const name = body.name;
159
- const targetPath = path.join(config.root, parentPath, name);
717
+ let parentDirPath;
718
+ if (body.root) {
719
+ parentDirPath = validateRoot(body.root, config);
720
+ if (!parentDirPath) {
721
+ return c.json({ error: 'Invalid root' }, 400);
722
+ }
723
+ }
724
+ else {
725
+ parentDirPath = findRootForPath(parentPath, config);
726
+ if (!parentDirPath)
727
+ return c.json({ error: 'Access denied' }, 403);
728
+ }
729
+ const targetPath = path.join(parentDirPath, parentPath, name);
160
730
  if (!isAllowed(targetPath, config)) {
161
731
  return c.json({ error: 'Access denied' }, 403);
162
732
  }
@@ -170,7 +740,7 @@ export function createFileRouter(config) {
170
740
  }
171
741
  await fs.writeFile(targetPath, '', 'utf-8');
172
742
  }
173
- return c.json({ success: true, path: targetPath.replace(config.root, '') });
743
+ return c.json({ success: true, path: '/' + path.relative(parentDirPath, targetPath).replace(/\\/g, '/') });
174
744
  }
175
745
  catch (e) {
176
746
  return c.json({ error: 'Failed to create' }, 500);
@@ -188,19 +758,24 @@ export function createFileRouter(config) {
188
758
  }
189
759
  });
190
760
  router.put('/*', async (c) => {
191
- const filePath = c.req.path.replace(/^\/api\/files/, '') || '/';
192
- const fullPath = path.join(config.root, filePath);
193
- if (!isAllowed(fullPath, config)) {
194
- return c.json({ error: 'Access denied' }, 403);
195
- }
761
+ const config = getConfig();
196
762
  try {
197
763
  const body = await c.req.json();
198
- const { oldPath, newPath, isDirectory } = body;
764
+ const { oldPath, newPath, isDirectory, sourceRoot, targetRoot } = body;
199
765
  if (!oldPath || !newPath) {
200
766
  return c.json({ error: 'oldPath and newPath are required' }, 400);
201
767
  }
202
- const oldFullPath = path.join(config.root, oldPath);
203
- const newFullPath = path.join(config.root, newPath);
768
+ if (!sourceRoot || !targetRoot) {
769
+ return c.json({ error: 'sourceRoot and targetRoot are required' }, 400);
770
+ }
771
+ const validatedSourceRoot = validateRoot(sourceRoot, config);
772
+ const validatedTargetRoot = validateRoot(targetRoot, config);
773
+ if (!validatedSourceRoot)
774
+ return c.json({ error: 'Invalid source root' }, 400);
775
+ if (!validatedTargetRoot)
776
+ return c.json({ error: 'Invalid target root' }, 400);
777
+ const oldFullPath = path.join(validatedSourceRoot, oldPath);
778
+ const newFullPath = path.join(validatedTargetRoot, newPath);
204
779
  if (!isAllowed(oldFullPath, config) || !isAllowed(newFullPath, config)) {
205
780
  return c.json({ error: 'Access denied' }, 403);
206
781
  }
@@ -209,15 +784,32 @@ export function createFileRouter(config) {
209
784
  if (!stat.isDirectory()) {
210
785
  return c.json({ error: 'Source is not a directory' }, 400);
211
786
  }
212
- await fs.rename(oldFullPath, newFullPath);
213
787
  }
214
788
  else {
215
789
  const ext = path.extname(newPath).toLowerCase();
216
790
  if (!hasAllowedExtension(newPath, config.allowedExtensions)) {
217
791
  return c.json({ error: 'File type not allowed' }, 400);
218
792
  }
793
+ }
794
+ try {
219
795
  await fs.rename(oldFullPath, newFullPath);
220
796
  }
797
+ catch (err) {
798
+ if (err instanceof Error && 'code' in err && err.code === 'EXDEV') {
799
+ // Cross-filesystem move - copy then delete
800
+ if (isDirectory) {
801
+ await fs.cp(oldFullPath, newFullPath, { recursive: true });
802
+ await fs.rm(oldFullPath, { recursive: true });
803
+ }
804
+ else {
805
+ await fs.copyFile(oldFullPath, newFullPath);
806
+ await fs.unlink(oldFullPath);
807
+ }
808
+ }
809
+ else {
810
+ throw err;
811
+ }
812
+ }
221
813
  return c.json({ success: true, newPath });
222
814
  }
223
815
  catch (e) {
@@ -226,8 +818,22 @@ export function createFileRouter(config) {
226
818
  }
227
819
  });
228
820
  router.delete('/*', async (c) => {
821
+ const config = getConfig();
229
822
  const filePath = c.req.path.replace(/^\/api\/files/, '') || '/';
230
- const fullPath = path.join(config.root, filePath);
823
+ const rootParam = c.req.query('root');
824
+ let dirPath;
825
+ if (rootParam) {
826
+ dirPath = validateRoot(rootParam, config);
827
+ if (!dirPath)
828
+ return c.json({ error: 'Invalid root' }, 400);
829
+ }
830
+ else {
831
+ dirPath = findRootForPath(filePath, config);
832
+ if (!dirPath)
833
+ return c.json({ error: 'Access denied' }, 403);
834
+ }
835
+ const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
836
+ const fullPath = path.join(dirPath, relativePath);
231
837
  if (!isAllowed(fullPath, config)) {
232
838
  return c.json({ error: 'Access denied' }, 403);
233
839
  }