pi-web-ui 0.62.0 → 0.63.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 -21
- package/README.md +504 -497
- package/README.zh-CN.md +427 -422
- package/bin/pi-web-ui.mjs +1809 -1881
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
- package/deploy/nginx-subpath.conf +88 -88
- package/deploy/pi-web-ui-task.xml +54 -54
- package/deploy/pi-web-ui.service +31 -31
- package/dist/server/agent-service.js +318 -88
- package/dist/server/attachments.js +37 -28
- package/dist/server/bg-servers.js +6 -1
- package/dist/server/client-state.js +15 -7
- package/dist/server/control-socket.js +1 -5
- package/dist/server/dsh/dsh-agent-service.js +454 -118
- package/dist/server/dsh/dsh-client.js +1 -3
- package/dist/server/dsh/dsh-sessions.js +4 -14
- package/dist/server/dsh/runtime/cordis.yml +1 -1
- package/dist/server/dsh/runtime/goal-rpc.mjs +645 -662
- package/dist/server/dsh/runtime/launcher.mjs +8 -18
- package/dist/server/dsh/runtime/override.patch.yml +9 -9
- package/dist/server/dsh/runtime/runtime-root.mjs +7 -11
- package/dist/server/ensure-bash.js +2 -5
- package/dist/server/files-service.js +42 -36
- package/dist/server/goal-service.js +59 -30
- package/dist/server/index.js +17 -10
- package/dist/server/mcp-bridge.js +11 -5
- package/dist/server/model-admin.js +78 -72
- package/dist/server/plugin-facilities.js +2 -4
- package/dist/server/plugin-updater.js +1 -1
- package/dist/server/plugins.js +26 -8
- package/dist/server/process-utils.js +4 -4
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/scm.js +27 -21
- package/dist/server/serialize.js +1 -3
- package/dist/server/settings-service.js +63 -21
- package/dist/server/slash-commands.js +29 -3
- package/dist/server/subagent-templates.js +249 -0
- package/dist/server/subagents.js +150 -0
- package/dist/server/terminals.js +101 -40
- package/dist/server/text-sniff.js +4 -24
- package/dist/server/update-check.js +2 -4
- package/dist/server/uploads.js +1 -3
- package/dist/server/vision-bridge.js +2 -7
- package/dist/server/wait-subscription-scan.js +102 -21
- package/dist/server/webui-context.js +1 -1
- package/extensions/webui.ts +190 -192
- package/package.json +109 -103
- package/web/dist/assets/TerminalPanel-B9vcI7-_.js +2 -0
- package/web/dist/assets/index-B8Wv29p5.css +10 -0
- package/web/dist/assets/index-CWUWD0rq.js +323 -0
- package/web/dist/favicon.svg +8 -8
- package/web/dist/index.html +15 -15
- package/web/public/favicon.svg +8 -8
- package/web/dist/assets/TerminalPanel-BkCWDsIG.js +0 -2
- package/web/dist/assets/index-BRVKed7W.js +0 -321
- package/web/dist/assets/index-ORTpR6ZQ.css +0 -10
|
@@ -38,20 +38,14 @@ if (!runtimeBase) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
const runConfig = process.env.DSH_CORDIS_CONFIG ?? join(HERE, "cordis.yml");
|
|
41
|
-
const baseBundlePatch = join(
|
|
42
|
-
runtimeBase,
|
|
43
|
-
"@deepseek-ai",
|
|
44
|
-
"dsh-base",
|
|
45
|
-
"cordis.patch.yml",
|
|
46
|
-
);
|
|
41
|
+
const baseBundlePatch = join(runtimeBase, "@deepseek-ai", "dsh-base", "cordis.patch.yml");
|
|
47
42
|
const overridePatch = join(HERE, "override.patch.yml");
|
|
48
43
|
|
|
49
44
|
// 用户 patch 层:<dataDir>/dsh-patches/*.yml(按文件名序,在 override 之后)。
|
|
50
45
|
// 引擎(DshClientSession)负责在重启运行时前创建目录;launcher 只负责加载。
|
|
51
|
-
const userPatchDir =
|
|
52
|
-
process.env.PI_WEB_DSH_DATA_DIR
|
|
53
|
-
|
|
54
|
-
: (process.env.PI_WEB_DSH_PATCH_DIR ?? null);
|
|
46
|
+
const userPatchDir = process.env.PI_WEB_DSH_DATA_DIR
|
|
47
|
+
? join(process.env.PI_WEB_DSH_DATA_DIR, "dsh-patches")
|
|
48
|
+
: (process.env.PI_WEB_DSH_PATCH_DIR ?? null);
|
|
55
49
|
const userPatchFiles = [];
|
|
56
50
|
if (userPatchDir) {
|
|
57
51
|
try {
|
|
@@ -78,7 +72,7 @@ const { boot, installFailLoud, loadOverlayPatches } = await import(
|
|
|
78
72
|
// sdk-jsonrpc 走本地扩展插件 goal-rpc.mjs(官方 server 类 + goal RPC 方法);
|
|
79
73
|
// 官方入口(base 类)通过 env PI_WEB_DSH_JSONRPC_ENTRY 传给 wrapper。
|
|
80
74
|
const jsonrpcWrapper = join(HERE, "goal-rpc.mjs");
|
|
81
|
-
const
|
|
75
|
+
const _jsonrpcEntry = process.env.PI_WEB_DSH_JSONRPC_ENTRY
|
|
82
76
|
? resolve(process.env.PI_WEB_DSH_JSONRPC_ENTRY)
|
|
83
77
|
: join(
|
|
84
78
|
resolve(HERE, "..", "..", "..", ".."),
|
|
@@ -106,9 +100,7 @@ const userPatchLists = userPatchFiles.map((file) => {
|
|
|
106
100
|
try {
|
|
107
101
|
return loadOverlayPatches(BIN_NAME, file);
|
|
108
102
|
} catch (err) {
|
|
109
|
-
process.stderr.write(
|
|
110
|
-
`[${BIN_NAME}] 跳过用户 patch ${file}: ${err?.message ?? String(err)}\n`,
|
|
111
|
-
);
|
|
103
|
+
process.stderr.write(`[${BIN_NAME}] 跳过用户 patch ${file}: ${err?.message ?? String(err)}\n`);
|
|
112
104
|
return [];
|
|
113
105
|
}
|
|
114
106
|
});
|
|
@@ -156,9 +148,7 @@ const release = () => {
|
|
|
156
148
|
try {
|
|
157
149
|
await ctx.fiber.dispose();
|
|
158
150
|
} catch (err) {
|
|
159
|
-
console.error(
|
|
160
|
-
`[${BIN_NAME}] teardown 错误: ${err?.message ?? String(err)}`,
|
|
161
|
-
);
|
|
151
|
+
console.error(`[${BIN_NAME}] teardown 错误: ${err?.message ?? String(err)}`);
|
|
162
152
|
}
|
|
163
153
|
process.exit(0);
|
|
164
154
|
})();
|
|
@@ -171,4 +161,4 @@ process.stdin.resume();
|
|
|
171
161
|
process.stdin.on("end", release);
|
|
172
162
|
process.stdin.on("close", release);
|
|
173
163
|
process.on("SIGTERM", release);
|
|
174
|
-
process.on("SIGINT", () => process.exit(130));
|
|
164
|
+
process.on("SIGINT", () => process.exit(130));
|
|
@@ -6,26 +6,26 @@
|
|
|
6
6
|
|
|
7
7
|
# 会话持久化根 —— 覆盖 base 的 dshHomePath('sessions'),让 pi-web-ui 指向自己的数据目录。
|
|
8
8
|
- id: session-persistence-jsonl
|
|
9
|
-
name:
|
|
9
|
+
name: "@deepseek-ai/dsh-session-persistence-jsonl"
|
|
10
10
|
config:
|
|
11
11
|
root: !!js process.env.DSH_SESSION_ROOT ?? dshHomePath('sessions')
|
|
12
12
|
|
|
13
13
|
# Agent 默认模型 —— initialize() 每次都带 provider/model,这里只兜底。
|
|
14
14
|
- id: agent-default-model
|
|
15
|
-
name:
|
|
15
|
+
name: "@deepseek-ai/dsh-agent-default-model"
|
|
16
16
|
config:
|
|
17
17
|
provider: !!js process.env.DSH_DEFAULT_PROVIDER ?? 'deepseek-official'
|
|
18
18
|
model: !!js process.env.DSH_DEFAULT_MODEL ?? 'deepseek-v4-flash'
|
|
19
19
|
|
|
20
20
|
# 部署人设(默认空;pi-web-ui 设置面板可注入)。
|
|
21
21
|
- id: system-prompt
|
|
22
|
-
name:
|
|
22
|
+
name: "@deepseek-ai/dsh-system-prompt"
|
|
23
23
|
config:
|
|
24
24
|
persona: !!js process.env.DSH_PERSONA ?? ''
|
|
25
25
|
|
|
26
26
|
# 沙箱工作区根(workspace-write 权限模式的护栏边界)。
|
|
27
27
|
- id: sandbox-policy
|
|
28
|
-
name:
|
|
28
|
+
name: "@deepseek-ai/dsh-sandbox-policy"
|
|
29
29
|
config:
|
|
30
30
|
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
|
|
31
31
|
workspaceRoot: !!js process.env.DSH_CWD ?? process.cwd()
|
|
@@ -33,14 +33,14 @@
|
|
|
33
33
|
# 无头 JSON-RPC 服务:没有交互用户来应答审批弹窗;工具在 workspace-write
|
|
34
34
|
# 沙箱内直接执行,用户的 Stop 按钮就是控制手段。
|
|
35
35
|
- id: approval
|
|
36
|
-
name:
|
|
36
|
+
name: "@deepseek-ai/dsh-user-approval"
|
|
37
37
|
config:
|
|
38
|
-
policy:
|
|
38
|
+
policy: "never"
|
|
39
39
|
|
|
40
40
|
# 权限预设表 —— 与 approval=never 组合匹配。patch 语义:整行 config 全量替换,
|
|
41
41
|
# 必须重述 base 的全部预设并补上 (workspace-write, never) 组合,显式 defaultPreset。
|
|
42
42
|
- id: permission
|
|
43
|
-
name:
|
|
43
|
+
name: "@deepseek-ai/dsh-permission-presets"
|
|
44
44
|
config:
|
|
45
45
|
presets:
|
|
46
46
|
read-only:
|
|
@@ -62,10 +62,10 @@
|
|
|
62
62
|
# 模型提问工具:ask_user_question → ctx.userQuestions(base 已挂 user-questions
|
|
63
63
|
# 服务)→ goal-rpc wrapper 注册的 provider 桥到浏览器对话框。
|
|
64
64
|
- id: tool-ask-user
|
|
65
|
-
name:
|
|
65
|
+
name: "@deepseek-ai/dsh-tool-ask-user"
|
|
66
66
|
# Stdio JSON-RPC 服务插件(inject: agents)。name 用 !!js 求值为绝对路径
|
|
67
67
|
# (指向项目 node_modules 里的 dsh-sdk-jsonrpc-server),因为 cordis loader
|
|
68
68
|
# 的裸包解析只认 bareModuleBaseUrl(运行时树),混树挂载必须走绝对路径;
|
|
69
69
|
# 该插件的 peer 依赖从项目树自洽解析。
|
|
70
70
|
- id: sdk-jsonrpc
|
|
71
|
-
name: !!js process.env.PI_WEB_DSH_JSONRPC_ENTRY
|
|
71
|
+
name: !!js process.env.PI_WEB_DSH_JSONRPC_ENTRY
|
|
@@ -68,16 +68,12 @@ export async function resolveRuntimeBase() {
|
|
|
68
68
|
}
|
|
69
69
|
try {
|
|
70
70
|
const { spawnSync } = await import("node:child_process");
|
|
71
|
-
const res = spawnSync(
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
windowsHide: true,
|
|
78
|
-
...(process.platform === "win32" ? { shell: true } : {}),
|
|
79
|
-
},
|
|
80
|
-
);
|
|
71
|
+
const res = spawnSync(process.platform === "win32" ? "npm" : "npm", ["root", "-g"], {
|
|
72
|
+
encoding: "utf8",
|
|
73
|
+
timeout: 15_000,
|
|
74
|
+
windowsHide: true,
|
|
75
|
+
...(process.platform === "win32" ? { shell: true } : {}),
|
|
76
|
+
});
|
|
81
77
|
const root = String(res.stdout ?? "").trim();
|
|
82
78
|
if (root) {
|
|
83
79
|
const base = runtimeBaseFor(root);
|
|
@@ -87,4 +83,4 @@ export async function resolveRuntimeBase() {
|
|
|
87
83
|
/* fall through */
|
|
88
84
|
}
|
|
89
85
|
return null;
|
|
90
|
-
}
|
|
86
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Download is fire-and-forget at server start and never throws: on failure
|
|
12
12
|
* the terminal simply falls back to $COMSPEC (cmd.exe) as before.
|
|
13
13
|
*/
|
|
14
|
-
import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync
|
|
14
|
+
import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
/** Official busybox-w32 64-bit Unicode build (Win10 1903+ / Win11). */
|
|
@@ -32,10 +32,7 @@ export function windowsBashPath() {
|
|
|
32
32
|
export function hasGitBash() {
|
|
33
33
|
const pf = process.env.ProgramFiles;
|
|
34
34
|
const pf86 = process.env["ProgramFiles(x86)"];
|
|
35
|
-
for (const cand of [
|
|
36
|
-
pf ? join(pf, "Git", "bin", "bash.exe") : "",
|
|
37
|
-
pf86 ? join(pf86, "Git", "bin", "bash.exe") : "",
|
|
38
|
-
]) {
|
|
35
|
+
for (const cand of [pf ? join(pf, "Git", "bin", "bash.exe") : "", pf86 ? join(pf86, "Git", "bin", "bash.exe") : ""]) {
|
|
39
36
|
if (cand && existsSync(cand))
|
|
40
37
|
return true;
|
|
41
38
|
}
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { mkdirSync, statSync, writeFileSync, watch } from "node:fs";
|
|
9
9
|
import { resolve, relative, sep } from "node:path";
|
|
10
|
-
import { previewKind, looksLikeText, decodeText, hexDump, countLines
|
|
11
|
-
import { gitDirOf, isNotRepoError, scmStatus, scmHistory, scmFileDiff, scmCommitDetail
|
|
10
|
+
import { previewKind, looksLikeText, decodeText, hexDump, countLines } from "./text-sniff.js";
|
|
11
|
+
import { gitDirOf, isNotRepoError, scmStatus, scmHistory, scmFileDiff, scmCommitDetail } from "./scm.js";
|
|
12
12
|
export const IS_WIN32 = process.platform === "win32";
|
|
13
13
|
/** 预览只读文件前 512KB。 */
|
|
14
14
|
export const MAX_PREVIEW_BYTES = 512 * 1024;
|
|
@@ -36,14 +36,7 @@ const IGNORED_ENTRIES = new Set([
|
|
|
36
36
|
// hide what would flood or destabilize the panel (dependency trees, VCS
|
|
37
37
|
// internals, session data) plus pure junk. Build output (dist/.next/…) and
|
|
38
38
|
// local env dirs (venv/__pycache__/…) stay visible — "所有文件可查看".
|
|
39
|
-
const IGNORED_ENTRIES_WIN = new Set([
|
|
40
|
-
"node_modules",
|
|
41
|
-
".git",
|
|
42
|
-
".pi-web",
|
|
43
|
-
".DS_Store",
|
|
44
|
-
"Thumbs.db",
|
|
45
|
-
"desktop.ini",
|
|
46
|
-
]);
|
|
39
|
+
const IGNORED_ENTRIES_WIN = new Set(["node_modules", ".git", ".pi-web", ".DS_Store", "Thumbs.db", "desktop.ini"]);
|
|
47
40
|
/** The ignore set for the current platform — keeps win/posix lists separate. */
|
|
48
41
|
function ignoredEntries() {
|
|
49
42
|
return IS_WIN32 ? IGNORED_ENTRIES_WIN : IGNORED_ENTRIES;
|
|
@@ -122,11 +115,7 @@ async function readDirForUI(abs, rel) {
|
|
|
122
115
|
entry.kind = previewKind(d.name);
|
|
123
116
|
out.push(entry);
|
|
124
117
|
}
|
|
125
|
-
out.sort((a, b) => a.type === b.type
|
|
126
|
-
? a.name.localeCompare(b.name)
|
|
127
|
-
: a.type === "dir"
|
|
128
|
-
? -1
|
|
129
|
-
: 1);
|
|
118
|
+
out.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1));
|
|
130
119
|
const truncated = out.length > MAX;
|
|
131
120
|
if (truncated)
|
|
132
121
|
out.length = MAX;
|
|
@@ -162,6 +151,7 @@ export class FilesService {
|
|
|
162
151
|
type: "notice",
|
|
163
152
|
level: "warning",
|
|
164
153
|
text: `路径超出工作区:${relPath ?? ""}`,
|
|
154
|
+
textEn: `Path is outside the workspace: ${relPath ?? ""}`,
|
|
165
155
|
});
|
|
166
156
|
return;
|
|
167
157
|
}
|
|
@@ -179,16 +169,13 @@ export class FilesService {
|
|
|
179
169
|
type: "notice",
|
|
180
170
|
level: "warning",
|
|
181
171
|
text: `目录不可读:${error}`,
|
|
172
|
+
textEn: `Directory is not readable: ${error}`,
|
|
182
173
|
});
|
|
183
174
|
}
|
|
184
175
|
this.host.emit({
|
|
185
176
|
type: "files",
|
|
186
177
|
path: rel === "" ? "" : rel,
|
|
187
|
-
parent: rel === ""
|
|
188
|
-
? null
|
|
189
|
-
: rel.includes("/")
|
|
190
|
-
? rel.slice(0, rel.lastIndexOf("/"))
|
|
191
|
-
: "",
|
|
178
|
+
parent: rel === "" ? null : rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "",
|
|
192
179
|
entries,
|
|
193
180
|
truncated,
|
|
194
181
|
});
|
|
@@ -218,9 +205,7 @@ export class FilesService {
|
|
|
218
205
|
const results = [];
|
|
219
206
|
let visited = 0;
|
|
220
207
|
let truncated = false;
|
|
221
|
-
const budgetLeft = () => results.length < MAX_RESULTS &&
|
|
222
|
-
visited < MAX_VISITED &&
|
|
223
|
-
Date.now() - start < MAX_MS;
|
|
208
|
+
const budgetLeft = () => results.length < MAX_RESULTS && visited < MAX_VISITED && Date.now() - start < MAX_MS;
|
|
224
209
|
// Breadth-first-ish iterative stack; depth cap is a symlink-cycle guard.
|
|
225
210
|
const walk = async (abs, rel, depth) => {
|
|
226
211
|
if (!budgetLeft() || depth > 24) {
|
|
@@ -299,8 +284,12 @@ export class FilesService {
|
|
|
299
284
|
throw new Error("路径超出工作区");
|
|
300
285
|
const { staged, worktree } = await scmFileDiff(cwd, arg.path);
|
|
301
286
|
this.host.emit({
|
|
302
|
-
type: "scm_data",
|
|
303
|
-
|
|
287
|
+
type: "scm_data",
|
|
288
|
+
reqId,
|
|
289
|
+
kind,
|
|
290
|
+
ok: true,
|
|
291
|
+
stagedText: staged,
|
|
292
|
+
worktreeText: worktree,
|
|
304
293
|
});
|
|
305
294
|
return;
|
|
306
295
|
}
|
|
@@ -315,10 +304,21 @@ export class FilesService {
|
|
|
315
304
|
if (isNotRepoError(err)) {
|
|
316
305
|
// Not a repo — a valid empty answer so the panel shows its hint.
|
|
317
306
|
this.host.emit({
|
|
318
|
-
type: "scm_data",
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
307
|
+
type: "scm_data",
|
|
308
|
+
reqId,
|
|
309
|
+
kind,
|
|
310
|
+
ok: true,
|
|
311
|
+
notRepo: true,
|
|
312
|
+
branch: "",
|
|
313
|
+
detached: false,
|
|
314
|
+
upstream: null,
|
|
315
|
+
ahead: 0,
|
|
316
|
+
behind: 0,
|
|
317
|
+
upstreamGone: false,
|
|
318
|
+
files: [],
|
|
319
|
+
branches: [],
|
|
320
|
+
stats: {},
|
|
321
|
+
history: [],
|
|
322
322
|
});
|
|
323
323
|
this.unwatchGit();
|
|
324
324
|
return;
|
|
@@ -482,6 +482,7 @@ export class FilesService {
|
|
|
482
482
|
type: "notice",
|
|
483
483
|
level: "info",
|
|
484
484
|
text: "此目录不支持实时文件监听(网络盘/受限目录),文件面板已改为每 10 秒自动刷新。",
|
|
485
|
+
textEn: "Live file watching is not supported for this directory (network/restricted); the file panel now refreshes every 10s",
|
|
485
486
|
});
|
|
486
487
|
}
|
|
487
488
|
unwatchDir() {
|
|
@@ -513,6 +514,7 @@ export class FilesService {
|
|
|
513
514
|
type: "notice",
|
|
514
515
|
level: "warning",
|
|
515
516
|
text: `路径超出工作区:${relPath}`,
|
|
517
|
+
textEn: `Path is outside the workspace: ${relPath}`,
|
|
516
518
|
});
|
|
517
519
|
return;
|
|
518
520
|
}
|
|
@@ -523,6 +525,7 @@ export class FilesService {
|
|
|
523
525
|
type: "notice",
|
|
524
526
|
level: "warning",
|
|
525
527
|
text: `不是文件:${relPath}`,
|
|
528
|
+
textEn: `Not a file: ${relPath}`,
|
|
526
529
|
});
|
|
527
530
|
return;
|
|
528
531
|
}
|
|
@@ -589,6 +592,7 @@ export class FilesService {
|
|
|
589
592
|
type: "notice",
|
|
590
593
|
level: "error",
|
|
591
594
|
text: `读取文件失败:${err.message}`,
|
|
595
|
+
textEn: `Failed to read file: ${err.message}`,
|
|
592
596
|
});
|
|
593
597
|
}
|
|
594
598
|
}
|
|
@@ -602,6 +606,7 @@ export class FilesService {
|
|
|
602
606
|
type: "notice",
|
|
603
607
|
level: "warning",
|
|
604
608
|
text: `路径超出工作区:${relPath}`,
|
|
609
|
+
textEn: `Path is outside the workspace: ${relPath}`,
|
|
605
610
|
});
|
|
606
611
|
return;
|
|
607
612
|
}
|
|
@@ -610,6 +615,7 @@ export class FilesService {
|
|
|
610
615
|
type: "notice",
|
|
611
616
|
level: "warning",
|
|
612
617
|
text: "文件内容过大,无法保存(上限 2MB)",
|
|
618
|
+
textEn: "File too large to save (2MB max)",
|
|
613
619
|
});
|
|
614
620
|
return;
|
|
615
621
|
}
|
|
@@ -619,6 +625,7 @@ export class FilesService {
|
|
|
619
625
|
type: "notice",
|
|
620
626
|
level: "warning",
|
|
621
627
|
text: `不是文件:${relPath}`,
|
|
628
|
+
textEn: `Not a file: ${relPath}`,
|
|
622
629
|
});
|
|
623
630
|
return;
|
|
624
631
|
}
|
|
@@ -627,6 +634,7 @@ export class FilesService {
|
|
|
627
634
|
type: "notice",
|
|
628
635
|
level: "info",
|
|
629
636
|
text: `已保存:${wp.rel}`,
|
|
637
|
+
textEn: `Saved: ${wp.rel}`,
|
|
630
638
|
});
|
|
631
639
|
// Re-read through the same path as the preview request so the client
|
|
632
640
|
// gets the canonical content, line count and file size after saving.
|
|
@@ -637,6 +645,7 @@ export class FilesService {
|
|
|
637
645
|
type: "notice",
|
|
638
646
|
level: "error",
|
|
639
647
|
text: `保存文件失败:${err.message}`,
|
|
648
|
+
textEn: `Failed to save file: ${err.message}`,
|
|
640
649
|
});
|
|
641
650
|
}
|
|
642
651
|
}
|
|
@@ -665,7 +674,7 @@ export class FilesService {
|
|
|
665
674
|
// Basename only — strips any path separators / ".." the name carries;
|
|
666
675
|
// reject empty results and Windows-illegal characters outright.
|
|
667
676
|
const base = name.split(/[\\/]/).pop() ?? "";
|
|
668
|
-
const safe = (base.replace(/[
|
|
677
|
+
const safe = (base.replace(/[/:*?"<>|\x00-\x1f]/g, "_").trim() || "file").slice(0, 200);
|
|
669
678
|
const abs = resolve(wp.abs, safe);
|
|
670
679
|
const rawRel = relative(root, abs);
|
|
671
680
|
if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`)) {
|
|
@@ -687,6 +696,7 @@ export class FilesService {
|
|
|
687
696
|
type: "notice",
|
|
688
697
|
level: "info",
|
|
689
698
|
text: `已上传:${rawRel.split(sep).join("/")}`,
|
|
699
|
+
textEn: `Uploaded: ${rawRel.split(sep).join("/")}`,
|
|
690
700
|
});
|
|
691
701
|
// Emit for the target directory itself so the panel refreshes even
|
|
692
702
|
// when the active listing/preview isn't that dir (posix watcher only
|
|
@@ -733,9 +743,7 @@ export class FilesService {
|
|
|
733
743
|
const lastSlash = Math.max(expanded.lastIndexOf("/"), expanded.lastIndexOf("\\"));
|
|
734
744
|
const dirPart = lastSlash >= 0 ? expanded.slice(0, lastSlash + 1) : "";
|
|
735
745
|
const prefix = lastSlash >= 0 ? expanded.slice(lastSlash + 1) : expanded;
|
|
736
|
-
const dirents = await fs
|
|
737
|
-
.readdir(dirPart, { withFileTypes: true })
|
|
738
|
-
.catch(() => null);
|
|
746
|
+
const dirents = await fs.readdir(dirPart, { withFileTypes: true }).catch(() => null);
|
|
739
747
|
if (!dirents) {
|
|
740
748
|
empty();
|
|
741
749
|
return;
|
|
@@ -747,9 +755,7 @@ export class FilesService {
|
|
|
747
755
|
name: d.name,
|
|
748
756
|
// Windows users type backslashes — normalize the completion to the
|
|
749
757
|
// wire format ("/") so the picked path round-trips cleanly.
|
|
750
|
-
path: IS_WIN32
|
|
751
|
-
? join(dirPart, d.name).split(sep).join("/")
|
|
752
|
-
: dirPart + d.name,
|
|
758
|
+
path: IS_WIN32 ? join(dirPart, d.name).split(sep).join("/") : dirPart + d.name,
|
|
753
759
|
type: (d.isDirectory() ? "dir" : "file"),
|
|
754
760
|
}))
|
|
755
761
|
.sort((a, b) => {
|