colonynote 1.0.0-beta.9 → 1.0.1

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 +109 -66
  2. package/README.zh.md +108 -65
  3. package/bin/colonynote.js +48 -81
  4. package/dist/client/assets/arc-CQThWgzz.js +1 -0
  5. package/dist/client/assets/architecture-YZFGNWBL-DtP_HzvZ.js +1 -0
  6. package/dist/client/assets/architectureDiagram-Q4EWVU46-CduhJNdk.js +36 -0
  7. package/dist/client/assets/array-C0548cPn.js +1 -0
  8. package/dist/client/assets/blockDiagram-DXYQGD6D-Bge_LBJ4.js +132 -0
  9. package/dist/client/assets/c4Diagram-AHTNJAMY-CIoq2al4.js +10 -0
  10. package/dist/client/assets/channel-CkBCwOh7.js +1 -0
  11. package/dist/client/assets/chunk-2KRD3SAO-DWEU0E2x.js +1 -0
  12. package/dist/client/assets/chunk-4BX2VUAB-D9btov2c.js +1 -0
  13. package/dist/client/assets/chunk-4TB4RGXK-CWGCiPPE.js +206 -0
  14. package/dist/client/assets/chunk-55IACEB6-Bko5tMmv.js +1 -0
  15. package/dist/client/assets/chunk-67CJDMHE-BNJbWbd3.js +1 -0
  16. package/dist/client/assets/chunk-7N4EOEYR-BZ6bA78p.js +1 -0
  17. package/dist/client/assets/chunk-AA7GKIK3-Dpen34JB.js +1 -0
  18. package/dist/client/assets/chunk-CIAEETIT-CyiIj1js.js +1 -0
  19. package/dist/client/assets/chunk-Dlc7tRH4.js +1 -0
  20. package/dist/client/assets/chunk-EDXVE4YY-DIUgGcqH.js +1 -0
  21. package/dist/client/assets/{chunk-FMBD7UC4-C7A8kZzG.js → chunk-FMBD7UC4-CRj4bnZr.js} +2 -2
  22. package/dist/client/assets/chunk-FOC6F5B3-BL67Wbz_.js +1 -0
  23. package/dist/client/assets/chunk-K5T4RW27-C8czLGM6.js +94 -0
  24. package/dist/client/assets/chunk-KGLVRYIC-DMMvz8yi.js +1 -0
  25. package/dist/client/assets/chunk-LIHQZDEY-CJaus4QW.js +1 -0
  26. package/dist/client/assets/chunk-ORNJ4GCN-TM5MkxJb.js +1 -0
  27. package/dist/client/assets/chunk-OYMX7WX6-DZJR2tbT.js +231 -0
  28. package/dist/client/assets/chunk-QZHKN3VN-2Ges7DwK.js +1 -0
  29. package/dist/client/assets/chunk-YZCP3GAM-P9O7utk9.js +1 -0
  30. package/dist/client/assets/classDiagram-6PBFFD2Q-ChQeyBcv.js +1 -0
  31. package/dist/client/assets/classDiagram-v2-HSJHXN6E-BALSptE2.js +1 -0
  32. package/dist/client/assets/clone-D3BIJwxV.js +1 -0
  33. package/dist/client/assets/cose-bilkent-S5V4N54A-BaWITYex.js +1 -0
  34. package/dist/client/assets/cytoscape.esm-DT0IEibP.js +321 -0
  35. package/dist/client/assets/dagre-KV5264BT-BR2SAWWM.js +4 -0
  36. package/dist/client/assets/dagre-wVYwri9O.js +1 -0
  37. package/dist/client/assets/defaultLocale-ClcAPJ5U.js +1 -0
  38. package/dist/client/assets/diagram-5BDNPKRD-D-6-Gum5.js +10 -0
  39. package/dist/client/assets/diagram-G4DWMVQ6-knal8ynW.js +24 -0
  40. package/dist/client/assets/diagram-MMDJMWI5-Cq28VT9h.js +43 -0
  41. package/dist/client/assets/diagram-TYMM5635-_ahIpIbu.js +24 -0
  42. package/dist/client/assets/erDiagram-SMLLAGMA-DWWpmrCX.js +85 -0
  43. package/dist/client/assets/flatten-CMqdPloh.js +1 -0
  44. package/dist/client/assets/flowDiagram-DWJPFMVM-QgTx1hit.js +162 -0
  45. package/dist/client/assets/ganttDiagram-T4ZO3ILL-BrlOV7sd.js +292 -0
  46. package/dist/client/assets/gitGraph-7Q5UKJZL-CaJwRPFW.js +1 -0
  47. package/dist/client/assets/gitGraphDiagram-UUTBAWPF-C1hAcDJi.js +106 -0
  48. package/dist/client/assets/graphlib-HzYvs4aA.js +1 -0
  49. package/dist/client/assets/identity-CQcu21F9.js +1 -0
  50. package/dist/client/assets/index-C9TyPVfK.js +574 -0
  51. package/dist/client/assets/index-ZllE5QRx.css +2 -0
  52. package/dist/client/assets/info-OMHHGYJF-DsnC4r6b.js +1 -0
  53. package/dist/client/assets/infoDiagram-42DDH7IO-CaDp7AXt.js +2 -0
  54. package/dist/client/assets/init-TU6eJ00_.js +1 -0
  55. package/dist/client/assets/ishikawaDiagram-UXIWVN3A-rIk_ch8r.js +70 -0
  56. package/dist/client/assets/journeyDiagram-VCZTEJTY-AKlpEShH.js +139 -0
  57. package/dist/client/assets/kanban-definition-6JOO6SKY-DG7E5YsZ.js +89 -0
  58. package/dist/client/assets/katex-HOUACuRw.js +257 -0
  59. package/dist/client/assets/linear-BhtBzbUW.js +1 -0
  60. package/dist/client/assets/mermaid-parser.core-C5TKoM7K.js +4 -0
  61. package/dist/client/assets/mindmap-definition-QFDTVHPH-DJ__gZG0.js +96 -0
  62. package/dist/client/assets/ordinal-DCsgWfZW.js +1 -0
  63. package/dist/client/assets/packet-4T2RLAQJ-B7X34Q_f.js +1 -0
  64. package/dist/client/assets/path-yo4Xej8w.js +1 -0
  65. package/dist/client/assets/pie-ZZUOXDRM-BfvCWICT.js +1 -0
  66. package/dist/client/assets/pieDiagram-DEJITSTG-MP7sf0Hn.js +30 -0
  67. package/dist/client/assets/quadrantDiagram-34T5L4WZ-C5-YkrxD.js +7 -0
  68. package/dist/client/assets/radar-PYXPWWZC-BQu_7Ie9.js +1 -0
  69. package/dist/client/assets/reduce-Cb52axiO.js +1 -0
  70. package/dist/client/assets/requirementDiagram-MS252O5E-CA27k-YW.js +84 -0
  71. package/dist/client/assets/rough.esm-DulVNktb.js +1 -0
  72. package/dist/client/assets/sankeyDiagram-XADWPNL6-DXKCqxys.js +10 -0
  73. package/dist/client/assets/sequenceDiagram-FGHM5R23-DgqvjCb6.js +157 -0
  74. package/dist/client/assets/stateDiagram-FHFEXIEX-CLQg0ba3.js +1 -0
  75. package/dist/client/assets/stateDiagram-v2-QKLJ7IA2-BojxKOV4.js +1 -0
  76. package/dist/client/assets/timeline-definition-GMOUNBTQ-CeOHNNzc.js +120 -0
  77. package/dist/client/assets/treeView-SZITEDCU-9OOXnf4D.js +1 -0
  78. package/dist/client/assets/treemap-W4RFUUIX-BdE-jrpA.js +1 -0
  79. package/dist/client/assets/vennDiagram-DHZGUBPP-b_x_2tJx.js +34 -0
  80. package/dist/client/assets/wardley-RL74JXVD-Tmb0J3Ps.js +1 -0
  81. package/dist/client/assets/wardleyDiagram-NUSXRM2D-C8WSLFLG.js +20 -0
  82. package/dist/client/assets/xychartDiagram-5P7HB3ND-CLRgesgk.js +7 -0
  83. package/dist/client/index.html +7 -2
  84. package/dist/config.js +122 -52
  85. package/dist/server/api.js +695 -32
  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-D--Y6Qnf.js +0 -1
  93. package/dist/client/assets/_baseUniq-CGmIayRA.js +0 -1
  94. package/dist/client/assets/arc-JEfbYn4Z.js +0 -1
  95. package/dist/client/assets/architectureDiagram-2XIMDMQ5-MWOMordV.js +0 -36
  96. package/dist/client/assets/blockDiagram-WCTKOSBZ-Dmag7Wj_.js +0 -132
  97. package/dist/client/assets/c4Diagram-IC4MRINW-C3_AIswr.js +0 -10
  98. package/dist/client/assets/channel-2eZL646b.js +0 -1
  99. package/dist/client/assets/chunk-4BX2VUAB-Cw3UPx2y.js +0 -1
  100. package/dist/client/assets/chunk-55IACEB6-B2NTtZaZ.js +0 -1
  101. package/dist/client/assets/chunk-JSJVCQXG-CzPi8WMG.js +0 -1
  102. package/dist/client/assets/chunk-KX2RTZJC-ea2QZZjB.js +0 -1
  103. package/dist/client/assets/chunk-NQ4KR5QH-B6pfGakc.js +0 -220
  104. package/dist/client/assets/chunk-QZHKN3VN-C0jglaZo.js +0 -1
  105. package/dist/client/assets/chunk-WL4C6EOR-CFMwJdhq.js +0 -189
  106. package/dist/client/assets/classDiagram-VBA2DB6C-3mkluTsn.js +0 -1
  107. package/dist/client/assets/classDiagram-v2-RAHNMMFH-3mkluTsn.js +0 -1
  108. package/dist/client/assets/clone-ByYsCwKu.js +0 -1
  109. package/dist/client/assets/cose-bilkent-S5V4N54A-D72MQZnH.js +0 -1
  110. package/dist/client/assets/cytoscape.esm-BQaXIfA_.js +0 -331
  111. package/dist/client/assets/dagre-KLK3FWXG-7JkAxkM4.js +0 -4
  112. package/dist/client/assets/defaultLocale-DX6XiGOO.js +0 -1
  113. package/dist/client/assets/diagram-E7M64L7V-Ug-UhMUJ.js +0 -24
  114. package/dist/client/assets/diagram-IFDJBPK2-Dkg0uhRn.js +0 -43
  115. package/dist/client/assets/diagram-P4PSJMXO-B2p0xObJ.js +0 -24
  116. package/dist/client/assets/erDiagram-INFDFZHY-brLn9Si4.js +0 -70
  117. package/dist/client/assets/flowDiagram-PKNHOUZH-BE1LUc-D.js +0 -162
  118. package/dist/client/assets/ganttDiagram-A5KZAMGK-Cpxwz2ZR.js +0 -292
  119. package/dist/client/assets/gitGraphDiagram-K3NZZRJ6-34Y0DLO4.js +0 -65
  120. package/dist/client/assets/graph-BdaeFsUq.js +0 -1
  121. package/dist/client/assets/index-AcpT_uDS.css +0 -1
  122. package/dist/client/assets/index-l_1AZZNa.js +0 -705
  123. package/dist/client/assets/infoDiagram-LFFYTUFH-DuMz9iqk.js +0 -2
  124. package/dist/client/assets/init-Gi6I4Gst.js +0 -1
  125. package/dist/client/assets/ishikawaDiagram-PHBUUO56-Ck0mnRfv.js +0 -70
  126. package/dist/client/assets/journeyDiagram-4ABVD52K-C94-O771.js +0 -139
  127. package/dist/client/assets/kanban-definition-K7BYSVSG-l46sso-6.js +0 -89
  128. package/dist/client/assets/katex-B1X10hvy.js +0 -261
  129. package/dist/client/assets/layout-CPSfpfI_.js +0 -1
  130. package/dist/client/assets/linear-DdrJMl3C.js +0 -1
  131. package/dist/client/assets/mindmap-definition-YRQLILUH-DHyNrbph.js +0 -68
  132. package/dist/client/assets/ordinal-Cboi1Yqb.js +0 -1
  133. package/dist/client/assets/pieDiagram-SKSYHLDU-BN73IyPO.js +0 -30
  134. package/dist/client/assets/quadrantDiagram-337W2JSQ-CRNMUsUn.js +0 -7
  135. package/dist/client/assets/requirementDiagram-Z7DCOOCP-D72Qsjcq.js +0 -73
  136. package/dist/client/assets/sankeyDiagram-WA2Y5GQK-DedybDaM.js +0 -10
  137. package/dist/client/assets/sequenceDiagram-2WXFIKYE-Cql4DQP3.js +0 -145
  138. package/dist/client/assets/stateDiagram-RAJIS63D-ChVgsQCJ.js +0 -1
  139. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-DpTKcB4R.js +0 -1
  140. package/dist/client/assets/timeline-definition-YZTLITO2-C4zgLb8F.js +0 -61
  141. package/dist/client/assets/treemap-KZPCXAKY-C56WA1HL.js +0 -162
  142. package/dist/client/assets/vennDiagram-LZ73GAT5-B-0T5e8m.js +0 -34
  143. package/dist/client/assets/xychartDiagram-JWTSCODW-D3_TT24I.js +0 -7
  144. package/dist/server/app.js +0 -14
