dsh-gufa-code 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +96 -2
- package/client.js +11548 -0
- package/cordis.patch.yml +8 -0
- package/docs/icon-180.png +0 -0
- package/docs/icon.svg +18 -0
- package/docs/market-submission.md +32 -0
- package/docs/screenshot-editor.jpg +0 -0
- package/docs/screenshot-ide.jpg +0 -0
- package/host/assets.js +31 -0
- package/host/chat.js +523 -0
- package/host/context.js +438 -0
- package/host/dev.js +35 -0
- package/host/fs.js +306 -0
- package/host/git.js +532 -0
- package/host/project.js +305 -0
- package/host/ssh.js +54 -0
- package/host/terminal-pty.js +237 -0
- package/host/terminal.js +947 -0
- package/icon.svg +18 -0
- package/index.js +63 -0
- package/locale/en.json +121 -0
- package/locale/zh.json +121 -0
- package/package.json +66 -4
- package/scripts/build-client.mjs +38 -0
- package/scripts/selftest-client.mjs +457 -0
- package/scripts/selftest-host.mjs +197 -0
- package/scripts/selftest-http.mjs +284 -0
- package/scripts/selftest-router.mjs +101 -0
- package/scripts/selftest-terminal-client.mjs +314 -0
- package/scripts/selftest-terminal-pty.mjs +190 -0
- package/scripts/selftest-terminal.mjs +351 -0
- package/scripts/test-chat-render.mjs +702 -0
- package/scripts/test-chat.mjs +761 -0
- package/scripts/test-editor.mjs +516 -0
- package/scripts/verify-workspace-sessions.mjs +135 -0
- package/src/client/00-core.js +1686 -0
- package/src/client/10-icons.js +205 -0
- package/src/client/20-editor.js +1318 -0
- package/src/client/30-terminal.js +1510 -0
- package/src/client/40-chat.js +1184 -0
- package/src/client/46-gitgraph.js +278 -0
- package/src/client/50-panels.js +916 -0
- package/src/client/55-backdrop.js +508 -0
- package/src/client/60-dock.js +662 -0
- package/src/client/85-shortcuts.js +516 -0
- package/src/client/90-app.js +2272 -0
- package/src/client/99-main.js +449 -0
- package/vendor/monaco/LICENSE +21 -0
- package/vendor/monaco/ThirdPartyNotices.txt +448 -0
- package/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- package/vendor/monaco/vs/base/worker/workerMain.js +31 -0
- package/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- package/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- package/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- package/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- package/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- package/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- package/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- package/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- package/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- package/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- package/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- package/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- package/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- package/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- package/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- package/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- package/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- package/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- package/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- package/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- package/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- package/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- package/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- package/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- package/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- package/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- package/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- package/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- package/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- package/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- package/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- package/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- package/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- package/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- package/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- package/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- package/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- package/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- package/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- package/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- package/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- package/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- package/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- package/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- package/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- package/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- package/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- package/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- package/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- package/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- package/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- package/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- package/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- package/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- package/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- package/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- package/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- package/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- package/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- package/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- package/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- package/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- package/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- package/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- package/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- package/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- package/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- package/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- package/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- package/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- package/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- package/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- package/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- package/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
- package/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- package/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- package/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- package/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- package/vendor/monaco/vs/editor/editor.main.css +8 -0
- package/vendor/monaco/vs/editor/editor.main.js +798 -0
- package/vendor/monaco/vs/language/css/cssMode.js +13 -0
- package/vendor/monaco/vs/language/css/cssWorker.js +77 -0
- package/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- package/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
- package/vendor/monaco/vs/language/json/jsonMode.js +19 -0
- package/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
- package/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- package/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
- package/vendor/monaco/vs/loader.js +11 -0
- package/vendor/monaco/vs/nls.messages.de.js +21 -0
- package/vendor/monaco/vs/nls.messages.es.js +21 -0
- package/vendor/monaco/vs/nls.messages.fr.js +19 -0
- package/vendor/monaco/vs/nls.messages.it.js +19 -0
- package/vendor/monaco/vs/nls.messages.ja.js +21 -0
- package/vendor/monaco/vs/nls.messages.ko.js +19 -0
- package/vendor/monaco/vs/nls.messages.ru.js +21 -0
- package/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
- package/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
- package/vendor/xterm/LICENSE +21 -0
- package/vendor/xterm/LICENSE.addon-fit +19 -0
- package/vendor/xterm/addon-fit.js +2 -0
- package/vendor/xterm/xterm.css +285 -0
- 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
|
+
}
|