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
+2 -2
View File
@@ -27,8 +27,8 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
- `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response.
- `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators.
- Game-specific pages use the scoped `plugin-data` collection API and declared plugin bridge machine actions; Platform does not expose game-specific projection or workflow clients.
- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it remains the low-level compatibility dispatch for plugin-declared file work.
- `getServerFileWorkspace`, `listServerFiles`, `refreshServerFiles`, `readServerFile`, `getServerFileReadSnapshot`, `writeServerFile`, `uploadServerFile`, and `prepareServerFileDownload` power the first-party server-detail file manager. The page renders plugin-declared logical directories, requests live listings through `files.list`, reads editable snapshots through `files.read`, saves through `files.write`, and stages browser uploads as server-instance artifacts before Run pulls input chunks on the dedicated file-transfer channel. Server detail may call only these server-file APIs plus the encapsulated download helper; it must not call raw artifact-transfer methods directly.
- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it remains the low-level compatibility dispatch for file work.
- `getServerFileWorkspace`, `listServerFiles`, `refreshServerFiles`, `readServerFile`, `getServerFileReadSnapshot`, `writeServerFile`, `uploadServerFile`, and `prepareServerFileDownload` power the first-party server-detail file manager. The page renders a generic server-root entry, requests live listings through `files.list`, reads snapshots through `files.read`, saves through `files.write`, and stages browser uploads as server-instance artifacts before Run pulls input chunks on the dedicated file-transfer channel. Server detail may call only these server-file APIs plus the encapsulated download helper; it must not call raw artifact-transfer methods directly.
- `listArtifacts`, `openArtifactDownload`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser reads are chunked through `/artifacts/{id}/content` and must render only safe filenames, checksums, progress, and platform storage behavior.
- `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions.
- `executePluginBridge` posts `PluginBridgeExecuteRequest` to `/plugin-bridge/execute` from host-owned bridge dispatch utilities only. Plugin pages receive typed `PluginBridgeExecuteResponse` envelopes and never receive the platform API client, bearer token, raw provider key, run socket, host path, or storage credential.
@@ -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>
+6 -6
View File
@@ -455,7 +455,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.server-file-back-icon{transform:rotate(180deg)}
.server-file-path-chip{display:inline-flex;align-items:center;gap:4px;min-height:30px;padding:0 10px;border:1px solid var(--line);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);font-size:12px;font-weight:800;white-space:nowrap}
.server-file-path-child{color:var(--ink-faint)}
.server-file-toolbar{display:grid;grid-template-columns:minmax(180px,1fr) minmax(260px,1.2fr) auto;gap:8px;align-items:center;min-width:0}
.server-file-toolbar{display:grid;grid-template-columns:minmax(320px,1fr) auto;gap:8px;align-items:center;min-width:0}.server-file-toolbar-has-tabs{grid-template-columns:minmax(160px,.8fr) minmax(320px,1.2fr) auto}
.server-file-directory-tabs{display:flex;gap:6px;overflow:auto;min-width:0;padding-bottom:2px}
.server-file-search{display:flex;align-items:center;gap:7px;min-width:0;min-height:38px;padding:0 8px;border:1px solid var(--line);border-radius:8px;background:var(--control-surface);box-shadow:inset 0 1px 0 var(--crystal-rim)}
.server-file-search input[type=search]{min-width:120px;flex:1 1 auto;border:0;background:transparent;color:var(--ink);font:inherit;outline:0}
@@ -464,11 +464,11 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.server-file-upload-control{min-height:36px;display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--line-strong);border-radius:8px;padding:0 12px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;box-shadow:inset 0 1px 0 var(--crystal-rim),0 8px 18px var(--glass-shadow);font-weight:700;white-space:nowrap}
.server-file-upload-control input{display:none}.server-file-upload-control:hover,.server-file-upload-control:focus-within{border-color:var(--accent);color:var(--ink);box-shadow:inset 0 1px 0 var(--crystal-rim),0 0 0 2px var(--accent-soft),0 12px 26px var(--glass-shadow)}
.server-file-upload-disabled{opacity:.55;cursor:not-allowed}
.server-file-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,360px);gap:12px;align-items:start;min-width:0}
.server-file-table-wrap{max-height:560px}.server-file-table{min-width:860px}.server-file-table td:first-child,.server-file-table th:first-child{width:44px}.server-file-directory-row{background:color-mix(in srgb,var(--accent-soft) 54%,transparent)}
.server-file-table-wrap{max-height:min(68vh,760px)}.server-file-table{min-width:760px}.server-file-table th:last-child,.server-file-table td:last-child{width:190px}.server-file-directory-row{background:color-mix(in srgb,var(--accent-soft) 54%,transparent)}
.server-file-name-button{display:inline-flex;align-items:center;gap:8px;color:var(--ink);font-weight:850}.server-file-name-button svg{color:var(--accent-deep)}
.server-file-editor{display:grid;gap:10px;padding:12px;border:1px solid var(--line);border-radius:8px;background:var(--glass-wash),var(--glass-tint),var(--surface);box-shadow:inset 0 1px 0 var(--crystal-rim),0 14px 30px var(--glass-shadow);min-width:0}
.server-file-editor-field{display:grid;gap:6px;color:var(--ink-soft);font-size:13px;font-weight:800}.server-file-editor-field textarea{min-height:320px;width:100%;border:1px solid var(--line-strong);border-radius:8px;padding:10px;background:var(--surface-solid);color:var(--ink);font:13px/1.45 var(--font-mono);resize:vertical}.server-file-editor-field textarea:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}
.server-file-editor-overlay{position:fixed;inset:0;z-index:70;display:grid;place-items:center;padding:24px;background:rgba(0,0,0,.42);backdrop-filter:blur(10px)}
.server-file-editor{display:grid;gap:10px;width:min(960px,calc(100vw - 48px));max-height:calc(100dvh - 48px);overflow:auto;padding:12px;border:1px solid var(--line);border-radius:8px;background:var(--glass-wash),var(--glass-tint),var(--surface);box-shadow:inset 0 1px 0 var(--crystal-rim),0 24px 70px var(--glass-shadow);min-width:0}
.server-file-editor-field{display:grid;gap:6px;color:var(--ink-soft);font-size:13px;font-weight:800}.server-file-editor-field textarea{min-height:320px;height:min(62vh,560px);width:100%;border:1px solid var(--line-strong);border-radius:8px;padding:10px;background:var(--surface-solid);color:var(--ink);font:13px/1.45 var(--font-mono);resize:vertical}.server-file-editor-field textarea:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}
.server-file-editor-actions .primary-command{width:auto}
.server-card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:16px}
.server-card{display:grid;gap:12px;padding:16px;border:1px solid var(--line);border-radius:8px;background:var(--frosted-surface),var(--glass-tint),var(--surface);backdrop-filter:blur(22px) saturate(1.28);text-align:left;transition:transform 120ms ease,border-color 120ms ease;box-shadow:var(--jelly-inset),inset 0 0 0 1px var(--diamond-line),0 18px 42px var(--glass-shadow),0 0 28px rgba(255,255,255,.2);position:relative;overflow:hidden;min-width:0}
@@ -726,7 +726,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.section-tabs{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px}
.server-toolbar{align-items:stretch}
.server-toolbar input[type=search],.server-toolbar select{flex:1 1 100%;min-width:0}
.server-file-toolbar,.server-file-layout{grid-template-columns:1fr}.server-file-search,.server-file-actions,.server-file-pathbar{align-items:stretch;flex-wrap:wrap}.server-file-search input[type=search]{min-width:0}.server-file-actions{justify-content:stretch}.server-file-actions>*{flex:1 1 auto}
.server-file-toolbar{grid-template-columns:1fr}.server-file-search,.server-file-actions,.server-file-pathbar{align-items:stretch;flex-wrap:wrap}.server-file-search input[type=search]{min-width:0}.server-file-actions{justify-content:stretch}.server-file-actions>*{flex:1 1 auto}
.plugin-control-row,.server-card-head,.server-detail-title-row{grid-template-columns:1fr;align-items:stretch}
.server-card-head,.server-detail-title-row{display:grid}
.server-detail-title-row .action-strip{align-items:stretch}