@@ -1,45 +1,126 @@
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, visited = new Set()) {
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
+ let isDir = entry.isDirectory();
65
+ let isFile = entry.isFile();
66
+ // 处理符号链接:跟随链接获取实际类型
67
+ if (entry.isSymbolicLink()) {
68
+ try {
69
+ const stat = await fs.stat(fullPath);
70
+ isDir = stat.isDirectory();
71
+ isFile = stat.isFile();
72
+ }
73
+ catch {
74
+ // 符号链接目标不存在或无法访问,跳过
75
+ continue;
76
+ }
77
+ }
18
78
  if (!config.showHiddenFiles && entry.name.startsWith('.'))
19
79
  continue;
20
- const fullPath = path.join(dir, entry.name);
21
- if (entry.isDirectory()) {
22
- const children = await walkDirectory(fullPath, config);
80
+ if (matcher.isIgnored(fullPath, isDir))
81
+ continue;
82
+ if (isDir) {
83
+ // 对符号链接目录进行循环检测(防止符号链接指向祖先目录导致无限递归)
84
+ if (entry.isSymbolicLink()) {
85
+ let realPath;
86
+ try {
87
+ realPath = await fs.realpath(fullPath);
88
+ }
89
+ catch {
90
+ realPath = fullPath;
91
+ }
92
+ if (visited.has(realPath))
93
+ continue;
94
+ visited.add(realPath);
95
+ }
96
+ const children = await walkDirectory(fullPath, dirPath, config, matcher, visited);
97
+ const relativePath = path.relative(dirPath, fullPath).replace(/\\/g, '/');
23
98
  nodes.push({
24
99
  name: entry.name,
25
- path: fullPath.replace(config.root, '').replace(/\\/g, '/'),
100
+ path: relativePath ? '/' + relativePath : '/',
26
101
  type: 'directory',
102
+ rootPath: dirPath,
27
103
  children,
28
104
  });
29
105
  }
30
- else if (entry.isFile()) {
106
+ else if (isFile) {
31
107
  if (hasAllowedExtension(entry.name, config.allowedExtensions)) {
108
+ const relativePath = path.relative(dirPath, fullPath).replace(/\\/g, '/');
32
109
  nodes.push({
33
110
  name: entry.name,
34
- path: fullPath.replace(config.root, '').replace(/\\/g, '/'),
111
+ path: relativePath ? '/' + relativePath : '/',
35
112
  type: 'file',
113
+ rootPath: dirPath,
36
114
  });
37
115
  }
38
116
  }
39
117
  }
40
118
  }
41
119
  catch (e) {
42
- // ignore errors
120
+ // Re-throw error if this is the root directory, otherwise ignore
121
+ if (dir === dirPath) {
122
+ throw e;
123
+ }
43
124
  }
44
125
  nodes.sort((a, b) => {
45
126
  if (a.type === 'directory' && b.type === 'file')
@@ -50,48 +131,367 @@ async function walkDirectory(dir, config) {
50
131
  });
51
132
  return nodes;
52
133
  }
53
- export function createFileRouter(config) {
134
+ export function createMutableConfigHolder(initialConfig, initialMatcher) {
135
+ let config = initialConfig;
136
+ let matcher = initialMatcher;
137
+ return {
138
+ get config() { return config; },
139
+ get matcher() { return matcher; },
140
+ setConfig(c) { config = c; },
141
+ setMatcher(m) { matcher = m; },
142
+ };
143
+ }
144
+ export function createFileRouter(holder, env = 'production') {
54
145
  const router = new Hono();
146
+ function getConfig() { return holder.config; }
147
+ function getMatcher() { return holder.matcher; }
148
+ let treeCache = null;
149
+ const CACHE_TTL_MS = 3000;
150
+ function computeConfigHash() {
151
+ const config = getConfig();
152
+ return JSON.stringify({
153
+ dirs: config.dirs.map(d => ({ path: d.path, name: d.name })),
154
+ showHiddenFiles: config.showHiddenFiles,
155
+ allowedExtensions: config.allowedExtensions,
156
+ ignore: config.ignore,
157
+ });
158
+ }
159
+ function invalidateTreeCache() {
160
+ treeCache = null;
161
+ }
162
+ async function getFileGroups() {
163
+ const config = getConfig();
164
+ const matcher = getMatcher();
165
+ const now = Date.now();
166
+ const hash = computeConfigHash();
167
+ if (treeCache && (now - treeCache.timestamp) < CACHE_TTL_MS && treeCache.configHash === hash) {
168
+ return treeCache.groups;
169
+ }
170
+ const groups = await Promise.all(config.dirs.map(async (dir) => {
171
+ try {
172
+ return {
173
+ root: dir,
174
+ files: await walkDirectory(dir.path, dir.path, config, matcher)
175
+ };
176
+ }
177
+ catch (e) {
178
+ return {
179
+ root: dir,
180
+ files: [],
181
+ error: e instanceof Error ? e.message : 'Failed to read directory'
182
+ };
183
+ }
184
+ }));
185
+ treeCache = { groups, timestamp: now, configHash: hash };
186
+ return groups;
187
+ }
55
188
  router.get('/config', async (c) => {
189
+ const config = getConfig();
56
190
  return c.json({
57
191
  showHiddenFiles: config.showHiddenFiles,
58
192
  allowedExtensions: config.allowedExtensions,
193
+ ignore: config.ignore,
59
194
  });
60
195
  });
61
196
  router.patch('/config', async (c) => {
197
+ const config = getConfig();
198
+ const matcher = getMatcher();
62
199
  try {
63
200
  const body = await c.req.json();
64
- const allowedFields = ['showHiddenFiles', 'allowedExtensions'];
201
+ const allowedFields = ['showHiddenFiles', 'allowedExtensions', 'ignore'];
65
202
  const updates = {};
66
203
  for (const key of allowedFields) {
67
204
  if (key in body) {
68
205
  updates[key] = body[key];
69
206
  }
70
207
  }
71
- saveUserConfig(config.root, updates);
208
+ // 先更新内存中的配置,再保存到文件
72
209
  if (typeof updates.showHiddenFiles === 'boolean') {
73
210
  config.showHiddenFiles = updates.showHiddenFiles;
74
211
  }
75
212
  if (Array.isArray(updates.allowedExtensions)) {
76
213
  config.allowedExtensions = updates.allowedExtensions;
77
214
  }
78
- return c.json({ success: true, config: { showHiddenFiles: config.showHiddenFiles, allowedExtensions: config.allowedExtensions } });
215
+ if (updates.ignore) {
216
+ if (Array.isArray(updates.ignore.patterns)) {
217
+ config.ignore.patterns = updates.ignore.patterns;
218
+ matcher.updateGlobalPatterns(updates.ignore.patterns);
219
+ }
220
+ }
221
+ saveConfig(config, env);
222
+ invalidateTreeCache();
223
+ return c.json({ success: true, config: { showHiddenFiles: config.showHiddenFiles, allowedExtensions: config.allowedExtensions, ignore: config.ignore } });
79
224
  }
80
225
  catch (e) {
81
226
  console.error('Failed to update config:', e);
82
227
  return c.json({ error: 'Failed to update config' }, 500);
83
228
  }
84
229
  });
230
+ // Dir management routes
231
+ router.get('/dirs', async (c) => {
232
+ const config = getConfig();
233
+ return c.json({ dirs: config.dirs });
234
+ });
235
+ router.post('/dirs', async (c) => {
236
+ const config = getConfig();
237
+ try {
238
+ const body = await c.req.json();
239
+ const newPath = body.path;
240
+ if (!newPath)
241
+ return c.json({ error: 'Path is required' }, 400);
242
+ try {
243
+ const stat = await fs.stat(newPath);
244
+ if (!stat.isDirectory())
245
+ return c.json({ error: 'Path must be a directory' }, 400);
246
+ }
247
+ catch {
248
+ return c.json({ error: 'Path does not exist' }, 400);
249
+ }
250
+ if (checkSensitivePath(newPath))
251
+ return c.json({ error: 'Sensitive path not allowed' }, 400);
252
+ const nested = checkNestedPath(newPath, config.dirs);
253
+ if (nested.isNested)
254
+ return c.json({ error: 'Nested path not allowed', conflictWith: nested.conflictWith, reason: nested.reason }, 400);
255
+ const newDir = { path: path.resolve(newPath), exclude: body.exclude, name: body.name };
256
+ config.dirs.push(newDir);
257
+ saveConfig(config, env);
258
+ invalidateTreeCache();
259
+ return c.json({ success: true, dir: newDir });
260
+ }
261
+ catch (e) {
262
+ return c.json({ error: 'Failed to add dir' }, 500);
263
+ }
264
+ });
265
+ router.delete('/dirs', async (c) => {
266
+ const config = getConfig();
267
+ const pathParam = c.req.query('path');
268
+ if (!pathParam)
269
+ return c.json({ error: 'path parameter required' }, 400);
270
+ const idx = config.dirs.findIndex(r => path.resolve(r.path) === path.resolve(pathParam));
271
+ if (idx === -1)
272
+ return c.json({ error: 'Dir not found' }, 404);
273
+ config.dirs.splice(idx, 1);
274
+ saveConfig(config, env);
275
+ invalidateTreeCache();
276
+ return c.json({ success: true });
277
+ });
278
+ router.patch('/dirs', async (c) => {
279
+ const config = getConfig();
280
+ try {
281
+ const body = await c.req.json();
282
+ const { path: dirPath, exclude, name } = body;
283
+ if (!dirPath)
284
+ return c.json({ error: 'Path is required' }, 400);
285
+ const dir = config.dirs.find(r => path.resolve(r.path) === path.resolve(dirPath));
286
+ if (!dir)
287
+ return c.json({ error: 'Dir not found' }, 404);
288
+ if (exclude !== undefined)
289
+ dir.exclude = exclude;
290
+ if (name !== undefined)
291
+ dir.name = name;
292
+ saveConfig(config, env);
293
+ invalidateTreeCache();
294
+ return c.json({ success: true, dir });
295
+ }
296
+ catch (e) {
297
+ return c.json({ error: 'Failed to update dir' }, 500);
298
+ }
299
+ });
300
+ router.get('/dirs/browse', async (c) => {
301
+ const rawPath = c.req.query('path') || '';
302
+ if (!rawPath.trim())
303
+ return c.json({ dirs: [], currentPath: '' });
304
+ let resolvedPath;
305
+ if (rawPath === '~' || rawPath.startsWith('~/')) {
306
+ resolvedPath = path.join(os.homedir(), rawPath.slice(1));
307
+ }
308
+ else {
309
+ resolvedPath = path.resolve(rawPath);
310
+ }
311
+ try {
312
+ const stat = await fs.stat(resolvedPath);
313
+ if (!stat.isDirectory()) {
314
+ return c.json({ dirs: [], currentPath: rawPath });
315
+ }
316
+ const entries = await fs.readdir(resolvedPath, { withFileTypes: true });
317
+ const dirs = [];
318
+ for (const entry of entries) {
319
+ if (entry.name.startsWith('.'))
320
+ continue;
321
+ const fullPath = path.join(resolvedPath, entry.name);
322
+ if (entry.isDirectory() && !checkSensitivePath(fullPath)) {
323
+ dirs.push(fullPath);
324
+ }
325
+ }
326
+ dirs.sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
327
+ return c.json({ dirs, currentPath: rawPath });
328
+ }
329
+ catch {
330
+ return c.json({ dirs: [], currentPath: rawPath });
331
+ }
332
+ });
333
+ router.get('/dirs/search', async (c) => {
334
+ const config = getConfig();
335
+ const query = c.req.query('q') || '';
336
+ const rawRoot = c.req.query('root');
337
+ const mode = c.req.query('mode') || 'fuzzy';
338
+ let searchRoot;
339
+ if (rawRoot === '~' || rawRoot === '') {
340
+ searchRoot = os.homedir();
341
+ }
342
+ else if (rawRoot === '/') {
343
+ searchRoot = '/';
344
+ }
345
+ else if (rawRoot && rawRoot.startsWith('~/')) {
346
+ searchRoot = path.join(os.homedir(), rawRoot.slice(2));
347
+ }
348
+ else if (rawRoot && path.isAbsolute(rawRoot)) {
349
+ searchRoot = path.normalize(rawRoot);
350
+ }
351
+ else if (rawRoot) {
352
+ // Relative path like 'projects' → resolve relative to home
353
+ searchRoot = path.join(os.homedir(), rawRoot);
354
+ }
355
+ else {
356
+ searchRoot = os.homedir();
357
+ }
358
+ // Browse mode: list direct children directories
359
+ if (mode === 'browse') {
360
+ try {
361
+ const stat = await fs.stat(searchRoot);
362
+ if (!stat.isDirectory()) {
363
+ return c.json({ matches: [] });
364
+ }
365
+ const entries = await fs.readdir(searchRoot, { withFileTypes: true });
366
+ const dirs = [];
367
+ for (const entry of entries) {
368
+ if (entry.name.startsWith('.'))
369
+ continue;
370
+ const fullPath = path.join(searchRoot, entry.name);
371
+ if (entry.isDirectory() && !checkSensitivePath(fullPath)) {
372
+ dirs.push({ path: fullPath, score: 0, indexes: [] });
373
+ }
374
+ }
375
+ dirs.sort((a, b) => path.basename(a.path).localeCompare(path.basename(b.path)));
376
+ return c.json({ matches: dirs });
377
+ }
378
+ catch {
379
+ return c.json({ matches: [] });
380
+ }
381
+ }
382
+ // Search mode requires non-empty query
383
+ if (!query.trim())
384
+ return c.json({ matches: [] });
385
+ const MAX_DEPTH = 5;
386
+ const MAX_CANDIDATES = 10000;
387
+ const MAX_RESULTS = 100;
388
+ const candidates = [];
389
+ async function traverse(dir, depth) {
390
+ if (depth > MAX_DEPTH || candidates.length >= MAX_CANDIDATES)
391
+ return;
392
+ try {
393
+ const entries = await fs.readdir(dir, { withFileTypes: true });
394
+ const subDirs = [];
395
+ for (const entry of entries) {
396
+ if (entry.name.startsWith('.'))
397
+ continue;
398
+ const fullPath = path.join(dir, entry.name);
399
+ // Check if path matches any ignore pattern
400
+ const isIgnored = config.ignore?.patterns?.some(pattern => {
401
+ const normalizedPattern = pattern.endsWith('/') ? pattern.slice(0, -1) : pattern;
402
+ return minimatch(fullPath, normalizedPattern, { dot: true, matchBase: true });
403
+ });
404
+ if (isIgnored)
405
+ continue;
406
+ if (entry.isDirectory() && !checkSensitivePath(fullPath)) {
407
+ subDirs.push(fullPath);
408
+ }
409
+ }
410
+ for (const subDir of subDirs) {
411
+ if (candidates.length >= MAX_CANDIDATES)
412
+ break;
413
+ candidates.push({ name: path.basename(subDir), fullPath: subDir });
414
+ }
415
+ for (const subDir of subDirs) {
416
+ if (candidates.length >= MAX_CANDIDATES)
417
+ break;
418
+ await traverse(subDir, depth + 1);
419
+ }
420
+ }
421
+ catch { }
422
+ }
423
+ await traverse(searchRoot, 0);
424
+ let results;
425
+ if (mode === 'prefix') {
426
+ const lowerQuery = query.toLowerCase();
427
+ results = candidates
428
+ .filter(c => c.name.toLowerCase().startsWith(lowerQuery))
429
+ .slice(0, MAX_RESULTS)
430
+ .map(c => ({
431
+ obj: c,
432
+ score: 0,
433
+ indexes: Array.from({ length: query.length }, (_, i) => i)
434
+ }));
435
+ }
436
+ else {
437
+ results = fuzzysort.go(query, candidates, {
438
+ key: 'name',
439
+ limit: MAX_RESULTS,
440
+ threshold: 0.5,
441
+ });
442
+ }
443
+ const matches = results.map(r => ({
444
+ path: r.obj.fullPath,
445
+ score: r.score,
446
+ indexes: r.indexes,
447
+ }));
448
+ return c.json({ matches });
449
+ });
450
+ // Lazy-load directory children on demand (performance optimization)
451
+ router.get('/children', async (c) => {
452
+ const config = getConfig();
453
+ const matcher = getMatcher();
454
+ const dirPathParam = c.req.query('dirPath');
455
+ const rootParam = c.req.query('root');
456
+ if (!dirPathParam)
457
+ return c.json({ error: 'dirPath is required' }, 400);
458
+ let dirPath;
459
+ if (rootParam) {
460
+ dirPath = validateRoot(rootParam, config);
461
+ if (!dirPath)
462
+ return c.json({ error: 'Invalid root' }, 400);
463
+ }
464
+ else {
465
+ dirPath = findRootForPath(dirPathParam, config);
466
+ if (!dirPath)
467
+ return c.json({ error: 'Access denied' }, 403);
468
+ }
469
+ const relativePath = dirPathParam.startsWith('/') ? dirPathParam.slice(1) : dirPathParam;
470
+ const fullPath = path.join(dirPath, relativePath);
471
+ if (!isAllowed(fullPath, config))
472
+ return c.json({ error: 'Access denied' }, 403);
473
+ try {
474
+ const stat = await fs.stat(fullPath);
475
+ if (!stat.isDirectory())
476
+ return c.json({ error: 'Not a directory' }, 400);
477
+ const children = await walkDirectory(fullPath, dirPath, config, matcher);
478
+ return c.json({ children });
479
+ }
480
+ catch (e) {
481
+ return c.json({ error: 'Failed to read directory' }, 500);
482
+ }
483
+ });
85
484
  router.get('/', async (c) => {
86
485
  try {
87
- const files = await walkDirectory(config.root, config);
88
- return c.json({ files });
486
+ const groups = await getFileGroups();
487
+ return c.json({ groups });
89
488
  }
90
489
  catch (e) {
91
490
  return c.json({ error: 'Failed to read directory' }, 500);
92
491
  }
93
492
  });
94
493
  router.get('/content', async (c) => {
494
+ const config = getConfig();
95
495
  const pathsParam = c.req.query('paths');
96
496
  if (!pathsParam) {
97
497
  return c.json({ error: 'paths parameter is required' }, 400);
@@ -99,7 +499,21 @@ export function createFileRouter(config) {
99
499
  const paths = pathsParam.split(',').filter(Boolean);
100
500
  const results = [];
101
501
  for (const filePath of paths) {
102
- const fullPath = path.join(config.root, filePath);
502
+ const rootParam = c.req.query('root');
503
+ let dirPath;
504
+ if (rootParam) {
505
+ dirPath = path.resolve(rootParam);
506
+ if (!config.dirs.some(r => path.resolve(r.path) === dirPath)) {
507
+ return c.json({ error: 'Invalid root' }, 400);
508
+ }
509
+ }
510
+ else {
511
+ dirPath = findRootForPath(filePath, config);
512
+ }
513
+ if (!dirPath)
514
+ return c.json({ error: 'Access denied' }, 403);
515
+ const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
516
+ const fullPath = path.join(dirPath, relativePath);
103
517
  if (!isAllowed(fullPath, config)) {
104
518
  continue;
105
519
  }
@@ -120,16 +534,132 @@ export function createFileRouter(config) {
120
534
  }
121
535
  return c.json({ files: results });
122
536
  });
537
+ router.get('/search', async (c) => {
538
+ const config = getConfig();
539
+ const query = c.req.query('q');
540
+ if (!query || !query.trim()) {
541
+ return c.json({ results: [] });
542
+ }
543
+ const limit = parseInt(c.req.query('limit') || '50', 10);
544
+ const rootParam = c.req.query('root');
545
+ let targetDirs;
546
+ if (rootParam) {
547
+ const validatedRoot = validateRoot(rootParam, config);
548
+ if (!validatedRoot) {
549
+ return c.json({ error: 'Invalid root' }, 400);
550
+ }
551
+ targetDirs = config.dirs.filter(d => path.resolve(d.path) === validatedRoot);
552
+ }
553
+ else {
554
+ targetDirs = config.dirs;
555
+ }
556
+ const results = [];
557
+ for (const dir of targetDirs) {
558
+ if (results.length >= limit)
559
+ break;
560
+ const dirPath = path.resolve(dir.path);
561
+ const dirName = dir.name || path.basename(dirPath);
562
+ try {
563
+ const { stdout, stderr } = await new Promise((resolve, reject) => {
564
+ execFile('rg', [
565
+ '--json',
566
+ '--ignore-case',
567
+ '--max-count', String(Math.min(limit, 5)),
568
+ '--line-number',
569
+ '--context', '1',
570
+ '-g', '*.md',
571
+ query,
572
+ dirPath,
573
+ ], { timeout: 5000, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
574
+ if (error && error.code !== 1) {
575
+ reject(error);
576
+ }
577
+ else {
578
+ resolve({ stdout, stderr });
579
+ }
580
+ });
581
+ });
582
+ if (stderr.includes('no matches'))
583
+ continue;
584
+ let currentPath = '';
585
+ let currentRelativePath = '';
586
+ let lines = [];
587
+ for (const rawLine of stdout.split('\n')) {
588
+ if (!rawLine.trim())
589
+ continue;
590
+ try {
591
+ const obj = JSON.parse(rawLine);
592
+ if (obj.type === 'begin') {
593
+ currentPath = obj.data.path.text;
594
+ currentRelativePath = path.relative(dirPath, currentPath).replace(/\\/g, '/');
595
+ lines = [];
596
+ }
597
+ else if (obj.type === 'match') {
598
+ const lineText = obj.data.lines.text;
599
+ lines.push({ line: lineText, lineNumber: obj.data.line_number, subMatches: obj.data.submatches });
600
+ }
601
+ else if (obj.type === 'end') {
602
+ if (lines.length > 0) {
603
+ const firstMatch = lines[0];
604
+ const snippet = firstMatch.line.trim();
605
+ results.push({
606
+ path: '/' + currentRelativePath,
607
+ name: path.basename(currentRelativePath),
608
+ rootPath: dirPath,
609
+ rootName: dirName,
610
+ matchedLine: snippet,
611
+ });
612
+ }
613
+ }
614
+ }
615
+ catch {
616
+ // skip parse errors
617
+ }
618
+ }
619
+ }
620
+ catch {
621
+ // skip directories where rg fails
622
+ }
623
+ }
624
+ return c.json({ results: results.slice(0, limit) });
625
+ });
123
626
  router.get('/*', async (c) => {
627
+ const config = getConfig();
628
+ const matcher = getMatcher();
124
629
  const filePath = c.req.path.replace(/^\/api\/files/, '') || '/';
125
- const fullPath = path.join(config.root, filePath);
630
+ // Handle root path - return grouped file tree
631
+ if (filePath === '/' || filePath === '') {
632
+ try {
633
+ const groups = await getFileGroups();
634
+ return c.json({ groups });
635
+ }
636
+ catch (e) {
637
+ return c.json({ error: 'Failed to read directory' }, 500);
638
+ }
639
+ }
640
+ // Handle root parameter from query string
641
+ const rootParam = c.req.query('root');
642
+ let dirPath;
643
+ if (rootParam) {
644
+ dirPath = path.resolve(rootParam);
645
+ if (!config.dirs.some(r => path.resolve(r.path) === dirPath)) {
646
+ return c.json({ error: 'Invalid root' }, 400);
647
+ }
648
+ }
649
+ else {
650
+ dirPath = findRootForPath(filePath, config);
651
+ }
652
+ if (!dirPath)
653
+ return c.json({ error: 'Access denied' }, 403);
654
+ const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
655
+ const fullPath = path.join(dirPath, relativePath);
126
656
  if (!isAllowed(fullPath, config)) {
127
657
  return c.json({ error: 'Access denied' }, 403);
128
658
  }
129
659
  try {
130
660
  const stat = await fs.stat(fullPath);
131
661
  if (stat.isDirectory()) {
132
- const files = await walkDirectory(fullPath, config);
662
+ const files = await walkDirectory(fullPath, dirPath, config, matcher);
133
663
  return c.json({ files });
134
664
  }
135
665
  }
@@ -137,6 +667,36 @@ export function createFileRouter(config) {
137
667
  // not a directory
138
668
  }
139
669
  try {
670
+ const ext = path.extname(fullPath).toLowerCase();
671
+ const binaryExts = new Set([
672
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico',
673
+ '.pdf', '.mp3', '.mp4', '.wav', '.ogg', '.webm', '.zip', '.tar', '.gz',
674
+ ]);
675
+ const contentTypeMap = {
676
+ '.png': 'image/png',
677
+ '.jpg': 'image/jpeg',
678
+ '.jpeg': 'image/jpeg',
679
+ '.gif': 'image/gif',
680
+ '.webp': 'image/webp',
681
+ '.svg': 'image/svg+xml',
682
+ '.bmp': 'image/bmp',
683
+ '.ico': 'image/x-icon',
684
+ '.pdf': 'application/pdf',
685
+ '.mp3': 'audio/mpeg',
686
+ '.mp4': 'video/mp4',
687
+ '.wav': 'audio/wav',
688
+ '.ogg': 'audio/ogg',
689
+ '.webm': 'video/webm',
690
+ '.zip': 'application/zip',
691
+ '.tar': 'application/x-tar',
692
+ '.gz': 'application/gzip',
693
+ };
694
+ if (binaryExts.has(ext)) {
695
+ const content = await fs.readFile(fullPath);
696
+ return new Response(content, {
697
+ headers: { 'Content-Type': contentTypeMap[ext] || 'application/octet-stream' },
698
+ });
699
+ }
140
700
  const content = await fs.readFile(fullPath, 'utf-8');
141
701
  return c.text(content);
142
702
  }
@@ -144,9 +704,64 @@ export function createFileRouter(config) {
144
704
  return c.json({ error: 'File not found' }, 404);
145
705
  }
146
706
  });
707
+ router.post('/copy', async (c) => {
708
+ const config = getConfig();
709
+ try {
710
+ const body = await c.req.json();
711
+ let { sourcePath, targetPath, sourceRoot, targetRoot } = body;
712
+ if (!sourcePath || !targetPath) {
713
+ return c.json({ error: 'sourcePath and targetPath required' }, 400);
714
+ }
715
+ if (!sourceRoot || !targetRoot) {
716
+ return c.json({ error: 'sourceRoot and targetRoot required' }, 400);
717
+ }
718
+ const validatedSourceRoot = validateRoot(sourceRoot, config);
719
+ const validatedTargetRoot = validateRoot(targetRoot, config);
720
+ if (!validatedSourceRoot)
721
+ return c.json({ error: 'Invalid source root' }, 400);
722
+ if (!validatedTargetRoot)
723
+ return c.json({ error: 'Invalid target root' }, 400);
724
+ const srcFullPath = path.join(validatedSourceRoot, sourcePath);
725
+ let tgtFullPath = path.join(validatedTargetRoot, targetPath);
726
+ const stat = await fs.stat(srcFullPath);
727
+ const targetExists = await fs.access(tgtFullPath).then(() => true).catch(() => false);
728
+ if (targetExists) {
729
+ const ext = path.extname(targetPath);
730
+ const base = path.basename(targetPath, ext);
731
+ const dir = path.dirname(targetPath);
732
+ targetPath = `${dir}/${base} (copy)${ext}`;
733
+ tgtFullPath = path.join(validatedTargetRoot, targetPath);
734
+ }
735
+ if (stat.isDirectory()) {
736
+ await fs.cp(srcFullPath, tgtFullPath, { recursive: true });
737
+ }
738
+ else {
739
+ await fs.copyFile(srcFullPath, tgtFullPath);
740
+ }
741
+ return c.json({ success: true, newPath: targetPath });
742
+ }
743
+ catch (e) {
744
+ console.error('Copy error:', e);
745
+ return c.json({ error: 'Failed to copy' }, 500);
746
+ }
747
+ });
147
748
  router.post('/*', async (c) => {
749
+ const config = getConfig();
148
750
  const filePath = c.req.path.replace(/^\/api\/files/, '') || '/';
149
- const fullPath = path.join(config.root, filePath);
751
+ const rootParam = c.req.query('root');
752
+ let dirPath;
753
+ if (rootParam) {
754
+ dirPath = validateRoot(rootParam, config);
755
+ if (!dirPath)
756
+ return c.json({ error: 'Invalid root' }, 400);
757
+ }
758
+ else {
759
+ dirPath = findRootForPath(filePath, config);
760
+ if (!dirPath)
761
+ return c.json({ error: 'Access denied' }, 403);
762
+ }
763
+ const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
764
+ const fullPath = path.join(dirPath, relativePath);
150
765
  if (!isAllowed(fullPath, config)) {
151
766
  return c.json({ error: 'Access denied' }, 403);
152
767
  }
@@ -156,7 +771,19 @@ export function createFileRouter(config) {
156
771
  if (body.type === 'create') {
157
772
  const parentPath = body.parentPath || '';
158
773
  const name = body.name;
159
- const targetPath = path.join(config.root, parentPath, name);
774
+ let parentDirPath;
775
+ if (body.root) {
776
+ parentDirPath = validateRoot(body.root, config);
777
+ if (!parentDirPath) {
778
+ return c.json({ error: 'Invalid root' }, 400);
779
+ }
780
+ }
781
+ else {
782
+ parentDirPath = findRootForPath(parentPath, config);
783
+ if (!parentDirPath)
784
+ return c.json({ error: 'Access denied' }, 403);
785
+ }
786
+ const targetPath = path.join(parentDirPath, parentPath, name);
160
787
  if (!isAllowed(targetPath, config)) {
161
788
  return c.json({ error: 'Access denied' }, 403);
162
789
  }
@@ -170,7 +797,7 @@ export function createFileRouter(config) {
170
797
  }
171
798
  await fs.writeFile(targetPath, '', 'utf-8');
172
799
  }
173
- return c.json({ success: true, path: targetPath.replace(config.root, '') });
800
+ return c.json({ success: true, path: '/' + path.relative(parentDirPath, targetPath).replace(/\\/g, '/') });
174
801
  }
175
802
  catch (e) {
176
803
  return c.json({ error: 'Failed to create' }, 500);
@@ -188,19 +815,24 @@ export function createFileRouter(config) {
188
815
  }
189
816
  });
190
817
  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
- }
818
+ const config = getConfig();
196
819
  try {
197
820
  const body = await c.req.json();
198
- const { oldPath, newPath, isDirectory } = body;
821
+ const { oldPath, newPath, isDirectory, sourceRoot, targetRoot } = body;
199
822
  if (!oldPath || !newPath) {
200
823
  return c.json({ error: 'oldPath and newPath are required' }, 400);
201
824
  }
202
- const oldFullPath = path.join(config.root, oldPath);
203
- const newFullPath = path.join(config.root, newPath);
825
+ if (!sourceRoot || !targetRoot) {
826
+ return c.json({ error: 'sourceRoot and targetRoot are required' }, 400);
827
+ }
828
+ const validatedSourceRoot = validateRoot(sourceRoot, config);
829
+ const validatedTargetRoot = validateRoot(targetRoot, config);
830
+ if (!validatedSourceRoot)
831
+ return c.json({ error: 'Invalid source root' }, 400);
832
+ if (!validatedTargetRoot)
833
+ return c.json({ error: 'Invalid target root' }, 400);
834
+ const oldFullPath = path.join(validatedSourceRoot, oldPath);
835
+ const newFullPath = path.join(validatedTargetRoot, newPath);
204
836
  if (!isAllowed(oldFullPath, config) || !isAllowed(newFullPath, config)) {
205
837
  return c.json({ error: 'Access denied' }, 403);
206
838
  }
@@ -209,15 +841,32 @@ export function createFileRouter(config) {
209
841
  if (!stat.isDirectory()) {
210
842
  return c.json({ error: 'Source is not a directory' }, 400);
211
843
  }
212
- await fs.rename(oldFullPath, newFullPath);
213
844
  }
214
845
  else {
215
846
  const ext = path.extname(newPath).toLowerCase();
216
847
  if (!hasAllowedExtension(newPath, config.allowedExtensions)) {
217
848
  return c.json({ error: 'File type not allowed' }, 400);
218
849
  }
850
+ }
851
+ try {
219
852
  await fs.rename(oldFullPath, newFullPath);
220
853
  }
854
+ catch (err) {
855
+ if (err instanceof Error && 'code' in err && err.code === 'EXDEV') {
856
+ // Cross-filesystem move - copy then delete
857
+ if (isDirectory) {
858
+ await fs.cp(oldFullPath, newFullPath, { recursive: true });
859
+ await fs.rm(oldFullPath, { recursive: true });
860
+ }
861
+ else {
862
+ await fs.copyFile(oldFullPath, newFullPath);
863
+ await fs.unlink(oldFullPath);
864
+ }
865
+ }
866
+ else {
867
+ throw err;
868
+ }
869
+ }
221
870
  return c.json({ success: true, newPath });
222
871
  }
223
872
  catch (e) {
@@ -226,8 +875,22 @@ export function createFileRouter(config) {
226
875
  }
227
876
  });
228
877
  router.delete('/*', async (c) => {
878
+ const config = getConfig();
229
879
  const filePath = c.req.path.replace(/^\/api\/files/, '') || '/';
230
- const fullPath = path.join(config.root, filePath);
880
+ const rootParam = c.req.query('root');
881
+ let dirPath;
882
+ if (rootParam) {
883
+ dirPath = validateRoot(rootParam, config);
884
+ if (!dirPath)
885
+ return c.json({ error: 'Invalid root' }, 400);
886
+ }
887
+ else {
888
+ dirPath = findRootForPath(filePath, config);
889
+ if (!dirPath)
890
+ return c.json({ error: 'Access denied' }, 403);
891
+ }
892
+ const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
893
+ const fullPath = path.join(dirPath, relativePath);
231
894
  if (!isAllowed(fullPath, config)) {
232
895
  return c.json({ error: 'Access denied' }, 403);
233
896
  }