Make server file manager list-first
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user