@dsh-xhl/dsh-file-explorer 0.1.5 → 0.1.6
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/README.md +10 -6
- package/lib/client.js +237 -25
- package/lib/index.js +138 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
## 功能
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
7
|
+
- **聊天页标题栏图标**(`conversation.session.header.utilities`):一个内置编辑器图标,点击打开文件预览 / 编辑弹窗(聊天页保持与现状一致,无下拉)
|
|
8
|
+
- **Hero / 新建会话页 ButtonGroup**:同一浮层位置由插件定位到会话列右上角(hero 阶段显示,有记录后由标题栏图标接管,无重复);hero 上为**左主按钮 + 右下拉**的组合:
|
|
9
|
+
- **编辑器**(默认):点击打开文件预览 / 编辑弹窗(Ctrl+P 亦可)
|
|
10
|
+
- **文件夹**:点击直接打开会话工作区所在的**系统文件夹**(Windows `explorer` / macOS `open` / Linux `xdg-open`)
|
|
11
|
+
- **VSCode**:点击用 `code` 命令行在 **VS Code** 中打开会话工作区(自动探测安装位置 / PATH;未安装则该项自动隐藏)
|
|
12
|
+
- 选择记忆在 localStorage,点击后底部提示确认打开的路径;下拉选中某项会先切换默认打开方式,随后立即执行对应的打开动作
|
|
13
|
+
- 纯插件实现,不改动框架代码(详见 `docs/hero-filex-button.md`)
|
|
14
|
+
- **对话内点击文件 → 编辑器预览**:聊天里的文件路径链接(工具行、产物行、正文提及)默认用系统程序打开;插件拦截 `remote.session.openWorkspacePath` 与 `workspaces.openPath` 两条通道,改为在编辑器弹窗中预览(目录或 cwd 外的路径仍落回系统打开)
|
|
11
15
|
- **文件列表**:左侧目录树(跳过 `node_modules`/`.git`/`dist` 等)
|
|
12
16
|
- **按文件名过滤**:输入即过滤
|
|
13
17
|
- **全文搜索**:正则 / 大小写 / 全词匹配、包含/排除过滤,结果按文件分组、命中高亮,点击跳转并高亮匹配
|
|
@@ -20,11 +24,11 @@
|
|
|
20
24
|
|
|
21
25
|
```
|
|
22
26
|
src/
|
|
23
|
-
index.ts Host:/filex/api JSON 路由(session.cwd / fs.list / fs.read / fs.write / fs.search)
|
|
27
|
+
index.ts Host:/filex/api JSON 路由(session.cwd / fs.list / fs.read / fs.write / fs.search / fs.reveal / fs.vscode / fs.capabilities)
|
|
24
28
|
+ /filex/file 媒体路由(图片/PDF 字节),loopback 信任围栏
|
|
25
29
|
trust-fence.ts Host-header loopback 校验(DNS-rebinding 防护)
|
|
26
30
|
client/
|
|
27
|
-
index.tsx
|
|
31
|
+
index.tsx 入口:聊天页 header 图标(内置编辑器)+ hero 模式 ButtonGroup + overlay 弹窗 + Ctrl+P 快捷键
|
|
28
32
|
Explorer.tsx 弹窗:文件树 / 文件名过滤 / 全文搜索 / 查看器分派(primitives 重排)
|
|
29
33
|
TextEditor.tsx CodeMirror 6 编辑器 + 搜索跳转高亮
|
|
30
34
|
lang.ts 扩展名 → CodeMirror 语言映射
|
package/lib/client.js
CHANGED
|
@@ -49,6 +49,15 @@ window.__ModuleLoader__.load({
|
|
|
49
49
|
path,
|
|
50
50
|
content
|
|
51
51
|
}),
|
|
52
|
+
fsReveal: (scope, cwd, signal) => call("fs.reveal", {
|
|
53
|
+
sessionId: scope.sessionId,
|
|
54
|
+
...cwd !== void 0 && cwd !== "" ? { cwd } : {}
|
|
55
|
+
}, signal),
|
|
56
|
+
fsVscode: (scope, cwd, signal) => call("fs.vscode", {
|
|
57
|
+
sessionId: scope.sessionId,
|
|
58
|
+
...cwd !== void 0 && cwd !== "" ? { cwd } : {}
|
|
59
|
+
}, signal),
|
|
60
|
+
fsCapabilities: (signal) => call("fs.capabilities", {}, signal),
|
|
52
61
|
fsSearch: (scope, pattern, options, include, exclude, signal) => call("fs.search", {
|
|
53
62
|
sessionId: scope.sessionId,
|
|
54
63
|
pattern,
|
|
@@ -37010,8 +37019,19 @@ window.__ModuleLoader__.load({
|
|
|
37010
37019
|
the width / padding / radius need pinning down. */
|
|
37011
37020
|
.filex-header-btn{width:28px;height:28px;padding:0;border-radius:14px}
|
|
37012
37021
|
|
|
37022
|
+
/* ── hero ButtonGroup (shell.overlay entry) ──────────────────────────── */
|
|
37023
|
+
/* The mode group shown only on the hero / new-session screen: the left
|
|
37024
|
+
button carries the remembered mode's icon, the right chevron the mode
|
|
37025
|
+
dropdown. Outer corners reuse the in-chat header-button radius (14px on
|
|
37026
|
+
a 28px-tall button = a fully round cap), so the group reads as one pill
|
|
37027
|
+
with the same curve as the session header icon. */
|
|
37028
|
+
.filex-group{display:inline-flex;align-items:center;position:relative;height:28px}
|
|
37029
|
+
.filex-group .filex-group-main,.filex-group .filex-group-trigger{height:28px}
|
|
37030
|
+
.filex-group .filex-group-main{border-radius:14px 0 0 14px;padding:0 9px}
|
|
37031
|
+
.filex-group .filex-group-trigger{margin-left:-1px;border-radius:0 14px 14px 0;border-left:0;padding:0 7px}
|
|
37032
|
+
|
|
37013
37033
|
/* ── hero floating utility (shell.overlay entry) ─────────────────────── */
|
|
37014
|
-
/* The
|
|
37034
|
+
/* The ButtonGroup, pinned by the plugin to the conversation column's
|
|
37015
37035
|
top-right while the column is in its hero phase. The shell.overlay layer
|
|
37016
37036
|
is click-through by design, so the entry opts back into pointer events;
|
|
37017
37037
|
no explicit z-index keeps it below sibling overlay entries (modal,
|
|
@@ -38035,24 +38055,23 @@ window.__ModuleLoader__.load({
|
|
|
38035
38055
|
//#endregion
|
|
38036
38056
|
//#region src/client/index.tsx
|
|
38037
38057
|
/**
|
|
38038
|
-
* Client half of dsh-file-explorer
|
|
38039
|
-
* built-in file-explorer modal (preview / edit), plus the overlay modal, the
|
|
38040
|
-
* Ctrl+P shortcut, and transient notices.
|
|
38058
|
+
* Client half of dsh-file-explorer.
|
|
38041
38059
|
*
|
|
38042
|
-
* The
|
|
38043
|
-
*
|
|
38044
|
-
*
|
|
38045
|
-
*
|
|
38046
|
-
*
|
|
38060
|
+
* The in-chat session header (`conversation.session.header.utilities`) keeps
|
|
38061
|
+
* a single built-in-editor icon: clicking it opens the file-explorer modal
|
|
38062
|
+
* (preview / edit). The hero / new-session floating utility rendered by the
|
|
38063
|
+
* plugin itself through the generic `shell.overlay` layer carries the full
|
|
38064
|
+
* ButtonGroup — the left button performs the remembered open action
|
|
38065
|
+
* (内置编辑器 / 系统文件夹 / VS Code) and the right chevron opens a dropdown
|
|
38066
|
+
* to switch between them. Selecting an entry both persists it as the new
|
|
38067
|
+
* default and immediately runs that action.
|
|
38047
38068
|
*
|
|
38048
|
-
* The
|
|
38049
|
-
*
|
|
38050
|
-
* plugin-owned entry pins it to the conversation column's top-right while
|
|
38051
|
-
* the column is in its hero phase — the no-session hero and the
|
|
38069
|
+
* The floating entry pins itself to the conversation column's top-right
|
|
38070
|
+
* while the column is in its hero phase — the no-session hero and the
|
|
38052
38071
|
* blank-session (new chat) hero, where the session header and its utilities
|
|
38053
|
-
* seat are absent or deliberately hidden.
|
|
38054
|
-
*
|
|
38055
|
-
*
|
|
38072
|
+
* seat are absent or deliberately hidden. It resolves its session itself:
|
|
38073
|
+
* the actions fall back to the live selection (`useSessions` →
|
|
38074
|
+
* `state.current`) and show a notice when none exists.
|
|
38056
38075
|
*
|
|
38057
38076
|
* Session binding: every /filex request is conversation-scoped, so the modal
|
|
38058
38077
|
* must know WHICH session it belongs to — the host resolves the workspace
|
|
@@ -38068,11 +38087,51 @@ window.__ModuleLoader__.load({
|
|
|
38068
38087
|
"remote",
|
|
38069
38088
|
"remote.session"
|
|
38070
38089
|
];
|
|
38090
|
+
const MODE_STORAGE_KEY = "dsh-file-explorer.header-mode";
|
|
38091
|
+
/** Read the persisted mode; anything unknown defaults to the editor. */
|
|
38092
|
+
function readStoredMode() {
|
|
38093
|
+
try {
|
|
38094
|
+
const stored = window.localStorage.getItem(MODE_STORAGE_KEY);
|
|
38095
|
+
if (stored === "folder" || stored === "vscode") return stored;
|
|
38096
|
+
return "editor";
|
|
38097
|
+
} catch {
|
|
38098
|
+
return "editor";
|
|
38099
|
+
}
|
|
38100
|
+
}
|
|
38101
|
+
/** The official VS Code logo (brand colors), sized like the kit icons. */
|
|
38102
|
+
function VscodeIcon(props) {
|
|
38103
|
+
const { size = 16, className } = props;
|
|
38104
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
|
|
38105
|
+
width: size,
|
|
38106
|
+
height: size,
|
|
38107
|
+
viewBox: "0 0 32 32",
|
|
38108
|
+
className,
|
|
38109
|
+
"aria-hidden": true,
|
|
38110
|
+
focusable: "false",
|
|
38111
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
38112
|
+
children: [
|
|
38113
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
38114
|
+
d: "M29.01,5.03,23.244,2.254a1.742,1.742,0,0,0-1.989.338L2.38,19.8A1.166,1.166,0,0,0,2.3,21.447c.025.027.05.053.077.077l1.541,1.4a1.165,1.165,0,0,0,1.489.066L28.142,5.75A1.158,1.158,0,0,1,30,6.672V6.605A1.748,1.748,0,0,0,29.01,5.03Z",
|
|
38115
|
+
fill: "#0065a9"
|
|
38116
|
+
}),
|
|
38117
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
38118
|
+
d: "M29.01,26.97l-5.766,2.777a1.745,1.745,0,0,1-1.989-.338L2.38,12.2A1.166,1.166,0,0,1,2.3,10.553c.025-.027.05-.053.077-.077l1.541-1.4A1.165,1.165,0,0,1,5.41,9.01L28.142,26.25A1.158,1.158,0,0,0,30,25.328V25.4A1.749,1.749,0,0,1,29.01,26.97Z",
|
|
38119
|
+
fill: "#007acc"
|
|
38120
|
+
}),
|
|
38121
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
|
|
38122
|
+
d: "M23.244,29.747a1.745,1.745,0,0,1-1.989-.338A1.025,1.025,0,0,0,23,28.684V3.316a1.024,1.024,0,0,0-1.749-.724,1.744,1.744,0,0,1,1.989-.339l5.765,2.772A1.748,1.748,0,0,1,30,6.6V25.4a1.748,1.748,0,0,1-.991,1.576Z",
|
|
38123
|
+
fill: "#1f9cf0"
|
|
38124
|
+
})
|
|
38125
|
+
]
|
|
38126
|
+
});
|
|
38127
|
+
}
|
|
38071
38128
|
let ctxRef;
|
|
38072
38129
|
let store = {
|
|
38073
38130
|
open: false,
|
|
38074
38131
|
sessionId: "",
|
|
38075
38132
|
notice: null,
|
|
38133
|
+
mode: readStoredMode(),
|
|
38134
|
+
vscode: true,
|
|
38076
38135
|
pendingPath: null,
|
|
38077
38136
|
pendingSeq: 0
|
|
38078
38137
|
};
|
|
@@ -38137,6 +38196,32 @@ window.__ModuleLoader__.load({
|
|
|
38137
38196
|
};
|
|
38138
38197
|
emit();
|
|
38139
38198
|
}
|
|
38199
|
+
/** Ask the host to reveal the session's working folder in the OS file manager. */
|
|
38200
|
+
async function openSystemFolder(sessionId, cwd) {
|
|
38201
|
+
const resolved = sessionId !== void 0 && sessionId !== "" ? sessionId : activeSessionId;
|
|
38202
|
+
if (resolved === void 0) {
|
|
38203
|
+
showNotice("没有可用的会话:无法确定文件工作区。请先新建/选择一个会话。");
|
|
38204
|
+
return;
|
|
38205
|
+
}
|
|
38206
|
+
try {
|
|
38207
|
+
showNotice(`已打开文件夹:${(await api.fsReveal({ sessionId: resolved }, cwd)).cwd}`);
|
|
38208
|
+
} catch (error) {
|
|
38209
|
+
showNotice(`打开系统文件夹失败:${error instanceof Error ? error.message : String(error)}`);
|
|
38210
|
+
}
|
|
38211
|
+
}
|
|
38212
|
+
/** Ask the host to open the session's working folder in VS Code. */
|
|
38213
|
+
async function openInVscode(sessionId, cwd) {
|
|
38214
|
+
const resolved = sessionId !== void 0 && sessionId !== "" ? sessionId : activeSessionId;
|
|
38215
|
+
if (resolved === void 0) {
|
|
38216
|
+
showNotice("没有可用的会话:无法确定文件工作区。请先新建/选择一个会话。");
|
|
38217
|
+
return;
|
|
38218
|
+
}
|
|
38219
|
+
try {
|
|
38220
|
+
showNotice(`已在 VS Code 打开:${(await api.fsVscode({ sessionId: resolved }, cwd)).cwd}`);
|
|
38221
|
+
} catch (error) {
|
|
38222
|
+
showNotice(`用 VS Code 打开失败:${error instanceof Error ? error.message : String(error)}`);
|
|
38223
|
+
}
|
|
38224
|
+
}
|
|
38140
38225
|
/** Close the modal (session binding stays for the next open). */
|
|
38141
38226
|
function closeExplorer() {
|
|
38142
38227
|
store = {
|
|
@@ -38145,8 +38230,64 @@ window.__ModuleLoader__.load({
|
|
|
38145
38230
|
};
|
|
38146
38231
|
emit();
|
|
38147
38232
|
}
|
|
38148
|
-
|
|
38149
|
-
|
|
38233
|
+
function setMode(mode) {
|
|
38234
|
+
if (store.mode === mode) return;
|
|
38235
|
+
store = {
|
|
38236
|
+
...store,
|
|
38237
|
+
mode
|
|
38238
|
+
};
|
|
38239
|
+
emit();
|
|
38240
|
+
try {
|
|
38241
|
+
window.localStorage.setItem(MODE_STORAGE_KEY, mode);
|
|
38242
|
+
} catch {}
|
|
38243
|
+
}
|
|
38244
|
+
/**
|
|
38245
|
+
* Probe the host once per activation for VS Code availability and hide the
|
|
38246
|
+
* VSCode option (falling back to the editor) when it is missing — the user
|
|
38247
|
+
* explicitly wants the entry hidden rather than shown-but-broken.
|
|
38248
|
+
*/
|
|
38249
|
+
function probeVscode() {
|
|
38250
|
+
api.fsCapabilities().then((result) => {
|
|
38251
|
+
store = {
|
|
38252
|
+
...store,
|
|
38253
|
+
vscode: result.vscode
|
|
38254
|
+
};
|
|
38255
|
+
if (!result.vscode && store.mode === "vscode") store = {
|
|
38256
|
+
...store,
|
|
38257
|
+
mode: "editor"
|
|
38258
|
+
};
|
|
38259
|
+
emit();
|
|
38260
|
+
}).catch(() => {
|
|
38261
|
+
store = {
|
|
38262
|
+
...store,
|
|
38263
|
+
vscode: false
|
|
38264
|
+
};
|
|
38265
|
+
if (store.mode === "vscode") store = {
|
|
38266
|
+
...store,
|
|
38267
|
+
mode: "editor"
|
|
38268
|
+
};
|
|
38269
|
+
emit();
|
|
38270
|
+
});
|
|
38271
|
+
}
|
|
38272
|
+
const MODE_ITEMS = [
|
|
38273
|
+
{
|
|
38274
|
+
id: "editor",
|
|
38275
|
+
label: "编辑器",
|
|
38276
|
+
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconEditOutline16, {})
|
|
38277
|
+
},
|
|
38278
|
+
{
|
|
38279
|
+
id: "folder",
|
|
38280
|
+
label: "文件夹",
|
|
38281
|
+
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconFolderOpen16, {})
|
|
38282
|
+
},
|
|
38283
|
+
{
|
|
38284
|
+
id: "vscode",
|
|
38285
|
+
label: "VSCode",
|
|
38286
|
+
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VscodeIcon, {})
|
|
38287
|
+
}
|
|
38288
|
+
];
|
|
38289
|
+
/** The in-chat header icon: a single built-in-editor entry. */
|
|
38290
|
+
function HeaderIcon(props) {
|
|
38150
38291
|
const title = "文件预览 / 编辑(Ctrl+P)";
|
|
38151
38292
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
38152
38293
|
label: title,
|
|
@@ -38168,7 +38309,77 @@ window.__ModuleLoader__.load({
|
|
|
38168
38309
|
});
|
|
38169
38310
|
}
|
|
38170
38311
|
/**
|
|
38171
|
-
*
|
|
38312
|
+
* The hero ButtonGroup: the left button carries the remembered mode's icon and
|
|
38313
|
+
* runs its action, the right chevron opens the mode dropdown. Rendered only on
|
|
38314
|
+
* the hero / new-session screen — the in-chat header keeps the single
|
|
38315
|
+
* built-in-editor `HeaderIcon`.
|
|
38316
|
+
*/
|
|
38317
|
+
function HeroGroup(props) {
|
|
38318
|
+
const state = (0, react.useSyncExternalStore)(subscribe, getSnapshot);
|
|
38319
|
+
const [menuOpen, setMenuOpen] = (0, react.useState)(false);
|
|
38320
|
+
const useSessions = props.useSessions ?? (() => void 0);
|
|
38321
|
+
const sessionId = useSessions((s) => s.current);
|
|
38322
|
+
const sessionCwd = useSessions((s) => sessionId !== void 0 ? s.byId?.[sessionId]?.cwd : void 0);
|
|
38323
|
+
const mode = state.mode;
|
|
38324
|
+
const items = state.vscode ? MODE_ITEMS : MODE_ITEMS.filter((item) => item.id !== "vscode");
|
|
38325
|
+
const mainIcon = mode === "folder" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconFolderOpen16, {}) : mode === "vscode" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(VscodeIcon, {}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconEditOutline16, {});
|
|
38326
|
+
const mainTitle = mode === "folder" ? "打开系统文件夹" : mode === "vscode" ? "用 VSCode 打开工作区" : "文件预览 / 编辑(Ctrl+P)";
|
|
38327
|
+
const onMainClick = (e) => {
|
|
38328
|
+
e.stopPropagation();
|
|
38329
|
+
if (mode === "folder") openSystemFolder(sessionId, sessionCwd);
|
|
38330
|
+
else if (mode === "vscode") openInVscode(sessionId, sessionCwd);
|
|
38331
|
+
else openExplorer(sessionId);
|
|
38332
|
+
};
|
|
38333
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
38334
|
+
label: mainTitle,
|
|
38335
|
+
side: "bottom",
|
|
38336
|
+
delayMs: 400,
|
|
38337
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Menu, {
|
|
38338
|
+
open: menuOpen,
|
|
38339
|
+
align: "end",
|
|
38340
|
+
portal: true,
|
|
38341
|
+
compact: true,
|
|
38342
|
+
selectedId: mode,
|
|
38343
|
+
items,
|
|
38344
|
+
onSelect: (id) => {
|
|
38345
|
+
const next = id === "folder" || id === "vscode" ? id : "editor";
|
|
38346
|
+
setMode(next);
|
|
38347
|
+
setMenuOpen(false);
|
|
38348
|
+
if (next === "folder") openSystemFolder(sessionId, sessionCwd);
|
|
38349
|
+
else if (next === "vscode") openInVscode(sessionId, sessionCwd);
|
|
38350
|
+
else openExplorer(sessionId);
|
|
38351
|
+
},
|
|
38352
|
+
onClose: () => setMenuOpen(false),
|
|
38353
|
+
anchor: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
38354
|
+
className: "filex-group",
|
|
38355
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
38356
|
+
type: "button",
|
|
38357
|
+
className: "filex-group-main",
|
|
38358
|
+
size: "sm",
|
|
38359
|
+
variant: "outline",
|
|
38360
|
+
icon: mainIcon,
|
|
38361
|
+
title: mainTitle,
|
|
38362
|
+
"aria-label": mainTitle,
|
|
38363
|
+
onClick: onMainClick
|
|
38364
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
38365
|
+
type: "button",
|
|
38366
|
+
className: "filex-group-trigger",
|
|
38367
|
+
size: "sm",
|
|
38368
|
+
variant: "outline",
|
|
38369
|
+
title: "选择打开方式:编辑器 / 文件夹 / VSCode",
|
|
38370
|
+
"aria-label": "选择打开方式",
|
|
38371
|
+
onClick: (e) => {
|
|
38372
|
+
e.stopPropagation();
|
|
38373
|
+
setMenuOpen((v) => !v);
|
|
38374
|
+
},
|
|
38375
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, {})
|
|
38376
|
+
})]
|
|
38377
|
+
})
|
|
38378
|
+
})
|
|
38379
|
+
});
|
|
38380
|
+
}
|
|
38381
|
+
/**
|
|
38382
|
+
* Hero / new-session floating utility: the hero ButtonGroup, rendered by the
|
|
38172
38383
|
* plugin itself through the generic `shell.overlay` floating layer (no shell
|
|
38173
38384
|
* change needed) and pinned to the conversation column's top-right corner —
|
|
38174
38385
|
* the spot where the session-header utilities sit once a conversation has
|
|
@@ -38176,11 +38387,11 @@ window.__ModuleLoader__.load({
|
|
|
38176
38387
|
* (no session at all, or a blank session whose header is deliberately
|
|
38177
38388
|
* hidden); the position is measured from the rendered column root
|
|
38178
38389
|
* (`[data-phase]`), so sidebar collapse and details-panel toggles are
|
|
38179
|
-
* tracked automatically. Clicking behaves exactly like the
|
|
38180
|
-
* blank-session hero the
|
|
38181
|
-
* at all
|
|
38390
|
+
* tracked automatically. Clicking behaves exactly like the in-chat icon: in a
|
|
38391
|
+
* blank-session hero the actions bind to that session, and with no session
|
|
38392
|
+
* at all they surface the no-session notice.
|
|
38182
38393
|
*/
|
|
38183
|
-
function HeroFilexButton() {
|
|
38394
|
+
function HeroFilexButton(props) {
|
|
38184
38395
|
const [pos, setPos] = (0, react.useState)(null);
|
|
38185
38396
|
(0, react.useEffect)(() => {
|
|
38186
38397
|
const update = () => {
|
|
@@ -38216,7 +38427,7 @@ window.__ModuleLoader__.load({
|
|
|
38216
38427
|
top: pos.top,
|
|
38217
38428
|
right: pos.right
|
|
38218
38429
|
},
|
|
38219
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(
|
|
38430
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HeroGroup, { useSessions: props.useSessions })
|
|
38220
38431
|
});
|
|
38221
38432
|
}
|
|
38222
38433
|
/** The overlay entry: keyboard shortcut + modal + transient notice. */
|
|
@@ -38293,7 +38504,7 @@ window.__ModuleLoader__.load({
|
|
|
38293
38504
|
id: "file-explorer",
|
|
38294
38505
|
order: 10,
|
|
38295
38506
|
label: "文件预览 / 编辑"
|
|
38296
|
-
},
|
|
38507
|
+
}, HeaderIcon));
|
|
38297
38508
|
ctx.slots.inject("shell.overlay", () => {
|
|
38298
38509
|
const overlay = ctx.slots.register({
|
|
38299
38510
|
name: "shell.overlay",
|
|
@@ -38312,6 +38523,7 @@ window.__ModuleLoader__.load({
|
|
|
38312
38523
|
heroFab();
|
|
38313
38524
|
};
|
|
38314
38525
|
});
|
|
38526
|
+
probeVscode();
|
|
38315
38527
|
ctx.effect(() => {
|
|
38316
38528
|
const deps = {
|
|
38317
38529
|
currentSessionId: () => activeSessionId,
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
2
3
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
4
|
//#region src/trust-fence.ts
|
|
4
5
|
function header(headers, name) {
|
|
@@ -42,8 +43,9 @@ function isTrustedApiRequest(request) {
|
|
|
42
43
|
//#region src/index.ts
|
|
43
44
|
/**
|
|
44
45
|
* dsh-file-explorer host half: the /filex JSON API (session cwd, file list,
|
|
45
|
-
* read / write / content search
|
|
46
|
-
* PDFs). Every route passes the same
|
|
46
|
+
* read / write / content search, OS file-manager reveal, VS Code launch) and
|
|
47
|
+
* the /filex/file media route (images / PDFs). Every route passes the same
|
|
48
|
+
* loopback browser-trust fence.
|
|
47
49
|
*
|
|
48
50
|
* All operations are conversation-scoped: requests carry a sessionId and the
|
|
49
51
|
* session's authoritative cwd comes from the session store.
|
|
@@ -296,6 +298,110 @@ function parseGlobList(raw) {
|
|
|
296
298
|
}
|
|
297
299
|
return out;
|
|
298
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* Launch the OS file manager pointing at `target` (a directory). Detached and
|
|
303
|
+
* stdio-ignored so the spawn never blocks the host or leaks pipes; failures
|
|
304
|
+
* are swallowed — the API reports unsupported platforms instead.
|
|
305
|
+
*
|
|
306
|
+
* NOTE: never pass `windowsHide: true` on Windows — explorer.exe honors the
|
|
307
|
+
* startup show-window flag and would open its folder window hidden, making
|
|
308
|
+
* the click look dead. The plain GUI-app spawn shows the window normally.
|
|
309
|
+
*/
|
|
310
|
+
function openInSystemFileManager(target) {
|
|
311
|
+
let command;
|
|
312
|
+
let args;
|
|
313
|
+
if (process.platform === "win32") {
|
|
314
|
+
command = "explorer";
|
|
315
|
+
args = [target];
|
|
316
|
+
} else if (process.platform === "darwin") {
|
|
317
|
+
command = "open";
|
|
318
|
+
args = [target];
|
|
319
|
+
} else if (process.platform === "linux") {
|
|
320
|
+
command = "xdg-open";
|
|
321
|
+
args = [target];
|
|
322
|
+
} else return false;
|
|
323
|
+
try {
|
|
324
|
+
const child = spawn(command, args, {
|
|
325
|
+
detached: true,
|
|
326
|
+
stdio: "ignore"
|
|
327
|
+
});
|
|
328
|
+
child.on("error", () => {});
|
|
329
|
+
child.unref();
|
|
330
|
+
return true;
|
|
331
|
+
} catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Resolve the directory a reveal/open action should point at. Authoritative
|
|
337
|
+
* source: the session's own header cwd. When it is absent (sessions created
|
|
338
|
+
* without workspace metadata fall back to the host process cwd — usually
|
|
339
|
+
* meaningless), accept the loopback-only client's session-list cwd hint so
|
|
340
|
+
* the folder still opens where the user works.
|
|
341
|
+
*/
|
|
342
|
+
function revealCwdOf(ctx, sessionId, rawHint) {
|
|
343
|
+
const headerCwd = ctx.sessions.get(sessionId)?.header.cwd;
|
|
344
|
+
const hint = typeof rawHint === "string" && rawHint !== "" ? rawHint : void 0;
|
|
345
|
+
return headerCwd !== void 0 && headerCwd !== "" ? resolve(headerCwd) : hint !== void 0 && isAbsolute(hint) ? resolve(hint) : process.cwd();
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Locate the VS Code CLI (`code`). Checks the standard install locations
|
|
349
|
+
* first, then falls back to a PATH scan. Windows `code` is a .cmd shim, so
|
|
350
|
+
* the caller must route it through `cmd /c` (spawn alone cannot execute it).
|
|
351
|
+
*/
|
|
352
|
+
async function resolveVscodeCli() {
|
|
353
|
+
const candidates = [];
|
|
354
|
+
if (process.platform === "win32") {
|
|
355
|
+
const local = process.env.LOCALAPPDATA;
|
|
356
|
+
if (local !== void 0) candidates.push(join(local, "Programs", "Microsoft VS Code", "bin", "code.cmd"));
|
|
357
|
+
const pf = process.env.ProgramFiles;
|
|
358
|
+
if (pf !== void 0) candidates.push(join(pf, "Microsoft VS Code", "bin", "code.cmd"));
|
|
359
|
+
const pf86 = process.env["ProgramFiles(x86)"];
|
|
360
|
+
if (pf86 !== void 0) candidates.push(join(pf86, "Microsoft VS Code", "bin", "code.cmd"));
|
|
361
|
+
} else if (process.platform === "darwin") candidates.push("/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code");
|
|
362
|
+
else if (process.platform === "linux") candidates.push("/usr/bin/code", "/usr/local/bin/code", "/snap/bin/code");
|
|
363
|
+
for (const candidate of candidates) try {
|
|
364
|
+
await stat(candidate);
|
|
365
|
+
return candidate;
|
|
366
|
+
} catch {}
|
|
367
|
+
const name = process.platform === "win32" ? "code.cmd" : "code";
|
|
368
|
+
const paths = (process.env.PATH ?? "").split(process.platform === "win32" ? ";" : ":");
|
|
369
|
+
for (const dir of paths) {
|
|
370
|
+
if (dir === "") continue;
|
|
371
|
+
const probe = join(dir, name);
|
|
372
|
+
try {
|
|
373
|
+
await stat(probe);
|
|
374
|
+
return probe;
|
|
375
|
+
} catch {}
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Launch VS Code pointed at `target` (a directory). On Windows the CLI is a
|
|
381
|
+
* .cmd shim, so it is routed through `cmd /c` with the console hidden (the
|
|
382
|
+
* hidden console belongs to cmd only — the Code GUI window is unaffected).
|
|
383
|
+
*/
|
|
384
|
+
function openInVscode(cli, target) {
|
|
385
|
+
try {
|
|
386
|
+
const child = process.platform === "win32" ? spawn("cmd", [
|
|
387
|
+
"/c",
|
|
388
|
+
cli,
|
|
389
|
+
target
|
|
390
|
+
], {
|
|
391
|
+
detached: true,
|
|
392
|
+
stdio: "ignore",
|
|
393
|
+
windowsHide: true
|
|
394
|
+
}) : spawn(cli, [target], {
|
|
395
|
+
detached: true,
|
|
396
|
+
stdio: "ignore"
|
|
397
|
+
});
|
|
398
|
+
child.on("error", () => {});
|
|
399
|
+
child.unref();
|
|
400
|
+
return true;
|
|
401
|
+
} catch {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
299
405
|
function buildApi(ctx) {
|
|
300
406
|
const cwdOf = (payload) => {
|
|
301
407
|
const sessionId = requireString(payload, "sessionId");
|
|
@@ -351,6 +457,36 @@ function buildApi(ctx) {
|
|
|
351
457
|
}
|
|
352
458
|
return { ok: true };
|
|
353
459
|
},
|
|
460
|
+
"fs.reveal": async (payload) => {
|
|
461
|
+
const { sessionId } = cwdOf(payload);
|
|
462
|
+
const cwd = revealCwdOf(ctx, sessionId, payload.cwd);
|
|
463
|
+
if (!(await stat(cwd).catch(() => {
|
|
464
|
+
throw new FilexError("fs-error", `cannot open "${cwd}": not found`, 404);
|
|
465
|
+
})).isDirectory()) throw new FilexError("fs-error", `"${cwd}" is not a directory`);
|
|
466
|
+
if (!openInSystemFileManager(cwd)) throw new FilexError("unsupported", "current platform has no file-manager launcher", 501);
|
|
467
|
+
return {
|
|
468
|
+
ok: true,
|
|
469
|
+
cwd
|
|
470
|
+
};
|
|
471
|
+
},
|
|
472
|
+
"fs.vscode": async (payload) => {
|
|
473
|
+
const { sessionId } = cwdOf(payload);
|
|
474
|
+
const cwd = revealCwdOf(ctx, sessionId, payload.cwd);
|
|
475
|
+
if (!(await stat(cwd).catch(() => {
|
|
476
|
+
throw new FilexError("fs-error", `cannot open "${cwd}": not found`, 404);
|
|
477
|
+
})).isDirectory()) throw new FilexError("fs-error", `"${cwd}" is not a directory`);
|
|
478
|
+
const cli = await resolveVscodeCli();
|
|
479
|
+
if (cli === null) throw new FilexError("vscode-not-found", "未找到 VS Code 命令行工具(code)。请确认 VS Code 已安装且勾选了“添加到 PATH”", 404);
|
|
480
|
+
if (!openInVscode(cli, cwd)) throw new FilexError("unsupported", "failed to launch the VS Code CLI", 501);
|
|
481
|
+
return {
|
|
482
|
+
ok: true,
|
|
483
|
+
cwd,
|
|
484
|
+
cli
|
|
485
|
+
};
|
|
486
|
+
},
|
|
487
|
+
"fs.capabilities": async () => {
|
|
488
|
+
return { vscode: await resolveVscodeCli() !== null };
|
|
489
|
+
},
|
|
354
490
|
"fs.search": async (payload) => {
|
|
355
491
|
const { cwd } = cwdOf(payload);
|
|
356
492
|
const pattern = requireString(payload, "pattern").trim();
|
package/package.json
CHANGED