Make server file manager list-first

This commit is contained in:
npc0-hue
2026-08-24 15:39:05 +08:00
parent 49b90ebd29
commit 3005e0510c
10 changed files with 113 additions and 114 deletions
@@ -85,6 +85,16 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).not.toContain("Bearer ");
});
it("keeps the server file manager list-first without plugin declaration gates", () => {
expect(serverDetailPageSource).toContain("server-file-editor-overlay");
expect(serverDetailPageSource).not.toContain("server-file-layout");
expect(serverDetailPageSource).not.toContain("未声明目录");
expect(serverDetailPageSource).not.toContain("插件尚未声明");
expect(serverDetailPageSource).not.toContain("插件声明为可编辑");
expect(serverDetailPageSource).not.toContain("entry.editable");
expect(serverDetailPageSource).not.toContain("entry.downloadable");
});
it("keeps run distribution and client-manager workflows out of server detail tabs", () => {
expect(serverDetailPageSource).not.toContain('id="run-builder"');
expect(serverDetailPageSource).not.toContain("getServerRuntimeActions");
+30 -30
View File
@@ -1,4 +1,4 @@
import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles, X } from "lucide-react";
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
@@ -571,7 +571,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
const [editor, setEditor] = useState<ServerFileEditorState>({ entry: null, key: "", draft: "", loading: false, saving: false });
const activeDirectory = workspace.status === "ready" ? workspace.data.directories.find((item) => item.key === directoryKey) : undefined;
const canUpload = workspace.status === "ready" && Boolean(activeDirectory) && activeDirectory?.scope !== "logs" && !uploadBusy;
const canUpload = workspace.status === "ready" && Boolean(activeDirectory) && !uploadBusy;
const entries = list.status === "ready" ? list.data.entries : [];
const loadWorkspace = useCallback(async () => {
@@ -581,9 +581,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
setWorkspace({ status: "ready", data: response });
const nextDirectoryKey = response.defaultDirectoryKey || response.directories[0]?.key || "";
setDirectoryKey((current) => current || nextDirectoryKey);
if (!nextDirectoryKey) {
setList({ status: "ready", data: { serverInstanceId: response.serverInstanceId, pluginId: response.pluginId, directoryKey: "", state: "declared", entries: [], reason: "插件尚未声明文件工作区;需要在插件 manifest 中添加 fileWorkspace。" } });
}
if (!nextDirectoryKey) setList({ status: "ready", data: { serverInstanceId: response.serverInstanceId, pluginId: response.pluginId, directoryKey: "", state: "declared", entries: [], reason: "尚未获得服务器文件入口。" } });
} catch (error) {
setWorkspace({ status: "error", reason: error instanceof Error ? error.message : "文件工作区加载失败" });
setList({ status: "error", reason: "文件工作区不可用" });
@@ -639,7 +637,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
async function openEditor(entry: ServerFileEntryResponse) {
const key = serverFileEntryKey(entry);
if (!key) {
setPanelResult({ status: "failed", label: "该文件缺少插件声明的逻辑 key,不能读取。" });
setPanelResult({ status: "failed", label: "该文件缺少路径 key,不能读取。" });
return;
}
setEditor({ entry, key, draft: "", loading: true, saving: false, message: "正在读取最近快照…" });
@@ -662,7 +660,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
}
async function saveEditor() {
if (!editor.entry || !editor.key || editor.saving || !editor.entry.editable) return;
if (!editor.entry || !editor.key || editor.saving) return;
const operationId = operations.begin({ intent: "保存文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
setEditor((current) => ({ ...current, saving: true, error: undefined, message: "正在派发写入任务…" }));
try {
@@ -681,7 +679,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
async function downloadEntry(entry: ServerFileEntryResponse) {
const key = serverFileEntryKey(entry);
if (!key || !entry.downloadable) return;
if (!key) return;
const operationId = operations.begin({ intent: "下载文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
setPanelResult({ status: "pending", label: "正在准备文件下载…" });
try {
@@ -736,6 +734,10 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
}
}
function closeEditor() {
setEditor({ entry: null, key: "", draft: "", loading: false, saving: false });
}
if (workspace.status === "loading") return <LoadingState label="正在加载文件工作区…" compact />;
if (workspace.status === "error") return <ErrorState title="文件工作区不可用" reason={workspace.reason} diagnosticId={`server-files:${instance.id}`} onRetry={() => void loadWorkspace()} compact />;
@@ -747,18 +749,17 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
</div>
<div className="server-file-pathbar" aria-label="当前文件路径">
<button type="button" className="icon-command" onClick={goUp} disabled={!relativePath && directoryKey === workspace.data.defaultDirectoryKey}><ChevronRight size={14} className="server-file-back-icon" /><span></span></button>
<span className="server-file-path-chip">{activeDirectory?.label ?? (directoryKey || "未声明目录")}</span>
<span className="server-file-path-chip">{activeDirectory?.label ?? (directoryKey || "服务器根目录")}</span>
{relativePath.split("/").filter(Boolean).map((part) => <span key={part} className="server-file-path-chip server-file-path-child"><ChevronRight size={12} />{part}</span>)}
</div>
<div className="server-file-toolbar">
<div className="server-file-directory-tabs" role="tablist" aria-label="文件目录">
{workspace.data.directories.length === 0 && <span className="provider-id"></span>}
<div className={cx("server-file-toolbar", workspace.data.directories.length > 1 && "server-file-toolbar-has-tabs")}>
{workspace.data.directories.length > 1 && <div className="server-file-directory-tabs" role="tablist" aria-label="文件目录">
{workspace.data.directories.map((directory) => (
<button key={directory.key} type="button" className={cx("segmented-button", directory.key === directoryKey && "segmented-button-active")} onClick={() => { setDirectoryKey(directory.key); setRelativePath(""); }}>
{directory.label}
</button>
))}
</div>
</div>}
<form className="server-file-search" onSubmit={submitSearch}>
<Search size={14} />
<input type="search" value={searchDraft} placeholder="搜索文件/目录" onChange={(event) => setSearchDraft(event.target.value)} />
@@ -767,7 +768,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
</form>
<div className="action-strip server-file-actions">
<button type="button" className="icon-command" onClick={() => void refreshRuntimeList()} disabled={!directoryKey}><RefreshCw size={14} /><span></span></button>
<label className={cx("server-file-upload-control", !canUpload && "server-file-upload-disabled")} title={canUpload ? "上传到当前逻辑目录" : "当前目录不可上传或正在上传"}>
<label className={cx("server-file-upload-control", !canUpload && "server-file-upload-disabled")} title={canUpload ? "上传到当前目录" : "当前目录不可上传或正在上传"}>
<Upload size={14} /><span>{uploadBusy ? "上传中…" : "上传"}</span><input type="file" disabled={!canUpload} onChange={(event) => void uploadFile(event)} />
</label>
</div>
@@ -776,38 +777,37 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
{list.status === "loading" && <LoadingState label="正在加载文件列表…" compact />}
{list.status === "error" && <ErrorState title="文件列表不可用" reason={list.reason} diagnosticId={`server-file-list:${instance.id}:${directoryKey}`} onRetry={() => void loadList()} compact />}
{list.status === "ready" && (
<div className="server-file-layout">
<div className="resource-table-wrap server-file-table-wrap">
<div className="resource-table-wrap server-file-table-wrap">
<table className="resource-table server-file-table">
<thead><tr><th aria-label="选择"><input type="checkbox" disabled /></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{entries.length === 0 && <tr><td colSpan={6}><span className="provider-id"></span></td></tr>}
{entries.length === 0 && <tr><td colSpan={5}><span className="provider-id"></span></td></tr>}
{entries.map((entry) => (
<tr key={serverFileEntryRowKey(entry)} className={entry.kind === "directory" ? "server-file-directory-row" : undefined}>
<td><input type="checkbox" disabled /></td>
<td>
<button type="button" className="table-link-button server-file-name-button" onClick={() => void openEntry(entry)}>
{entry.kind === "directory" ? <Folder size={16} /> : <FileText size={16} />}<span>{entry.name}</span>
</button>
<span className="provider-id">{entry.logicalKey || entry.relativePath || entry.directoryKey}</span>
</td>
<td>{entry.kind === "directory" ? "计算" : formatBytes(entry.sizeBytes)}</td>
<td>{entry.kind === "directory" ? "--" : formatBytes(entry.sizeBytes)}</td>
<td>{formatDateTime(entry.modifiedAt)}</td>
<td>{entry.remark || entry.scope || "--"}</td>
<td>
<div className="row-actions human-row-actions">
{entry.kind === "directory" ? <button type="button" title="打开目录" onClick={() => void openEntry(entry)}><Eye size={14} /><span></span></button> : <button type="button" title="读取/编辑" disabled={!entry.editable} onClick={() => void openEditor(entry)}><Pencil size={14} /><span></span></button>}
{entry.kind === "file" && <button type="button" title="下载" disabled={!entry.downloadable} onClick={() => void downloadEntry(entry)}><Download size={14} /><span></span></button>}
{entry.kind === "directory" ? <button type="button" title="打开目录" onClick={() => void openEntry(entry)}><Eye size={14} /><span></span></button> : <button type="button" title="读取/编辑" onClick={() => void openEditor(entry)}><Pencil size={14} /><span></span></button>}
{entry.kind === "file" && <button type="button" title="下载" onClick={() => void downloadEntry(entry)}><Download size={14} /><span></span></button>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<aside className="server-file-editor" aria-label="file editor">
<div className="panel-header"><h3><FileText size={15} style={{ verticalAlign: "-2px" }} /> </h3>{editor.entry && <span className="page-status">{editor.entry.name}</span>}</div>
{!editor.entry && <p className="section-copy"> Run </p>}
</div>
)}
{editor.entry && (
<div className="server-file-editor-overlay" role="dialog" aria-modal="true" aria-label="file editor">
<section className="server-file-editor">
<div className="panel-header"><h3><FileText size={15} style={{ verticalAlign: "-2px" }} /> </h3><span className="page-status">{editor.entry.name}</span><button type="button" className="icon-command" onClick={closeEditor}><X size={14} /><span></span></button></div>
{editor.entry && editor.loading && <LoadingState label="正在读取文件快照…" compact />}
{editor.entry && editor.error && <ErrorState title="文件编辑不可用" reason={editor.error} diagnosticId={`server-file-edit:${instance.id}:${editor.key}`} compact />}
{editor.entry && editor.message && !editor.error && <span className="provider-id">{editor.message}</span>}
@@ -819,11 +819,11 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
)}
{editor.entry && (
<div className="action-strip server-file-editor-actions">
<button type="button" className="primary-command" disabled={!editor.entry.editable || editor.loading || editor.saving || editor.snapshot?.content === undefined} onClick={() => void saveEditor()}><Save size={14} /><span>{editor.saving ? "保存中…" : "保存"}</span></button>
<button type="button" className="icon-command" disabled={!editor.entry.downloadable} onClick={() => void downloadEntry(editor.entry!)}><Download size={14} /><span></span></button>
<button type="button" className="primary-command" disabled={editor.loading || editor.saving || editor.snapshot?.content === undefined} onClick={() => void saveEditor()}><Save size={14} /><span>{editor.saving ? "保存中…" : "保存"}</span></button>
<button type="button" className="icon-command" onClick={() => void downloadEntry(editor.entry!)}><Download size={14} /><span></span></button>
</div>
)}
</aside>
</section>
</div>
)}
</article>