feat(panel): add the server files page for the world-volume repair lever
The backend could list/read/write a stopped server's world volume since the file-editor slice, but the panel had no entry, so the one repair path for a server that will not boot (a wrong line in server.properties) was API-only. New /servers/:name/files page: breadcrumb browser, editor dialog with the base64 []byte codec, binary files open read-only, the stopped gate is owned up front (with a stop action) instead of letting every call 409, and a doorway card on the console. i18n files namespace + wire-shape tests.
This commit is contained in:
11 files changed
+601
-1
No files matched your search
@@ -12,6 +12,7 @@ import { ServersPage } from "@/pages/servers/ServersPage";
|
||||
import { ServerConsole } from "@/pages/ServerConsole";
|
||||
import { ServerPlayers } from "@/pages/ServerPlayers";
|
||||
import { ServerBackups } from "@/pages/ServerBackups";
|
||||
import { ServerFiles } from "@/pages/ServerFiles";
|
||||
import { ServerLuckPerms } from "@/pages/ServerLuckPerms";
|
||||
import { Account } from "@/pages/Account";
|
||||
import { ImageAdmin } from "@/pages/admin/ImageAdmin";
|
||||
@@ -54,6 +55,7 @@ export default function App() {
|
||||
<Route path="servers/:name" element={<ServerConsole />} />
|
||||
<Route path="servers/:name/players" element={<ServerPlayers />} />
|
||||
<Route path="servers/:name/backups" element={<ServerBackups />} />
|
||||
<Route path="servers/:name/files" element={<ServerFiles />} />
|
||||
<Route path="servers/:name/luckperms" element={<ServerLuckPerms />} />
|
||||
<Route path="submissions" element={<MySubmissionsPage />} />
|
||||
<Route path="account" element={<Account />} />
|
||||
|
||||
@@ -12,6 +12,7 @@ import enErrors from "./resources/en-US/errors.json";
|
||||
import enNavigation from "./resources/en-US/navigation.json";
|
||||
import enBackups from "./resources/en-US/backups.json";
|
||||
import enSubmissions from "./resources/en-US/submissions.json";
|
||||
import enFiles from "./resources/en-US/files.json";
|
||||
import zhCommon from "./resources/zh-CN/common.json";
|
||||
import zhAuth from "./resources/zh-CN/auth.json";
|
||||
import zhDashboard from "./resources/zh-CN/dashboard.json";
|
||||
@@ -23,6 +24,7 @@ import zhErrors from "./resources/zh-CN/errors.json";
|
||||
import zhNavigation from "./resources/zh-CN/navigation.json";
|
||||
import zhBackups from "./resources/zh-CN/backups.json";
|
||||
import zhSubmissions from "./resources/zh-CN/submissions.json";
|
||||
import zhFiles from "./resources/zh-CN/files.json";
|
||||
|
||||
i18next
|
||||
.use(LanguageDetector)
|
||||
@@ -41,6 +43,7 @@ i18next
|
||||
navigation: enNavigation,
|
||||
backups: enBackups,
|
||||
submissions: enSubmissions,
|
||||
files: enFiles,
|
||||
},
|
||||
"zh-CN": {
|
||||
common: zhCommon,
|
||||
@@ -54,6 +57,7 @@ i18next
|
||||
navigation: zhNavigation,
|
||||
backups: zhBackups,
|
||||
submissions: zhSubmissions,
|
||||
files: zhFiles,
|
||||
},
|
||||
},
|
||||
fallbackLng: "en-US",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"title": "Server Files",
|
||||
"back_to_console": "Back to console",
|
||||
"root": "World root",
|
||||
"up": "Up",
|
||||
"refresh": "Refresh",
|
||||
"col_name": "Name",
|
||||
"col_size": "Size",
|
||||
"col_modified": "Modified",
|
||||
"empty_dir_title": "This folder is empty",
|
||||
"empty_dir_hint": "Nothing has been written here yet.",
|
||||
"list_truncated": "The listing was truncated at the server's safety limit.",
|
||||
"stopped_required_title": "The server is running",
|
||||
"stopped_required_body": "Files can only be browsed or edited while the server is fully stopped — the world volume is single-attached to the running server. Stop it to continue.",
|
||||
"stop_server": "Stop server",
|
||||
"not_yours_title": "Not your server",
|
||||
"not_yours_body": "Only the owner or an admin can edit this server's files.",
|
||||
"binary_hint": "Binary file — opened read-only; saving non-text bytes through an editor could corrupt it.",
|
||||
"saved": "Saved {{path}}",
|
||||
"saving": "Saving…",
|
||||
"save": "Save",
|
||||
"too_large": "Exceeds the {{limit}} editor limit."
|
||||
}
|
||||
@@ -49,6 +49,8 @@
|
||||
"luckperms_link_desc": "Manage player groups and fine-grained permission nodes.",
|
||||
"backups_link_title": "Backups & restore",
|
||||
"backups_link_desc": "View and restore world backups for this server.",
|
||||
"files_link_title": "Server files",
|
||||
"files_link_desc": "Browse and repair files in the world volume.",
|
||||
"players_back_to_console": "Back to console",
|
||||
"players_not_yours_title": "Not your server",
|
||||
"players_not_yours_body": "Only the owner or an admin can manage this server's players.",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"title": "服务器文件",
|
||||
"back_to_console": "返回控制台",
|
||||
"root": "世界根目录",
|
||||
"up": "上级目录",
|
||||
"refresh": "刷新",
|
||||
"col_name": "名称",
|
||||
"col_size": "大小",
|
||||
"col_modified": "修改时间",
|
||||
"empty_dir_title": "此目录为空",
|
||||
"empty_dir_hint": "这里还没有写入任何内容。",
|
||||
"list_truncated": "列表已按服务器安全上限截断。",
|
||||
"stopped_required_title": "服务器正在运行",
|
||||
"stopped_required_body": "只有在服务器完全停止后才能浏览或编辑文件——世界卷被运行中的服务器独占挂载。请先停止服务器。",
|
||||
"stop_server": "停止服务器",
|
||||
"not_yours_title": "这不是你的服务器",
|
||||
"not_yours_body": "只有服务器所有者或管理员才能编辑其文件。",
|
||||
"binary_hint": "二进制文件——以只读方式打开;通过编辑器保存非文本字节可能损坏该文件。",
|
||||
"saved": "已保存 {{path}}",
|
||||
"saving": "保存中…",
|
||||
"save": "保存",
|
||||
"too_large": "超出编辑器 {{limit}} 上限。"
|
||||
}
|
||||
@@ -49,6 +49,8 @@
|
||||
"luckperms_link_desc": "管理玩家用户组与细粒度权限节点。",
|
||||
"backups_link_title": "备份与恢复",
|
||||
"backups_link_desc": "查看并恢复该服务器的世界备份。",
|
||||
"files_link_title": "服务器文件",
|
||||
"files_link_desc": "浏览并修复世界卷中的文件。",
|
||||
"players_back_to_console": "返回控制台",
|
||||
"players_not_yours_title": "这不是你的服务器",
|
||||
"players_not_yours_body": "只有所有者或管理员才能管理此服务器的玩家。",
|
||||
|
||||
@@ -582,6 +582,46 @@ describe("image whitelist and builds wire shapes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("server file editor wire shape", () => {
|
||||
it("listServerFiles GETs /servers/{name}/files with the path as a query parameter", async () => {
|
||||
const entries = [
|
||||
{ name: "world", size: 0, is_dir: true, mod_time: "2026-07-03T12:00:00Z" },
|
||||
{ name: "server.properties", size: 580, is_dir: false, mod_time: "2026-07-03T12:00:00Z" },
|
||||
];
|
||||
const fetchSpy = fakeFetch({ path: "config", entries, truncated: false });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.listServerFiles("survival", "config/old stuff");
|
||||
expect(res.entries).toHaveLength(2);
|
||||
expect(res.entries[1].is_dir).toBe(false);
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
// "/" and " " must survive the query encoding — the path is a query value,
|
||||
// never a path segment.
|
||||
expect(String(url)).toBe("/servers/survival/files?path=config%2Fold%20stuff");
|
||||
expect((opts as RequestInit).method).toBe("GET");
|
||||
});
|
||||
|
||||
it("readServerFile GETs /servers/{name}/file and passes base64 through", async () => {
|
||||
const fetchSpy = fakeFetch({ path: "world/level.dat", content: "AAEC" });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.readServerFile("survival", "world/level.dat");
|
||||
expect(res.content).toBe("AAEC");
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/servers/survival/file?path=world%2Flevel.dat");
|
||||
expect((opts as RequestInit).method).toBe("GET");
|
||||
});
|
||||
|
||||
it("writeServerFile PUTs {content} — an explicit \"\" is a deliberate truncate, not an omitted field", async () => {
|
||||
const fetchSpy = fakeFetch({ path: "a.txt", status: "written" });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.writeServerFile("survival", "a.txt", "");
|
||||
expect(res.status).toBe("written");
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/servers/survival/file?path=a.txt");
|
||||
expect((opts as RequestInit).method).toBe("PUT");
|
||||
expect((opts as RequestInit).body).toBe(JSON.stringify({ content: "" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("user passkey unbind wire shape", () => {
|
||||
it("unbindUserPasskeys DELETEs /users/{id}/passkeys", async () => {
|
||||
const fetchSpy = fakeFetch({ ok: true });
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
PlayersResult,
|
||||
QuotaInput,
|
||||
QuotaView,
|
||||
ServerFileEntry,
|
||||
ServerJob,
|
||||
ServerInfo,
|
||||
SessionView,
|
||||
@@ -359,6 +360,37 @@ export const api = {
|
||||
`/servers/${name}/jobs`,
|
||||
).then((r) => r.jobs ?? []),
|
||||
|
||||
// Server file editor (spec §7). All three routes are owner-or-admin gated and
|
||||
// refuse with 409 not_stopped unless the server is fully stopped (the world
|
||||
// volume is RWO), so callers gate on phase === "Stopped". The path travels as a
|
||||
// query parameter — a file path contains "/" and never round-trips through a
|
||||
// path segment. Content is []byte on the wire, which Go's encoding/json renders
|
||||
// as base64, so it is binary-safe in both directions.
|
||||
listServerFiles: (name: string, path: string) =>
|
||||
request<{ path: string; entries: ServerFileEntry[]; truncated: boolean }>(
|
||||
"GET",
|
||||
`/servers/${name}/files?path=${encodeURIComponent(path)}`,
|
||||
),
|
||||
|
||||
// readServerFile returns one file's bytes (base64). A file over the read
|
||||
// ceiling is a 413, never a silent truncation, because a later save of a
|
||||
// truncated body would destroy the rest of the file.
|
||||
readServerFile: (name: string, path: string) =>
|
||||
request<{ path: string; content: string }>(
|
||||
"GET",
|
||||
`/servers/${name}/file?path=${encodeURIComponent(path)}`,
|
||||
),
|
||||
|
||||
// writeServerFile replaces a file's contents (creating it if absent). Sending
|
||||
// an explicit "" is a deliberate truncate; the wire field is required, but that
|
||||
// is enforced by the caller (this method always sends one).
|
||||
writeServerFile: (name: string, path: string, content: string) =>
|
||||
request<{ path: string; status: string }>(
|
||||
"PUT",
|
||||
`/servers/${name}/file?path=${encodeURIComponent(path)}`,
|
||||
{ content },
|
||||
),
|
||||
|
||||
// Account linking (spec §10). Both are POST: start reports status from the
|
||||
// session principal (no body, side-effect-free), verify consumes a code the
|
||||
// player was shown in-game. The panel can never mint a code — that is the
|
||||
|
||||
@@ -236,6 +236,15 @@ export interface BindResult {
|
||||
|
||||
export type BuildStatus = "pending" | "building" | "succeeded" | "failed" | "cancelled";
|
||||
|
||||
/** ServerFileEntry mirrors fileedit.Entry — one row of a world-directory listing
|
||||
* (GET /servers/{name}/files). */
|
||||
export interface ServerFileEntry {
|
||||
name: string;
|
||||
size: number;
|
||||
is_dir: boolean;
|
||||
mod_time: string;
|
||||
}
|
||||
|
||||
/** Build mirrors an image_builds row (spec §6, §16). */
|
||||
export interface Build {
|
||||
id: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useCallback, useLayoutEffect, type KeyboardEvent } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, ChevronRight, type LucideIcon } from "lucide-react";
|
||||
import { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, FolderOpen, ChevronRight, type LucideIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -316,6 +316,21 @@ export function ServerConsole() {
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
|
||||
{/* File editor — its own subpage. Files are only reachable while the server
|
||||
is stopped (the world volume is RWO), so the page itself owns that gate
|
||||
and offers the stop action; the doorway shows at every phase. */}
|
||||
<Link
|
||||
to={`/servers/${name}/files`}
|
||||
className="group flex items-center gap-3 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent"
|
||||
>
|
||||
<FolderOpen className="h-5 w-5 shrink-0 text-primary" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{t("files_link_title")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("files_link_desc")}</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowUp,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Square,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BackLink } from "@/components/BackLink";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { MessageLine } from "@/components/MessageLine";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { PhaseBadge } from "@/components/PhaseBadge";
|
||||
import { Loading, ErrorState, NotYours, EmptyState } from "@/components/States";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import { useTier } from "@/lib/tier";
|
||||
import { formatBytes, formatRelative } from "@/lib/format";
|
||||
import type { ServerFileEntry } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** The write ceiling, mirrored from fileedit.MaxWriteBytes (server truth). Reads
|
||||
* are capped at 1 MiB server-side; a larger file is refused there with 413, so
|
||||
* this only gates the save button to keep the common case honest up front. */
|
||||
const MAX_WRITE_BYTES = 256 * 1024;
|
||||
|
||||
/** The []byte wire codec: Go's encoding/json renders []byte as base64, so the
|
||||
* editor must speak it explicitly in both directions. Chunked so a ~256 KiB
|
||||
* file never trips the argument-length ceiling of String.fromCharCode. */
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let bin = "";
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
function base64ToBytes(b64: string): Uint8Array {
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** decodeText returns the file's text, or null when the bytes are not valid
|
||||
* UTF-8 or contain NUL. Such files open read-only: a textarea round-trip would
|
||||
* silently corrupt them, and this editor exists to REPAIR configs, never to
|
||||
* damage data it does not understand. */
|
||||
function decodeText(bytes: Uint8Array): string | null {
|
||||
try {
|
||||
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
return text.includes("\u0000") ? null : text;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function joinPath(dir: string, name: string): string {
|
||||
return dir === "" ? name : `${dir}/${name}`;
|
||||
}
|
||||
|
||||
function parentOf(dir: string): string {
|
||||
const i = dir.lastIndexOf("/");
|
||||
return i === -1 ? "" : dir.slice(0, i);
|
||||
}
|
||||
|
||||
/** ServerFiles is the world-volume file editor (the "one wrong line in
|
||||
* server.properties" repair). Every call is owner-or-admin gated and refused
|
||||
* with 409 not_stopped unless the server is fully stopped (the world volume is
|
||||
* RWO), so the page gates up front instead of letting each call fail. */
|
||||
export function ServerFiles() {
|
||||
const { name = "" } = useParams();
|
||||
const { t, i18n } = useTranslation("files");
|
||||
const locale = i18n.language;
|
||||
const { isAdmin, loading: tierLoading } = useTier();
|
||||
|
||||
const statusQ = useAsync(() => api.status(name), [name]);
|
||||
const mineQ = useAsync(
|
||||
() => (isAdmin ? Promise.resolve([]) : api.myServers()),
|
||||
[isAdmin, name],
|
||||
);
|
||||
const ownershipPending = tierLoading || (!isAdmin && mineQ.data === null && !mineQ.error);
|
||||
const owned = isAdmin || (mineQ.data ?? []).some((s) => s.name === name && s.owned === true);
|
||||
const stopped = statusQ.data?.phase === "Stopped";
|
||||
|
||||
const [dir, setDir] = useState("");
|
||||
const [entries, setEntries] = useState<ServerFileEntry[] | null>(null);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const [listErr, setListErr] = useState<unknown>(null);
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
async (p: string) => {
|
||||
setListLoading(true);
|
||||
setListErr(null);
|
||||
try {
|
||||
const r = await api.listServerFiles(name, p);
|
||||
setEntries(r.entries ?? []);
|
||||
setTruncated(r.truncated === true);
|
||||
setDir(p);
|
||||
} catch (e) {
|
||||
setEntries(null);
|
||||
setListErr(e);
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
},
|
||||
[name],
|
||||
);
|
||||
|
||||
// Load (and reload after a stop) only once the viewer is resolved as owner and
|
||||
// the server is fully stopped — both are hard server-side gates of every call.
|
||||
useEffect(() => {
|
||||
if (owned && stopped) void load(dir);
|
||||
}, [owned, stopped, load]);
|
||||
|
||||
// While the server is not stopped, poll the phase so the first successful
|
||||
// stop flips the page from the notice to the listing without a manual reload.
|
||||
useEffect(() => {
|
||||
if (stopped) return;
|
||||
const id = setInterval(() => statusQ.reload(), 4000);
|
||||
return () => clearInterval(id);
|
||||
}, [stopped, statusQ.reload]);
|
||||
|
||||
// Editor state. `editable` false marks a binary file (rendered read-only).
|
||||
const [open, setOpen] = useState<{
|
||||
path: string;
|
||||
text: string;
|
||||
original: string;
|
||||
editable: boolean;
|
||||
} | null>(null);
|
||||
const [opening, setOpening] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
|
||||
async function openFile(entry: ServerFileEntry) {
|
||||
const p = joinPath(dir, entry.name);
|
||||
setOpening(p);
|
||||
setMsg(null);
|
||||
try {
|
||||
const r = await api.readServerFile(name, p);
|
||||
const text = decodeText(base64ToBytes(r.content ?? ""));
|
||||
setOpen({
|
||||
path: p,
|
||||
text: text ?? "",
|
||||
original: text ?? "",
|
||||
editable: text !== null,
|
||||
});
|
||||
} catch (e) {
|
||||
setMsg({ kind: "error", text: humanizeError(e) });
|
||||
} finally {
|
||||
setOpening(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!open || saving) return;
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await api.writeServerFile(
|
||||
name,
|
||||
open.path,
|
||||
bytesToBase64(new TextEncoder().encode(open.text)),
|
||||
);
|
||||
setMsg({ kind: "success", text: t("saved", { path: open.path }) });
|
||||
setOpen(null);
|
||||
void load(dir);
|
||||
} catch (e) {
|
||||
setMsg({ kind: "error", text: humanizeError(e) });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
if (stopping) return;
|
||||
setStopping(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await api.stop(name);
|
||||
statusQ.reload();
|
||||
} catch (e) {
|
||||
setMsg({ kind: "error", text: humanizeError(e) });
|
||||
} finally {
|
||||
setStopping(false);
|
||||
}
|
||||
}
|
||||
|
||||
const back = <BackLink to={`/servers/${name}`} label={t("back_to_console")} />;
|
||||
if (statusQ.loading && !statusQ.data) {
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<Loading />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (statusQ.error) {
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
<ErrorState error={statusQ.error} onRetry={statusQ.reload} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (!statusQ.data) return back;
|
||||
|
||||
const now = Date.now();
|
||||
const segments = dir === "" ? [] : dir.split("/");
|
||||
const dirty = open !== null && open.text !== open.original;
|
||||
const dirtyBytes = open ? new TextEncoder().encode(open.text).length : 0;
|
||||
const tooLarge = dirtyBytes > MAX_WRITE_BYTES;
|
||||
|
||||
const header = (
|
||||
<PageHeader
|
||||
icon={FolderOpen}
|
||||
title={statusQ.data.displayName || statusQ.data.name}
|
||||
subtitle={t("title")}
|
||||
actions={<PhaseBadge phase={statusQ.data.phase} />}
|
||||
className="mb-6"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
{header}
|
||||
{ownershipPending ? (
|
||||
<Loading />
|
||||
) : mineQ.error ? (
|
||||
<ErrorState error={mineQ.error} onRetry={mineQ.reload} />
|
||||
) : !owned ? (
|
||||
<NotYours title={t("not_yours_title")} body={t("not_yours_body")} />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{msg && <MessageLine kind={msg.kind} message={msg.text} />}
|
||||
{!stopped ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-14 text-center">
|
||||
<Square className="h-7 w-7 text-muted-foreground/70" />
|
||||
<div>
|
||||
<p className="font-medium">{t("stopped_required_title")}</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
|
||||
{t("stopped_required_body")}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleStop} disabled={stopping}>
|
||||
{stopping ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Square className="h-4 w-4" />
|
||||
)}
|
||||
{t("stop_server")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : listLoading && entries === null ? (
|
||||
<Loading />
|
||||
) : listErr ? (
|
||||
<ErrorState error={listErr} onRetry={() => load(dir)} />
|
||||
) : (
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
{/* Location bar: parent button + clickable breadcrumbs + refresh */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void load(parentOf(dir))}
|
||||
disabled={dir === "" || listLoading}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
{t("up")}
|
||||
</Button>
|
||||
<nav className="flex flex-wrap items-center gap-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load("")}
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 hover:bg-muted",
|
||||
dir === "" ? "font-medium text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t("root")}
|
||||
</button>
|
||||
{segments.map((seg, i) => {
|
||||
const target = segments.slice(0, i + 1).join("/");
|
||||
const last = i === segments.length - 1;
|
||||
return (
|
||||
<span key={target} className="flex items-center gap-1">
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load(target)}
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 font-mono text-xs hover:bg-muted",
|
||||
last ? "font-medium text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{seg}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void load(dir)}
|
||||
disabled={listLoading}
|
||||
title={t("refresh")}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", listLoading && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entries && entries.length === 0 ? (
|
||||
<div className="p-4">
|
||||
<EmptyState title={t("empty_dir_title")} hint={t("empty_dir_hint")} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-left text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-4 py-2.5 font-medium">{t("col_name")}</th>
|
||||
<th className="px-4 py-2.5 font-medium">{t("col_size")}</th>
|
||||
<th className="px-4 py-2.5 font-medium">{t("col_modified")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{(entries ?? []).map((e) => (
|
||||
<tr
|
||||
key={e.name}
|
||||
onClick={() => (e.is_dir ? void load(joinPath(dir, e.name)) : void openFile(e))}
|
||||
className="cursor-pointer transition-colors hover:bg-muted/30"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{e.is_dir ? (
|
||||
<Folder className="h-4 w-4 shrink-0 text-primary" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate font-mono text-xs">{e.name}</span>
|
||||
{opening === joinPath(dir, e.name) && (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">
|
||||
{e.is_dir ? "—" : formatBytes(e.size)}
|
||||
</td>
|
||||
<td
|
||||
className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground"
|
||||
title={e.mod_time}
|
||||
>
|
||||
{e.mod_time ? formatRelative(e.mod_time, now, locale) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{truncated && (
|
||||
<p className="border-t border-border px-4 py-2 text-xs text-amber-500">
|
||||
{t("list_truncated")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File editor dialog */}
|
||||
<Dialog open={open !== null} onOpenChange={(v) => !v && !saving && setOpen(null)}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="break-all font-mono text-sm">
|
||||
{open?.path}
|
||||
</DialogTitle>
|
||||
{open && !open.editable && (
|
||||
<p className="text-xs text-muted-foreground">{t("binary_hint")}</p>
|
||||
)}
|
||||
</DialogHeader>
|
||||
{open && (
|
||||
<>
|
||||
<textarea
|
||||
value={open.text}
|
||||
onChange={(e) => setOpen({ ...open, text: e.target.value })}
|
||||
readOnly={!open.editable}
|
||||
spellCheck={false}
|
||||
className="h-[50vh] w-full resize-none rounded-md border border-input bg-background p-3 font-mono text-xs leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<DialogFooter className="items-center gap-2 sm:justify-between">
|
||||
<span className={cn("text-xs", tooLarge ? "text-destructive" : "text-muted-foreground")}>
|
||||
{tooLarge
|
||||
? t("too_large", { limit: formatBytes(MAX_WRITE_BYTES) })
|
||||
: formatBytes(dirtyBytes)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(null)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !open.editable || !dirty || tooLarge}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{saving ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user