dsh-gufa-code 0.0.0-stage → 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 (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -2
  3. package/client.js +11548 -0
  4. package/cordis.patch.yml +8 -0
  5. package/docs/icon-180.png +0 -0
  6. package/docs/icon.svg +18 -0
  7. package/docs/market-submission.md +32 -0
  8. package/docs/screenshot-ide.jpg +0 -0
  9. package/host/assets.js +31 -0
  10. package/host/chat.js +523 -0
  11. package/host/context.js +438 -0
  12. package/host/dev.js +35 -0
  13. package/host/fs.js +306 -0
  14. package/host/git.js +532 -0
  15. package/host/project.js +305 -0
  16. package/host/ssh.js +54 -0
  17. package/host/terminal-pty.js +237 -0
  18. package/host/terminal.js +947 -0
  19. package/icon.svg +18 -0
  20. package/index.js +63 -0
  21. package/locale/en.json +121 -0
  22. package/locale/zh.json +121 -0
  23. package/package.json +67 -4
  24. package/screenshots.json +3 -0
  25. package/scripts/build-client.mjs +38 -0
  26. package/scripts/selftest-client.mjs +457 -0
  27. package/scripts/selftest-host.mjs +197 -0
  28. package/scripts/selftest-http.mjs +284 -0
  29. package/scripts/selftest-router.mjs +101 -0
  30. package/scripts/selftest-terminal-client.mjs +314 -0
  31. package/scripts/selftest-terminal-pty.mjs +190 -0
  32. package/scripts/selftest-terminal.mjs +351 -0
  33. package/scripts/test-chat-render.mjs +702 -0
  34. package/scripts/test-chat.mjs +761 -0
  35. package/scripts/test-editor.mjs +516 -0
  36. package/scripts/verify-workspace-sessions.mjs +135 -0
  37. package/src/client/00-core.js +1686 -0
  38. package/src/client/10-icons.js +205 -0
  39. package/src/client/20-editor.js +1318 -0
  40. package/src/client/30-terminal.js +1510 -0
  41. package/src/client/40-chat.js +1184 -0
  42. package/src/client/46-gitgraph.js +278 -0
  43. package/src/client/50-panels.js +916 -0
  44. package/src/client/55-backdrop.js +508 -0
  45. package/src/client/60-dock.js +662 -0
  46. package/src/client/85-shortcuts.js +516 -0
  47. package/src/client/90-app.js +2272 -0
  48. package/src/client/99-main.js +449 -0
  49. package/vendor/monaco/LICENSE +21 -0
  50. package/vendor/monaco/ThirdPartyNotices.txt +448 -0
  51. package/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  52. package/vendor/monaco/vs/base/worker/workerMain.js +31 -0
  53. package/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  54. package/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  55. package/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  56. package/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  57. package/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  58. package/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  59. package/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  60. package/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  61. package/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  62. package/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  63. package/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  64. package/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  65. package/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  66. package/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  67. package/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  68. package/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  69. package/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  70. package/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  71. package/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  72. package/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  73. package/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  74. package/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  75. package/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  76. package/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  77. package/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  78. package/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  79. package/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  80. package/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  81. package/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  82. package/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  83. package/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  84. package/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  85. package/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  86. package/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  87. package/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  88. package/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  89. package/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  90. package/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  91. package/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  92. package/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  93. package/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  94. package/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  95. package/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  96. package/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  97. package/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  98. package/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  99. package/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  100. package/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  101. package/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  102. package/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  103. package/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  104. package/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  105. package/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  106. package/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  107. package/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  108. package/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  109. package/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  110. package/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  111. package/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  112. package/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  113. package/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  114. package/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  115. package/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  116. package/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  117. package/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  118. package/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  119. package/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  120. package/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  121. package/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  122. package/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  123. package/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  124. package/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  125. package/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  126. package/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  127. package/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  128. package/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  129. package/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
  130. package/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  131. package/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  132. package/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  133. package/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  134. package/vendor/monaco/vs/editor/editor.main.css +8 -0
  135. package/vendor/monaco/vs/editor/editor.main.js +798 -0
  136. package/vendor/monaco/vs/language/css/cssMode.js +13 -0
  137. package/vendor/monaco/vs/language/css/cssWorker.js +77 -0
  138. package/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  139. package/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
  140. package/vendor/monaco/vs/language/json/jsonMode.js +19 -0
  141. package/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
  142. package/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  143. package/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
  144. package/vendor/monaco/vs/loader.js +11 -0
  145. package/vendor/monaco/vs/nls.messages.de.js +21 -0
  146. package/vendor/monaco/vs/nls.messages.es.js +21 -0
  147. package/vendor/monaco/vs/nls.messages.fr.js +19 -0
  148. package/vendor/monaco/vs/nls.messages.it.js +19 -0
  149. package/vendor/monaco/vs/nls.messages.ja.js +21 -0
  150. package/vendor/monaco/vs/nls.messages.ko.js +19 -0
  151. package/vendor/monaco/vs/nls.messages.ru.js +21 -0
  152. package/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
  153. package/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
  154. package/vendor/xterm/LICENSE +21 -0
  155. package/vendor/xterm/LICENSE.addon-fit +19 -0
  156. package/vendor/xterm/addon-fit.js +2 -0
  157. package/vendor/xterm/xterm.css +285 -0
  158. package/vendor/xterm/xterm.js +2 -0
package/host/fs.js ADDED
@@ -0,0 +1,306 @@
1
+ /**
2
+ * 文件系统能力:列表 / 读取 / 保存 / 新建 / 重命名 / 删除。
3
+ * 所有路径都必须是「已打开项目根」内的相对路径,防止越权访问任意磁盘。
4
+ */
5
+ import { createHash, randomUUID } from 'node:crypto'
6
+ import { createReadStream } from 'node:fs'
7
+ import { access, cp, lstat, mkdir, open, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
8
+ import { execFile } from 'node:child_process'
9
+ import { promisify } from 'node:util'
10
+ import { constants } from 'node:fs'
11
+ import { tmpdir } from 'node:os'
12
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
13
+ import { ApiError, badRequest } from './context.js'
14
+
15
+ const TEXT_LIMIT = 4 * 1024 * 1024
16
+ const runCommand = promisify(execFile)
17
+ const LIST_LIMIT = 4000
18
+
19
+ /** 扩展名 → Monaco language id(与 VS Code 常用映射一致)。 */
20
+ export const LANG_BY_EXT = {
21
+ js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
22
+ ts: 'typescript', tsx: 'typescript', mts: 'typescript', cts: 'typescript',
23
+ json: 'json', jsonc: 'json', json5: 'json',
24
+ md: 'markdown', markdown: 'markdown', mdx: 'markdown',
25
+ css: 'css', scss: 'scss', less: 'less',
26
+ html: 'html', htm: 'html', vue: 'html', svelte: 'html',
27
+ xml: 'xml', svg: 'xml', plist: 'xml', csproj: 'xml',
28
+ yaml: 'yaml', yml: 'yaml', toml: 'ini', ini: 'ini', cfg: 'ini', conf: 'ini', properties: 'ini', env: 'ini',
29
+ py: 'python', pyw: 'python',
30
+ sh: 'shell', bash: 'shell', zsh: 'shell', fish: 'shell', ps1: 'powershell',
31
+ lua: 'lua', java: 'java', c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp', hxx: 'cpp',
32
+ cs: 'csharp', go: 'go', rs: 'rust', rb: 'ruby', php: 'php', sql: 'sql',
33
+ swift: 'swift', kt: 'kotlin', kts: 'kotlin', dart: 'dart', scala: 'scala',
34
+ dockerfile: 'dockerfile', makefile: 'makefile', cmake: 'cmake',
35
+ txt: 'plaintext', log: 'plaintext', gitignore: 'plaintext', lock: 'plaintext',
36
+ }
37
+
38
+ export function languageOf(path) {
39
+ const base = String(path).split(/[\\/]/).pop() ?? ''
40
+ if (/^dockerfile$/i.test(base)) return 'dockerfile'
41
+ if (/^makefile$/i.test(base)) return 'makefile'
42
+ if (/^\.?gitignore$/i.test(base) || /^\.?env/i.test(base)) return 'plaintext'
43
+ const dot = base.lastIndexOf('.')
44
+ if (dot <= 0) return 'plaintext'
45
+ return LANG_BY_EXT[base.slice(dot + 1).toLowerCase()] ?? 'plaintext'
46
+ }
47
+
48
+ /** 项目根内的相对路径解析(拒绝绝对路径、.. 逃逸与符号链接逃逸)。 */
49
+ export async function resolveProjectPath(root, raw, { create = false } = {}) {
50
+ if (typeof raw !== 'string') throw badRequest('path must be a string')
51
+ if (isAbsolute(raw)) throw new ApiError('path-invalid', '不允许绝对路径', 400)
52
+ const absolute = resolve(root, raw === '' ? '.' : raw)
53
+ const rel = relative(root, absolute)
54
+ if (rel !== '' && (rel.startsWith(`..${sep}`) || rel === '..' || isAbsolute(rel))) {
55
+ throw new ApiError('path-outside', '路径超出项目范围', 403)
56
+ }
57
+ if (create) {
58
+ const parent = await realpath(dirname(absolute)).catch(() => {
59
+ throw new ApiError('parent-missing', '父目录不存在', 404)
60
+ })
61
+ const relParent = relative(root, parent)
62
+ if (relParent !== '' && (relParent.startsWith(`..${sep}`) || isAbsolute(relParent))) {
63
+ throw new ApiError('path-outside', '父目录超出项目范围', 403)
64
+ }
65
+ } else {
66
+ const actual = await realpath(absolute).catch(() => {
67
+ throw new ApiError('not-found', `路径不存在:${raw}`, 404)
68
+ })
69
+ const relActual = relative(root, actual)
70
+ if (relActual !== '' && (relActual.startsWith(`..${sep}`) || isAbsolute(relActual))) {
71
+ throw new ApiError('path-outside', '路径超出项目范围', 403)
72
+ }
73
+ }
74
+ return absolute
75
+ }
76
+
77
+ function relativePath(root, absolute) {
78
+ return relative(root, absolute).split(sep).join('/')
79
+ }
80
+
81
+ function revisionOf(content, mtimeMs) {
82
+ return createHash('sha256').update(content).update(String(mtimeMs)).digest('hex')
83
+ }
84
+
85
+ async function projectOf(env, body) {
86
+ const project = env.projects.ensure(body.root)
87
+ return project.root
88
+ }
89
+
90
+ export function register(router, env) {
91
+ router.method('fs.list', async (body) => {
92
+ const root = await projectOf(env, body)
93
+ const absolute = await resolveProjectPath(root, typeof body.path === 'string' ? body.path : '')
94
+ const values = (await readdir(absolute, { withFileTypes: true })).filter((item) => item.name !== '.git')
95
+ const rows = await Promise.all(values.slice(0, LIST_LIMIT).map(async (item) => {
96
+ const full = join(absolute, item.name)
97
+ const entry = { name: item.name, path: relativePath(root, full), kind: item.isDirectory() ? 'dir' : 'file' }
98
+ try {
99
+ const info = await lstat(full)
100
+ entry.size = info.size
101
+ entry.mtimeMs = info.mtimeMs
102
+ } catch {
103
+ /* 权限/竞态:忽略详情 */
104
+ }
105
+ return entry
106
+ }))
107
+ rows.sort((a, b) => {
108
+ if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
109
+ return a.name.localeCompare(b.name, undefined, { numeric: true })
110
+ })
111
+ return { path: relativePath(root, absolute), entries: rows }
112
+ })
113
+
114
+ router.method('fs.read', async (body) => {
115
+ const root = await projectOf(env, body)
116
+ const absolute = await resolveProjectPath(root, body.path)
117
+ const info = await stat(absolute)
118
+ if (!info.isFile()) throw new ApiError('not-file', '不是文件', 400)
119
+ const tooLarge = info.size > TEXT_LIMIT
120
+ const handle = await open(absolute, 'r')
121
+ try {
122
+ const buffer = Buffer.alloc(Math.min(info.size, tooLarge ? 4096 : TEXT_LIMIT))
123
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
124
+ const bytes = buffer.subarray(0, bytesRead)
125
+ const binary = bytes.includes(0)
126
+ return {
127
+ path: relativePath(root, absolute),
128
+ content: binary || tooLarge ? '' : bytes.toString('utf8'),
129
+ revision: revisionOf(bytes, info.mtimeMs),
130
+ size: info.size,
131
+ mtimeMs: info.mtimeMs,
132
+ binary,
133
+ tooLarge,
134
+ language: languageOf(body.path),
135
+ }
136
+ } finally {
137
+ await handle.close()
138
+ }
139
+ })
140
+
141
+ router.method('fs.write', async (body) => {
142
+ const root = await projectOf(env, body)
143
+ if (typeof body.content !== 'string') throw badRequest('content must be a string')
144
+ const absolute = await resolveProjectPath(root, body.path)
145
+ const current = await stat(absolute).catch(() => undefined)
146
+ if (current === undefined) throw new ApiError('not-found', '文件不存在', 404)
147
+ const handle = await open(absolute, 'r')
148
+ let buffer
149
+ try {
150
+ buffer = Buffer.alloc(Math.min(current.size, TEXT_LIMIT))
151
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
152
+ buffer = buffer.subarray(0, bytesRead)
153
+ } finally {
154
+ await handle.close()
155
+ }
156
+ const revision = revisionOf(buffer, current.mtimeMs)
157
+ if (typeof body.expectedRevision === 'string' && body.expectedRevision !== revision) {
158
+ throw new ApiError('revision-conflict', '文件已在磁盘上被修改,请重新打开后再保存', 409)
159
+ }
160
+ const temp = join(dirname(absolute), `.${randomUUID()}.gufa.tmp`)
161
+ await writeFile(temp, body.content, { encoding: 'utf8', flag: 'wx' })
162
+ try {
163
+ await rename(temp, absolute)
164
+ } catch (error) {
165
+ await rm(temp, { force: true })
166
+ throw error
167
+ }
168
+ const saved = await stat(absolute)
169
+ return { revision: revisionOf(Buffer.from(body.content, 'utf8'), saved.mtimeMs), size: saved.size }
170
+ })
171
+
172
+ router.method('fs.create', async (body) => {
173
+ const root = await projectOf(env, body)
174
+ const absolute = await resolveProjectPath(root, body.path, { create: true })
175
+ if (body.kind === 'dir') {
176
+ await mkdir(absolute)
177
+ return { path: relativePath(root, absolute), kind: 'dir' }
178
+ }
179
+ const handle = await open(absolute, 'wx').catch((error) => {
180
+ if (error !== null && typeof error === 'object' && error.code === 'EEXIST') throw new ApiError('exists', '同名文件已存在', 409)
181
+ throw error
182
+ })
183
+ await handle.close()
184
+ return { path: relativePath(root, absolute), kind: 'file' }
185
+ })
186
+
187
+ router.method('fs.rename', async (body) => {
188
+ const root = await projectOf(env, body)
189
+ const source = await resolveProjectPath(root, body.path)
190
+ const target = await resolveProjectPath(root, body.nextPath, { create: true })
191
+ await access(target, constants.F_OK).then(
192
+ () => {
193
+ throw new ApiError('target-exists', '目标已存在', 409)
194
+ },
195
+ () => undefined,
196
+ )
197
+ await rename(source, target)
198
+ return { path: relativePath(root, target) }
199
+ })
200
+
201
+ router.method('fs.delete', async (body) => {
202
+ const root = await projectOf(env, body)
203
+ const absolute = await resolveProjectPath(root, body.path)
204
+ if (absolute === root) throw new ApiError('refuse', '不能删除项目根目录', 400)
205
+ await rm(absolute, { recursive: true, force: false })
206
+ return { ok: true }
207
+ })
208
+
209
+ /**
210
+ * 复制/粘贴:把 from(项目内相对路径数组)复制到目录 to 下(同名冲突自动加序号)。
211
+ * 用于资源管理器的多选复制、Cmd+C / Cmd+V。
212
+ */
213
+ router.method('fs.copy', async (body) => {
214
+ const root = await projectOf(env, body)
215
+ if (!Array.isArray(body.from) || body.from.length === 0) throw badRequest('from is required')
216
+ const targetDir = await resolveProjectPath(root, typeof body.to === 'string' && body.to !== '' ? body.to : '')
217
+ const info = await stat(targetDir)
218
+ if (!info.isDirectory()) throw new ApiError('not-dir', '目标不是目录', 400)
219
+ const copied = []
220
+ for (const raw of body.from) {
221
+ if (typeof raw !== 'string' || raw === '') continue
222
+ const source = await resolveProjectPath(root, raw)
223
+ if (source === root) throw new ApiError('refuse', '不能复制项目根目录', 400)
224
+ const base = raw.split('/').filter(Boolean).pop()
225
+ let name = base
226
+ let target = join(targetDir, name)
227
+ let suffix = 1
228
+ while (await stat(target).then(() => true, () => false)) {
229
+ const dot = base.lastIndexOf('.')
230
+ const stem = dot > 0 ? base.slice(0, dot) : base
231
+ const ext = dot > 0 ? base.slice(dot) : ''
232
+ name = `${stem} 副本${suffix > 1 ? ` ${suffix}` : ''}${ext}`
233
+ target = join(targetDir, name)
234
+ suffix += 1
235
+ if (suffix > 200) throw new ApiError('copy-name-failed', '无法生成目标文件名', 500)
236
+ }
237
+ await cp(source, target, { recursive: true, errorOnExist: true, force: false })
238
+ copied.push({ from: raw, to: relativePath(root, target) })
239
+ }
240
+ return { copied }
241
+ })
242
+
243
+ /** 软删除:移动到系统临时目录,返回可撤销的 id(资源管理器删除后用 ⌘Z 恢复)。 */
244
+ router.method('fs.trash', async (body) => {
245
+ const root = await projectOf(env, body)
246
+ const absolute = await resolveProjectPath(root, body.path)
247
+ if (absolute === root) throw new ApiError('refuse', '不能删除项目根目录', 400)
248
+ const info = await lstat(absolute)
249
+ const id = randomUUID()
250
+ const trashDir = join(tmpdir(), 'gufa-code-trash')
251
+ await mkdir(trashDir, { recursive: true })
252
+ await rename(absolute, join(trashDir, id))
253
+ return { id, path: relativePath(root, absolute), kind: info.isDirectory() ? 'dir' : 'file' }
254
+ })
255
+
256
+ /** 撤销软删除:把临时目录里的条目移回原路径。 */
257
+ router.method('fs.untrash', async (body) => {
258
+ const root = await projectOf(env, body)
259
+ const idRaw = typeof body.id === 'string' ? body.id : ''
260
+ if (!/^[0-9a-f-]{8,64}$/i.test(idRaw)) throw badRequest('id is invalid')
261
+ const source = join(tmpdir(), 'gufa-code-trash', idRaw)
262
+ const target = await resolveProjectPath(root, body.path, { create: true })
263
+ const exists = await access(target).then(() => true, () => false)
264
+ if (exists) throw new ApiError('target-exists', '原位置已存在同名文件,无法撤销', 409)
265
+ await rename(source, target)
266
+ return { ok: true, path: relativePath(root, target) }
267
+ })
268
+
269
+ /** 在系统文件管理器里显示(macOS Finder / Windows 资源管理器 / Linux 文件管理器)。 */
270
+ router.method('fs.reveal', async (body) => {
271
+ const root = await projectOf(env, body)
272
+ const absolute = await resolveProjectPath(root, typeof body.path === 'string' ? body.path : '')
273
+ const info = await stat(absolute)
274
+ const target = info.isDirectory() ? absolute : dirname(absolute)
275
+ try {
276
+ if (process.platform === 'darwin') {
277
+ await runCommand('open', [target], { timeout: 10_000 })
278
+ } else if (process.platform === 'win32') {
279
+ await runCommand('explorer.exe', [target], { timeout: 10_000 }).catch(() => undefined)
280
+ } else {
281
+ await runCommand('xdg-open', [target], { timeout: 10_000 })
282
+ }
283
+ } catch (error) {
284
+ throw new ApiError('reveal-failed', `无法打开文件管理器:${error instanceof Error ? error.message : String(error)}`, 500)
285
+ }
286
+ return { ok: true }
287
+ })
288
+
289
+ /** 以流的方式返回项目内文件内容(图片预览等)。 */
290
+ router.method('fs.rawUrl', async (body) => {
291
+ const root = await projectOf(env, body)
292
+ const absolute = await resolveProjectPath(root, body.path)
293
+ void absolute
294
+ return { path: body.path }
295
+ })
296
+
297
+ router.method('fs.stat', async (body) => {
298
+ const root = await projectOf(env, body)
299
+ const absolute = await resolveProjectPath(root, body.path)
300
+ const info = await stat(absolute)
301
+ return { kind: info.isDirectory() ? 'dir' : 'file', size: info.size, mtimeMs: info.mtimeMs }
302
+ })
303
+
304
+ /** 供 Host 内部使用(例如 git 忽略列表),导出以便其他模块复用。 */
305
+ router.fs = { resolveProjectPath, languageOf, relativePath }
306
+ }