feat(files): 文件管理可新建、建目录、删除、重命名和上传,写入内容拆成多个环境变量不再超内核单变量上限

This commit is contained in:
Lemon-miaow committed 2026-09-27 19:58:28 +08:00
1 parent b6751eac0f
commit 051cc1c9f5
37 files changed
+5972 -416

No files matched your search

+225
View File
@@ -89,6 +89,8 @@ interface MockState {
passkeys: Record<AccountID, { id: string; name: string; created_at: string }[]>;
submissions: Submission[];
updateWindow: { start: string | null; end: string | null };
// Each server's world volume, seeded on first visit.
files: Record<string, MockTree>;
}
// PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock
@@ -446,6 +448,7 @@ function initialState(): MockState {
},
],
updateWindow: { start: null, end: null },
files: {},
};
}
@@ -2307,10 +2310,232 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
if (ctx.parts[4] === "access") {
return handleAccessMock(ctx, serverInfo);
}
if (ctx.parts[4] === "files" || ctx.parts[4] === "file") {
return handleFilesMock(ctx, serverInfo);
}
return false;
}
// ---- server files (handlers_files.go) ----
interface MockFileNode {
is_dir: boolean;
data: Buffer;
mod_time: string;
}
type MockTree = Map<string, MockFileNode>;
const MOCK_MAX_WRITE = 256 * 1024;
const MOCK_MAX_READ = 1024 * 1024;
const MOCK_MAX_UPLOAD = 64 * 1024 * 1024;
const MOCK_SECRET_CONFIG = "config/paper-global.yml";
const MOCK_MANAGED = new Set([MOCK_SECRET_CONFIG, "config", "server.properties"]);
// seedFiles is a Paper server's volume after a few days of play: the configs an
// owner edits, plugin jars, a world and its logs. Sizes are real-looking, the
// binary content is zeros.
function seedFiles(serverName: string): MockTree {
const tree: MockTree = new Map();
const ago = (hours: number) => new Date(Date.now() - hours * 3_600_000).toISOString();
const dir = (p: string, hours: number) => tree.set(p, { is_dir: true, data: Buffer.alloc(0), mod_time: ago(hours) });
const file = (p: string, content: string | number, hours: number) =>
tree.set(p, {
is_dir: false,
data: typeof content === "string" ? Buffer.from(content) : Buffer.alloc(content),
mod_time: ago(hours),
});
dir("", 0);
file(
"server.properties",
`#Minecraft server properties\nmotd=${serverName}\nmax-players=20\nonline-mode=false\ndifficulty=normal\ngamemode=survival\npvp=true\nview-distance=10\nspawn-protection=16\nenable-rcon=true\nrcon.password=[redacted by felis]\n`,
30,
);
file("eula.txt", "eula=true\n", 400);
file("ops.json", "[]\n", 50);
file("whitelist.json", '[\n {\n "uuid": "069a79f4-44e9-4726-a5be-fca90e38aaf5",\n "name": "Notch"\n }\n]\n', 50);
file("banned-players.json", "[]\n", 50);
file("bukkit.yml", "settings:\n allow-end: true\n warn-on-overload: true\nspawn-limits:\n monsters: 70\n", 300);
file("spigot.yml", "settings:\n debug: false\n restart-on-crash: true\n", 300);
dir("config", 300);
file(MOCK_SECRET_CONFIG, "# Do not hand-edit: felis rewrites this file on every boot.\n", 30);
file("config/paper-world-defaults.yml", "entities:\n spawning:\n per-player-mob-spawns: true\n", 300);
dir("plugins", 72);
file("plugins/LuckPerms-Bukkit-5.4.141.jar", 1_380_214, 72);
file("plugins/EssentialsX-2.20.1.jar", 3_120_876, 70);
file("plugins/Chunky-Bukkit-1.4.28.jar", 412_553, 12);
dir("plugins/LuckPerms", 72);
file("plugins/LuckPerms/config.yml", "server: global\nstorage-method: h2\n", 72);
dir("plugins/Essentials", 70);
file("plugins/Essentials/config.yml", "ops-name-color: '4'\nnickname-prefix: '~'\n", 70);
dir("world", 2);
file("world/level.dat", 2_431, 2);
file("world/session.lock", 3, 2);
dir("world/region", 2);
file("world/region/r.0.0.mca", 8_392_704, 2);
file("world/region/r.-1.0.mca", 5_246_976, 3);
file("world/region/r.0.-1.mca", 6_295_552, 26);
dir("world_nether", 26);
dir("world_the_end", 140);
dir("logs", 2);
file("logs/latest.log", `[12:00:00] [Server thread/INFO]: Starting minecraft server version 1.21.1\n[12:00:04] [Server thread/INFO]: Done (3.912s)! For help, type "help"\n`, 2);
return tree;
}
function filesOf(state: MockState, serverName: string): MockTree {
let tree = state.files[serverName];
if (!tree) {
tree = seedFiles(serverName);
state.files[serverName] = tree;
}
return tree;
}
function parentPath(p: string): string {
const i = p.lastIndexOf("/");
return i === -1 ? "" : p.slice(0, i);
}
// cleanFilePath is os.Root's containment in miniature: "." segments and empty
// ones collapse, and ".." or an absolute path is 400 bad_path. null means the
// response has been sent.
function cleanFilePath(ctx: SessionContext, raw: string | null, allowRoot = false): string | null {
if (raw === null || (raw === "" && !allowRoot)) {
sendError(ctx.res, 400, "bad_request", "path is required");
return null;
}
if (raw.startsWith("/") || raw.split("/").includes("..")) {
sendError(ctx.res, 400, "bad_path", `${raw} escapes the world root`);
return null;
}
const p = raw.split("/").filter((s) => s !== "" && s !== ".").join("/");
if (p === "" && !allowRoot) {
sendError(ctx.res, 400, "bad_path", "the world root itself cannot be changed");
return null;
}
return p;
}
function mockSha(data: Buffer): string {
return createHash("sha256").update(data).digest("hex");
}
async function readRawBody(req: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
return Buffer.concat(chunks);
}
// handleFilesMock mirrors the file routes: owner-or-admin, the stopped gate, then
// the op. Each real call is a Job that takes seconds; the pause stands in for it.
async function handleFilesMock(ctx: SessionContext, serverInfo: MockServer): Promise<boolean> {
const route = ctx.parts.slice(4).join("/");
const known = ["GET files", "GET file", "PUT file", "DELETE file", "POST files/mkdir", "POST files/rename", "PUT files/upload"];
const key = `${ctx.method} ${route}`;
const fail = (status: number, code: string, message: string): true => {
sendError(ctx.res, status, code, message);
return true;
};
if (!known.includes(key)) return false;
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
}
if (serverInfo.phase !== "Stopped") {
sendError(ctx.res, 409, "not_stopped", "stop the server before editing its files");
return true;
}
const url = new URL(ctx.req.url ?? "/", "http://localhost");
const tree = filesOf(ctx.state, serverInfo.name);
const p = cleanFilePath(ctx, url.searchParams.get("path"), key === "GET files");
if (p === null) return true;
await new Promise((r) => setTimeout(r, 350));
const now = new Date().toISOString();
const node = tree.get(p);
const parent = tree.get(parentPath(p));
switch (key) {
case "GET files": {
if (!node) return fail(404, "not_found", `${p} does not exist`);
if (!node.is_dir) return fail(400, "bad_path", `${p} is not a directory`);
const entries = [...tree.entries()]
.filter(([k]) => k !== "" && parentPath(k) === p)
.map(([k, n]) => ({ name: k.slice(p === "" ? 0 : p.length + 1), size: n.is_dir ? 4096 : n.data.length, is_dir: n.is_dir, mod_time: n.mod_time }))
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
sendJSON(ctx.res, 200, { path: p, entries, truncated: false });
return true;
}
case "GET file": {
if (p === MOCK_SECRET_CONFIG) return fail(400, "bad_path", `${p} holds the proxy forwarding secret`);
if (!node) return fail(404, "not_found", `${p} does not exist`);
if (node.is_dir) return fail(400, "bad_path", `${p} is a directory, not a file`);
if (node.data.length > MOCK_MAX_READ) return fail(413, "too_large", `${p} is larger than the editor reads`);
sendJSON(ctx.res, 200, { path: p, content: node.data.toString("base64"), sha256: mockSha(node.data) });
return true;
}
case "PUT file": {
const body = await readJSON<{ content?: string; expect_sha256?: string; create_only?: boolean }>(ctx.req);
if (typeof body.content !== "string") return fail(400, "bad_request", "content is required");
const data = Buffer.from(body.content, "base64");
if (data.length > MOCK_MAX_WRITE) return fail(413, "too_large", "the content is larger than the editor writes");
if (!parent?.is_dir) return fail(404, "not_found", `folder ${parentPath(p)} does not exist`);
if (node?.is_dir) return fail(400, "bad_path", `${p} is a directory`);
if (body.create_only && node) return fail(409, "file_exists", `${p} already exists`);
if (body.expect_sha256 && (!node || mockSha(node.data) !== body.expect_sha256)) {
return fail(409, "file_changed", `${p} changed since it was read`);
}
tree.set(p, { is_dir: false, data, mod_time: now });
sendJSON(ctx.res, 200, { path: p, status: "written", sha256: mockSha(data) });
return true;
}
case "DELETE file": {
if (!node) return fail(404, "not_found", `${p} does not exist`);
for (const k of [...tree.keys()]) if (k === p || k.startsWith(`${p}/`)) tree.delete(k);
sendJSON(ctx.res, 200, { path: p, status: "deleted" });
return true;
}
case "POST files/mkdir": {
if (!parent?.is_dir) return fail(404, "not_found", `folder ${parentPath(p)} does not exist`);
if (node) return fail(409, "file_exists", `${p} already exists`);
tree.set(p, { is_dir: true, data: Buffer.alloc(0), mod_time: now });
sendJSON(ctx.res, 200, { path: p, status: "created" });
return true;
}
case "POST files/rename": {
const body = await readJSON<{ to?: string }>(ctx.req);
const to = cleanFilePath(ctx, body.to ?? "");
if (to === null) return true;
if (!node) return fail(404, "not_found", `${p} does not exist`);
if (MOCK_MANAGED.has(p)) return fail(400, "bad_path", `${p} is managed by felis and cannot be moved or renamed`);
if (tree.has(to)) return fail(409, "file_exists", `${to} already exists`);
if (!tree.get(parentPath(to))?.is_dir) return fail(404, "not_found", `folder ${parentPath(to)} does not exist`);
for (const [k, n] of [...tree.entries()]) {
if (k === p || k.startsWith(`${p}/`)) {
tree.delete(k);
tree.set(to + k.slice(p.length), n);
}
}
sendJSON(ctx.res, 200, { path: p, to, status: "renamed" });
return true;
}
case "PUT files/upload": {
const declared = Number(ctx.req.headers["content-length"] ?? NaN);
if (Number.isNaN(declared)) return fail(411, "length_required", "an upload needs a Content-Length");
if (declared > MOCK_MAX_UPLOAD) return fail(413, "too_large", "uploads are at most 64 MiB");
const data = await readRawBody(ctx.req);
if (!parent?.is_dir) return fail(404, "not_found", `folder ${parentPath(p)} does not exist`);
if (node?.is_dir) return fail(400, "bad_path", `${p} is a directory`);
if (node && url.searchParams.get("overwrite") !== "true") return fail(409, "file_exists", `${p} already exists`);
// Landing takes the Job a moment after the body is in.
await new Promise((r) => setTimeout(r, 900));
tree.set(p, { is_dir: false, data, mod_time: now });
sendJSON(ctx.res, 200, { path: p, status: "uploaded", sha256: mockSha(data), size: data.length });
return true;
}
}
return false;
}
// handleRestoreBackupMock mirrors the backend's restore authorization order
// (handlers_backups.go): owner-or-admin → specific backup by id or latest present
// backup else 404 no_backup → non-admin former-owner match → stopped gate else
+137
View File
@@ -0,0 +1,137 @@
import { useRef, useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { MessageLine } from "@/components/MessageLine";
import { humanizeError } from "@/lib/api";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
label: string;
confirmLabel: string;
/** What the field starts with. A rename starts from the old name, selected up
* to its extension so typing replaces just the name. */
initial?: string;
/** Why a name cannot be used, as a sentence, or null when it can. */
problem: (name: string) => string | null;
/** Runs the action with the name less surrounding spaces. The dialog closes
* when it resolves; a rejection is shown in the dialog, which stays open. */
onSubmit: (name: string) => Promise<void>;
}
// NameDialog asks for one file or folder name. The problem with a name shows as
// soon as something has been typed, so the button is never disabled without a
// reason on screen; the server's own refusal lands in the same place. Mount it
// afresh for each question (a key per opening): the field starts from `initial`.
export function NameDialog({
open,
onOpenChange,
title,
description,
label,
confirmLabel,
initial = "",
problem,
onSubmit,
}: Props) {
const { t } = useTranslation("common");
const [value, setValue] = useState(initial);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const issue = problem(value);
const unchanged = initial !== "" && value.trim() === initial;
// An untouched empty field has nothing wrong with it yet; saying so before the
// first keystroke would greet the dialog with an error.
const shownIssue = value === "" && initial === "" ? null : issue;
function setOpen(next: boolean) {
if (busy) return;
onOpenChange(next);
}
// The submit button is disabled whenever the name cannot be sent, and a form
// whose default button is disabled does not submit on Enter either.
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
await onSubmit(value.trim());
setBusy(false);
onOpenChange(false);
} catch (err) {
setError(humanizeError(err));
setBusy(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
onOpenAutoFocus={(e) => {
e.preventDefault();
const input = inputRef.current;
if (!input) return;
input.focus();
const dot = initial.lastIndexOf(".");
input.setSelectionRange(0, dot > 0 ? dot : initial.length);
}}
>
<form onSubmit={(e) => void submit(e)} className="grid gap-4">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<div className="grid gap-2">
<Label htmlFor="file-name">{label}</Label>
<Input
id="file-name"
ref={inputRef}
value={value}
onChange={(e) => {
setValue(e.target.value);
setError(null);
}}
disabled={busy}
autoComplete="off"
spellCheck={false}
aria-invalid={shownIssue !== null}
aria-describedby={shownIssue ? "file-name-problem" : undefined}
className="font-mono"
/>
{shownIssue && (
<p id="file-name-problem" className="text-xs text-destructive">
{shownIssue}
</p>
)}
</div>
{error && <MessageLine kind="error" message={error} compact />}
<DialogFooter>
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(false)} disabled={busy}>
{t("cancel")}
</Button>
<Button type="submit" size="sm" disabled={busy || issue !== null || unchanged}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
{confirmLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+185
View File
@@ -0,0 +1,185 @@
import { useTranslation } from "react-i18next";
import { AlertCircle, AlertTriangle, CheckCircle2, Clock, Loader2, RotateCw, Upload, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { formatBytes } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { UploadItem } from "./useUploads";
interface Props {
items: readonly UploadItem[];
/** The folder on screen: an upload headed elsewhere names its folder. */
dir: string;
onReplace: (id: number) => void;
onRetry: (id: number) => void;
onRemove: (id: number) => void;
onReplaceAll: () => void;
onSkipAll: () => void;
onClearDone: () => void;
}
// UploadQueue shows each upload and what it waits on. A file already there asks
// replace or skip on its own row, so a batch never overwrites anything unasked.
export function UploadQueue({ items, dir, onReplace, onRetry, onRemove, onReplaceAll, onSkipAll, onClearDone }: Props) {
const { t } = useTranslation("files");
if (items.length === 0) return null;
const done = items.filter((it) => it.state === "done").length;
const asking = items.filter((it) => it.state === "exists").length;
return (
<section aria-label={t("uploads_label")} className="border-b border-border bg-muted/20">
<div className="flex flex-wrap items-center gap-2 px-4 pt-3 pb-2">
<Upload className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium">{t("uploads_title", { done, total: items.length })}</p>
<div className="ml-auto flex flex-wrap items-center gap-1">
{asking > 1 && (
<>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={onReplaceAll}>
{t("upload_replace_all")}
</Button>
<Button size="sm" variant="ghost" className="h-7 text-xs" onClick={onSkipAll}>
{t("upload_skip_all")}
</Button>
</>
)}
{done > 0 && (
<Button size="sm" variant="ghost" className="h-7 text-xs" onClick={onClearDone}>
{t("upload_clear_done")}
</Button>
)}
</div>
</div>
<ul className="max-h-72 divide-y divide-border/60 overflow-y-auto px-4 pb-2">
{items.map((it) => (
<UploadRow
key={it.id}
item={it}
elsewhere={it.dir !== dir}
onReplace={() => onReplace(it.id)}
onRetry={() => onRetry(it.id)}
onRemove={() => onRemove(it.id)}
/>
))}
</ul>
</section>
);
}
function UploadRow({
item,
elsewhere,
onReplace,
onRetry,
onRemove,
}: {
item: UploadItem;
elsewhere: boolean;
onReplace: () => void;
onRetry: () => void;
onRemove: () => void;
}) {
const { t } = useTranslation("files");
const { file, state } = item;
const percent = file.size > 0 ? Math.min(100, Math.floor((item.sent * 100) / file.size)) : 100;
// The body is all sent and the Job is landing it: seconds more, with no bytes
// left to count.
const landing = state === "uploading" && item.sent >= file.size;
const icon = {
queued: <Clock className="h-4 w-4 text-muted-foreground" />,
uploading: <Loader2 className="h-4 w-4 animate-spin text-primary" />,
done: <CheckCircle2 className="h-4 w-4 text-emerald-500" />,
exists: <AlertTriangle className="h-4 w-4 text-amber-500" />,
failed: <AlertCircle className="h-4 w-4 text-destructive" />,
}[state];
let status: string;
if (state === "queued") status = t("upload_queued");
else if (landing) status = t("upload_landing");
else if (state === "uploading") status = t("upload_sending", { sent: formatBytes(item.sent), total: formatBytes(file.size), percent });
else if (state === "done") status = t("upload_done");
else if (state === "exists") status = t("upload_exists");
else status = item.error ?? "";
return (
<li className="py-2">
<div className="flex items-start gap-2.5">
<span className="mt-0.5 shrink-0">{icon}</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2">
<span className="truncate font-mono text-xs" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground tabular-nums">{formatBytes(file.size)}</span>
</div>
{elsewhere && (
<p className="truncate font-mono text-[11px] text-muted-foreground">
→ {item.dir === "" ? t("root") : `${item.dir}/`}
</p>
)}
<p
className={cn(
"mt-0.5 text-xs tabular-nums",
state === "failed"
? "text-destructive"
: state === "exists"
? "text-amber-600 dark:text-amber-400"
: "text-muted-foreground",
)}
>
{status}
</p>
{state === "uploading" && (
<div
role="progressbar"
aria-label={t("upload_progress_label", { name: file.name })}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
className="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-muted"
>
<div
className={cn(
"h-full rounded-full bg-primary transition-[width] duration-300 ease-out",
landing && "animate-pulse",
)}
style={{ width: `${percent}%` }}
/>
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{state === "exists" && (
<>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={onReplace}>
{t("upload_replace")}
</Button>
<Button size="sm" variant="ghost" className="h-7 text-xs" onClick={onRemove}>
{t("upload_skip")}
</Button>
</>
)}
{state === "failed" && item.retryable && (
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={onRetry}>
<RotateCw className="h-3.5 w-3.5" />
{t("upload_retry")}
</Button>
)}
{/* Once the body is all sent the Job may already be landing it, and
a cancel could no longer say whether the file changed. */}
{state !== "exists" && state !== "done" && !landing && (
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={onRemove}
aria-label={t(state === "failed" ? "upload_dismiss" : "upload_cancel", { name: file.name })}
title={t(state === "failed" ? "upload_dismiss" : "upload_cancel", { name: file.name })}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</li>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect } from "vitest";
import { isManaged, joinPath, nameProblem, parentOf, sortEntries } from "./names";
import type { ServerFileEntry } from "@/lib/types";
const entry = (name: string, is_dir = false): ServerFileEntry => ({ name, size: 1, is_dir, mod_time: "2026-09-01T00:00:00Z" });
describe("joinPath and parentOf", () => {
it("join at the root without a leading slash, and walk back to it", () => {
expect(joinPath("", "world")).toBe("world");
expect(joinPath("world/region", "r.0.0.mca")).toBe("world/region/r.0.0.mca");
expect(parentOf("world/region")).toBe("world");
expect(parentOf("world")).toBe("");
});
});
describe("isManaged", () => {
it("names exactly the paths the read path guards by name", () => {
expect(isManaged("server.properties")).toBe(true);
expect(isManaged("config")).toBe(true);
expect(isManaged("config/paper-global.yml")).toBe(true);
// The same names anywhere else are the owner's own.
expect(isManaged("backup/server.properties")).toBe(false);
expect(isManaged("config/paper-world-defaults.yml")).toBe(false);
expect(isManaged("plugins/config")).toBe(false);
});
});
describe("nameProblem", () => {
const here = [entry("world", true), entry("eula.txt")];
it("takes a free name, less the spaces around it", () => {
expect(nameProblem(" notes.txt ", here)).toBeNull();
});
it("asks for a name when there is none but spaces", () => {
expect(nameProblem("", here)).toBe("name_required");
expect(nameProblem(" ", here)).toBe("name_required");
});
it("refuses a slash, which would reach into another folder", () => {
expect(nameProblem("config/x", here)).toBe("name_slash");
});
it("refuses the names that mean this folder or its parent", () => {
expect(nameProblem(".", here)).toBe("name_dots");
expect(nameProblem(" .. ", here)).toBe("name_dots");
expect(nameProblem("...", here)).toBeNull();
expect(nameProblem(".hidden", here)).toBeNull();
});
it("counts the length in bytes, as the filesystem does", () => {
expect(nameProblem("a".repeat(255), here)).toBeNull();
expect(nameProblem("a".repeat(256), here)).toBe("name_too_long");
// 85 three-byte characters are 255 bytes; one more is over.
expect(nameProblem("界".repeat(85), here)).toBeNull();
expect(nameProblem("界".repeat(86), here)).toBe("name_too_long");
});
it("refuses a name already in the folder, file or folder alike", () => {
expect(nameProblem("eula.txt", here)).toBe("name_taken");
expect(nameProblem(" world", here)).toBe("name_taken");
expect(nameProblem("World", here)).toBeNull();
});
it("lets a rename keep its own name, and no other one there", () => {
expect(nameProblem("world", here, "world")).toBeNull();
expect(nameProblem("eula.txt", here, "world")).toBe("name_taken");
});
it("checks nothing against a folder not listed yet", () => {
expect(nameProblem("eula.txt", null)).toBeNull();
});
});
describe("sortEntries", () => {
it("puts folders first, then orders names by their numbers and without case", () => {
const listed = [
entry("r.10.0.mca"),
entry("world", true),
entry("Banned-players.json"),
entry("r.2.0.mca"),
entry("logs", true),
entry("apple.txt"),
];
expect(sortEntries(listed).map((e) => e.name)).toEqual([
"logs",
"world",
"apple.txt",
"Banned-players.json",
"r.2.0.mca",
"r.10.0.mca",
]);
// The listing it was given stays as the server sent it.
expect(listed[0].name).toBe("r.10.0.mca");
});
});
+57
View File
@@ -0,0 +1,57 @@
import type { ServerFileEntry } from "@/lib/types";
export function joinPath(dir: string, name: string): string {
return dir === "" ? name : `${dir}/${name}`;
}
export function parentOf(dir: string): string {
const i = dir.lastIndexOf("/");
return i === -1 ? "" : dir.slice(0, i);
}
/** The file whose platform secret the read path refuses outright
* (fileedit.secretConfigPath). */
export const SECRET_CONFIG_PATH = "config/paper-global.yml";
/** The paths the read path guards by name (fileedit.guardedPaths): under another
* name it would hand back what it withholds, so the server refuses to move them
* (400 bad_path) and the page does not offer to. */
const MANAGED_PATHS = new Set([SECRET_CONFIG_PATH, "config", "server.properties"]);
export function isManaged(path: string): boolean {
return MANAGED_PATHS.has(path);
}
/** The longest name a Linux filesystem takes, in bytes (NAME_MAX). */
const NAME_MAX = 255;
export type NameProblem = "name_required" | "name_slash" | "name_dots" | "name_too_long" | "name_taken";
/** nameProblem says why name cannot be used for a new entry in a folder listing
* `entries`, or null when it can. `current` is the entry being renamed, whose own
* name is not a clash. The name is taken as typed less surrounding spaces, which
* is what the page sends. */
export function nameProblem(
raw: string,
entries: readonly ServerFileEntry[] | null,
current?: string,
): NameProblem | null {
const name = raw.trim();
if (name === "") return "name_required";
if (name.includes("/")) return "name_slash";
if (name === "." || name === "..") return "name_dots";
if (new TextEncoder().encode(name).length > NAME_MAX) return "name_too_long";
if (name !== current && entries?.some((e) => e.name === name)) return "name_taken";
return null;
}
/** sortEntries puts folders first, then orders names the way people count
* ("r.2.0" before "r.10.0"), with case second to the letters ("apple" before
* "Banned"). The server lists in byte order. */
export function sortEntries(entries: readonly ServerFileEntry[]): ServerFileEntry[] {
return [...entries].sort(
(a, b) =>
Number(b.is_dir) - Number(a.is_dir) ||
a.name.localeCompare(b.name, undefined, { numeric: true }),
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef, useState } from "react";
import i18next from "i18next";
import { api, humanizeError } from "@/lib/api";
import { formatBytes } from "@/lib/format";
import type { ServerFileEntry } from "@/lib/types";
import { joinPath } from "./names";
/** The upload ceiling, mirrored from fileedit.MaxUploadBytes (server truth, 413
* above it). Checked here so a file too large is refused before it is sent. */
export const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
/** queued waits its turn; exists found a file already at its path and waits for
* replace or skip; failed stays until it is retried or dismissed. */
export type UploadState = "queued" | "uploading" | "done" | "exists" | "failed";
export interface UploadItem {
id: number;
file: File;
/** The folder it lands in, fixed when it was added. */
dir: string;
state: UploadState;
sent: number;
overwrite: boolean;
error: string | null;
/** false for a refusal sending again cannot change (too large, a folder there). */
retryable: boolean;
}
// useUploads runs a queue of uploads into a server's world volume, one at a time:
// each is a file Job that holds the world lock, so a second one sent alongside
// would only be refused with 409 maintenance_in_progress. onLanded runs after
// each upload that landed, with the folder it landed in.
export function useUploads(server: string, onLanded: (dir: string) => void) {
const [items, setItems] = useState<UploadItem[]>([]);
const nextId = useRef(1);
const running = useRef<number | null>(null);
const abort = useRef<AbortController | null>(null);
const landed = useRef(onLanded);
landed.current = onLanded;
const patch = useCallback((id: number, change: Partial<UploadItem>) => {
setItems((all) => all.map((it) => (it.id === id ? { ...it, ...change } : it)));
}, []);
const drop = useCallback((id: number) => {
setItems((all) => all.filter((it) => it.id !== id));
}, []);
// Leaving the page stops the upload in flight; the queued ones were never sent.
useEffect(() => () => abort.current?.abort(), []);
useEffect(() => {
if (running.current !== null) return;
const next = items.find((it) => it.state === "queued");
if (!next) return;
running.current = next.id;
const ctrl = new AbortController();
abort.current = ctrl;
patch(next.id, { state: "uploading", sent: 0, error: null });
const settle = () => {
running.current = null;
abort.current = null;
};
api
.uploadServerFile(server, joinPath(next.dir, next.file.name), next.file, next.overwrite, {
signal: ctrl.signal,
onProgress: (sent) => patch(next.id, { sent }),
})
.then(
() => {
settle();
patch(next.id, { state: "done", sent: next.file.size });
landed.current(next.dir);
},
(e: unknown) => {
settle();
if (e instanceof DOMException && e.name === "AbortError") {
drop(next.id);
} else if ((e as { code?: string }).code === "file_exists") {
// Someone put a file there since the listing: ask, as for one listed.
patch(next.id, { state: "exists", sent: 0 });
} else {
patch(next.id, { state: "failed", sent: 0, error: humanizeError(e), retryable: true });
}
},
);
}, [items, server, patch, drop]);
/** add queues files for dir. `entries` is dir's listing: a file of the same
* name there waits for replace or skip instead of being sent to be refused. */
const add = useCallback((files: readonly File[], dir: string, entries: readonly ServerFileEntry[] | null) => {
const t = i18next.getFixedT(null, "files");
const added = files.map((file): UploadItem => {
const base = { id: nextId.current++, file, dir, sent: 0, overwrite: false, error: null, retryable: false };
const there = entries?.find((e) => e.name === file.name);
if (file.size > MAX_UPLOAD_BYTES) {
return { ...base, state: "failed", error: t("upload_too_large", { limit: formatBytes(MAX_UPLOAD_BYTES) }) };
}
if (there?.is_dir) {
return { ...base, state: "failed", error: t("upload_folder_there") };
}
return { ...base, state: there ? "exists" : "queued" };
});
setItems((all) => [...all, ...added]);
}, []);
const replace = useCallback((id: number) => patch(id, { state: "queued", overwrite: true }), [patch]);
const retry = useCallback((id: number) => patch(id, { state: "queued", error: null }), [patch]);
/** remove cancels an upload in flight, or takes any other one off the list. */
const remove = useCallback(
(id: number) => {
if (running.current === id) abort.current?.abort();
else drop(id);
},
[drop],
);
const replaceAll = useCallback(() => {
setItems((all) => all.map((it) => (it.state === "exists" ? { ...it, state: "queued", overwrite: true } : it)));
}, []);
const skipAll = useCallback(() => setItems((all) => all.filter((it) => it.state !== "exists")), []);
const clearDone = useCallback(() => setItems((all) => all.filter((it) => it.state !== "done")), []);
const busy = items.some((it) => it.state === "queued" || it.state === "uploading");
return { items, busy, add, replace, retry, remove, replaceAll, skipAll, clearDone };
}
+6 -2
View File
@@ -24,11 +24,11 @@
"luckperms_missing": "LuckPerms isn't installed on this server, so permission and group changes have no effect. For the lobby, ask the operator to re-run the installer; any other server needs the LuckPerms plugin added first.",
"no_backup": "There's no restorable backup for this server yet.",
"backup_corrupt": "This backup failed its read-back check and can't be restored intact — pick another backup.",
"not_stopped": "Stop the server completely before restoring — a restore overwrites the live world volume.",
"not_stopped": "Stop the server completely first — this changes its world volume, which the running server holds.",
"maintenance_in_progress": "This server's world is busy with a restore, backup, file write or idle reclaim — try again once it finishes. A restore, backup or file write usually takes a minute or two; an idle reclaim can take longer on a large world.",
"no_world_volume": "This server has no world volume yet — start it once so it is created, then retry.",
"file_changed": "This file changed after you opened it (another manager saved it, or the server rewrote it on its last run), so your save was not written, to keep that change.",
"volume_full": "The server's volume is full, so the save was not written and the file is unchanged. Ask an admin to grow or clean up this server's volume.",
"volume_full": "The server's volume is full, so the change was not written and the files there are unchanged. Delete files it no longer needs, or ask an admin to grow its volume.",
"backup_cooldown": "This server was backed up moments ago, and manual backups have a cooldown — try again in a few minutes.",
"backup_store_full": "The backup store is full, so manual backups are paused — ask an administrator to free space.",
"restore_unavailable": "Restore isn't available right now — try again later.",
@@ -63,6 +63,10 @@
"too_large": "That is larger than the size limit.",
"files_timeout": "The file operation timed out — try again shortly.",
"files_unavailable": "File editing isn't available right now.",
"file_exists": "Something with that name is already there. Pick another name, or rename or delete the one that is there first.",
"upload_staging_full": "The panel's upload space is nearly full right now, so the file was not passed on. Try again later, or ask an admin to free space on the uploads volume.",
"upload_incomplete": "The upload stopped before the whole file arrived, so nothing was changed. Try again.",
"length_required": "The upload did not say how large it is, so it was refused. Upload it again from the panel.",
"jobs_unavailable": "The background job service isn't available right now.",
"already_terminal": "This build already finished — there is nothing to cancel.",
"build_unavailable": "Image builds aren't available right now.",
+51 -2
View File
@@ -10,7 +10,7 @@
"col_size": "Size",
"col_modified": "Modified",
"empty_dir_title": "This folder is empty",
"empty_dir_hint": "Nothing has been written here yet.",
"empty_dir_hint": "Drop files here to upload them, or make a file or folder with the buttons above.",
"list_truncated": "The listing was truncated at the server's safety limit.",
"stopped_required_title": "Stop the server first",
"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.",
@@ -30,5 +30,54 @@
"discard_prompt": "You have unsaved changes. Discard them?",
"discard": "Discard",
"keep_editing": "Keep editing",
"status_refresh_failed": "Could not refresh the server's status, so what you see may be out of date: {{reason}}"
"status_refresh_failed": "Could not refresh the server's status, so what you see may be out of date: {{reason}}",
"col_actions": "Actions",
"new_file": "New file",
"new_folder": "New folder",
"upload": "Upload",
"upload_hint": "Upload files into this folder (up to {{limit}} each), or drop them onto the list",
"in_folder": "In {{dir}}",
"name_label": "Name",
"create": "Create",
"rename": "Rename",
"rename_title": "Rename {{name}}",
"rename_item": "Rename {{name}}",
"delete_item": "Delete {{name}}",
"managed_no_rename": "Felis manages this one, so it keeps its name and place.",
"wait_for_uploads": "Wait for the uploads to finish first.",
"name_required": "Enter a name.",
"name_slash": "A name cannot contain “/”.",
"name_dots": "“.” and “..” cannot be used as names.",
"name_too_long": "That name is too long.",
"name_taken": "Something here already has that name.",
"folder_created": "Created folder {{path}}",
"renamed": "Renamed {{from}} to {{to}}",
"deleted": "Deleted {{path}}",
"delete": "Delete",
"delete_file_title": "Delete {{name}}?",
"delete_folder_title": "Delete the folder {{name}}?",
"delete_file_body": "The file is deleted for good. Take a backup first if you may want it back.",
"delete_folder_body": "The folder and everything in it are deleted for good. Take a backup first if you may want any of it back.",
"secret_config_unreadable": "config/paper-global.yml holds the proxy forwarding secret every server shares, so the editor does not open it.",
"drop_here": "Drop to upload into {{dir}}",
"upload_no_folders_one": "A folder cannot be uploaded, so it was skipped. Make the folder here, then upload the files inside it.",
"upload_no_folders_other": "Folders cannot be uploaded, so {{count}} were skipped. Make the folders here, then upload the files inside them.",
"uploads_label": "Uploads",
"uploads_title": "Uploads · {{done}} of {{total}} done",
"upload_queued": "Waiting",
"upload_sending": "{{sent}} of {{total}} · {{percent}}%",
"upload_landing": "Writing it to the server…",
"upload_done": "Uploaded",
"upload_exists": "A file with this name is already here.",
"upload_replace": "Replace",
"upload_skip": "Skip",
"upload_replace_all": "Replace all",
"upload_skip_all": "Skip all",
"upload_retry": "Retry",
"upload_cancel": "Cancel uploading {{name}}",
"upload_dismiss": "Dismiss {{name}}",
"upload_clear_done": "Clear finished",
"upload_progress_label": "Uploading {{name}}",
"upload_too_large": "Larger than the {{limit}} upload limit.",
"upload_folder_there": "A folder with this name is already here."
}
+6 -2
View File
@@ -24,11 +24,11 @@
"luckperms_missing": "这台服务器没有装 LuckPerms,权限和用户组的改动不会生效。大厅请让运维重跑安装脚本来补上;其他服务器需要先装 LuckPerms 插件。",
"no_backup": "这台服务器暂时没有可回档的备份。",
"backup_corrupt": "这份备份回读校验未通过,已无法完整恢复——请选择另一份备份。",
"not_stopped": "回档会覆盖世界的实时存储卷,请先把服务器完全停止再回档。",
"not_stopped": "请先把服务器完全停止——这项操作要改动世界存储卷,运行中的服务器独占着它。",
"maintenance_in_progress": "这台服务器的世界正在回档、备份、写入文件或闲置回收——等它完成后再试。回档、备份和写文件通常一两分钟,闲置回收视世界大小可能更久。",
"no_world_volume": "这台服务器还没有世界卷——先启动一次让它创建,然后再试。",
"file_changed": "这个文件在你打开之后被改过了(另一位管理者保存过,或者服务器上次运行时改写了它)。为了不覆盖那次修改,这次保存没有写入。",
"volume_full": "服务器的存储卷已满,这次保存没有写入,原文件保持不变。请联系管理员扩容或清理这台服务器的数据。",
"volume_full": "服务器的存储卷已满,这次改动没有写入,原有文件保持不变。删掉用不着的文件,或者请管理员给它扩容。",
"backup_cooldown": "这台服务器刚备份过,手动备份之间有冷却时间——请过几分钟再试。",
"backup_store_full": "备份存储已满,暂时无法手动备份——请联系管理员清理空间。",
"restore_unavailable": "回档功能当前不可用,请稍后再试。",
@@ -63,6 +63,10 @@
"too_large": "内容超出大小限制,无法处理。",
"files_timeout": "文件操作超时——请稍后重试。",
"files_unavailable": "文件功能当前不可用。",
"file_exists": "这里已经有同名的文件或文件夹。换个名字,或者先把原来那个改名或删除。",
"upload_staging_full": "面板的上传暂存空间快满了,这个文件没有转存过去。稍后再试,或者请管理员清理上传卷。",
"upload_incomplete": "文件还没传完上传就中断了,什么都没有改动。请重试。",
"length_required": "这次上传没有声明文件大小,被拒绝了。请从面板重新上传。",
"jobs_unavailable": "后台任务服务当前不可用。",
"already_terminal": "该构建已经结束,无法重复取消。",
"build_unavailable": "构建功能当前不可用。",
+50 -2
View File
@@ -10,7 +10,7 @@
"col_size": "大小",
"col_modified": "修改时间",
"empty_dir_title": "此目录为空",
"empty_dir_hint": "这里还没有写入任何内容。",
"empty_dir_hint": "把文件拖到这里上传,或者用上方的按钮新建文件、文件夹。",
"list_truncated": "列表已按服务器安全上限截断。",
"stopped_required_title": "需要先停服",
"stopped_required_body": "只有在服务器完全停止后才能浏览或编辑文件——世界卷被运行中的服务器独占挂载。请先停止服务器。",
@@ -30,5 +30,53 @@
"discard_prompt": "有未保存的修改,要放弃吗?",
"discard": "放弃修改",
"keep_editing": "继续编辑",
"status_refresh_failed": "刷新服务器状态失败,页面显示的可能不是最新状态:{{reason}}"
"status_refresh_failed": "刷新服务器状态失败,页面显示的可能不是最新状态:{{reason}}",
"col_actions": "操作",
"new_file": "新建文件",
"new_folder": "新建文件夹",
"upload": "上传",
"upload_hint": "上传文件到这个文件夹(每个最大 {{limit}}),也可以直接拖到列表上",
"in_folder": "位置:{{dir}}",
"name_label": "名称",
"create": "创建",
"rename": "重命名",
"rename_title": "重命名 {{name}}",
"rename_item": "重命名 {{name}}",
"delete_item": "删除 {{name}}",
"managed_no_rename": "这个由 Felis 管理,名称和位置都要保持不变。",
"wait_for_uploads": "等上传完成后再操作。",
"name_required": "请输入名称。",
"name_slash": "名称里不能有“/”。",
"name_dots": "“.” 和 “..” 不能用作名称。",
"name_too_long": "名称太长了。",
"name_taken": "这里已经有同名的文件或文件夹。",
"folder_created": "已新建文件夹 {{path}}",
"renamed": "已把 {{from}} 重命名为 {{to}}",
"deleted": "已删除 {{path}}",
"delete": "删除",
"delete_file_title": "删除 {{name}}?",
"delete_folder_title": "删除文件夹 {{name}}?",
"delete_file_body": "文件会被永久删除。之后可能还要用的话,先做一次备份。",
"delete_folder_body": "文件夹和里面的所有内容都会被永久删除。之后可能还要用的话,先做一次备份。",
"secret_config_unreadable": "config/paper-global.yml 里有所有服务器共用的代理转发密钥,编辑器不打开它。",
"drop_here": "松开即可上传到 {{dir}}",
"upload_no_folders": "文件夹没法直接上传,已跳过 {{count}} 个。先在这里新建同名文件夹,再上传里面的文件。",
"uploads_label": "上传",
"uploads_title": "上传 · 已完成 {{done}}/{{total}}",
"upload_queued": "等待中",
"upload_sending": "{{sent}} / {{total}} · {{percent}}%",
"upload_landing": "正在写入服务器…",
"upload_done": "已上传",
"upload_exists": "这里已经有同名文件。",
"upload_replace": "替换",
"upload_skip": "跳过",
"upload_replace_all": "全部替换",
"upload_skip_all": "全部跳过",
"upload_retry": "重试",
"upload_cancel": "取消上传 {{name}}",
"upload_dismiss": "移除 {{name}}",
"upload_clear_done": "清除已完成",
"upload_progress_label": "正在上传 {{name}}",
"upload_too_large": "超过 {{limit}} 的上传上限。",
"upload_folder_there": "这里已经有同名文件夹。"
}
+118
View File
@@ -1331,6 +1331,115 @@ describe("chunked context upload", () => {
});
});
// The file manager's changes: each is one route, and what the server takes from
// it is the query path plus a small JSON body (an upload, the raw bytes).
describe("server file manager wire shapes", () => {
beforeEach(() => {
vi.restoreAllMocks();
FakeXHR.last = undefined;
vi.stubGlobal("XMLHttpRequest", FakeXHR);
});
afterEach(() => vi.unstubAllGlobals());
function sent(fetchSpy: typeof fetch): [string, RequestInit] {
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
return [String(url), opts as RequestInit];
}
it("createServerFile PUTs the content with create_only, so nothing already there is replaced", async () => {
const fetchSpy = fakeFetch({ path: "plugins/new.yml", status: "written", sha256: "c".repeat(64) });
vi.stubGlobal("fetch", fetchSpy);
expect(await api.createServerFile("survival", "plugins/new.yml", "")).toEqual({
path: "plugins/new.yml",
status: "written",
sha256: "c".repeat(64),
});
const [url, opts] = sent(fetchSpy);
expect(url).toBe("/servers/survival/file?path=plugins%2Fnew.yml");
expect(opts.method).toBe("PUT");
expect(opts.body).toBe(JSON.stringify({ content: "", create_only: true }));
});
it("deleteServerFile DELETEs /servers/{name}/file with no body", async () => {
const fetchSpy = fakeFetch({ path: "logs", status: "deleted" });
vi.stubGlobal("fetch", fetchSpy);
expect(await api.deleteServerFile("survival", "old logs")).toEqual({ path: "logs", status: "deleted" });
const [url, opts] = sent(fetchSpy);
expect(url).toBe("/servers/survival/file?path=old%20logs");
expect(opts.method).toBe("DELETE");
expect(opts.body).toBeUndefined();
});
it("mkdirServerFolder POSTs /servers/{name}/files/mkdir with the path in the query", async () => {
const fetchSpy = fakeFetch({ path: "plugins/Chunky", status: "created" });
vi.stubGlobal("fetch", fetchSpy);
expect(await api.mkdirServerFolder("survival", "plugins/Chunky")).toEqual({ path: "plugins/Chunky", status: "created" });
const [url, opts] = sent(fetchSpy);
expect(url).toBe("/servers/survival/files/mkdir?path=plugins%2FChunky");
expect(opts.method).toBe("POST");
expect(opts.body).toBeUndefined();
});
it("renameServerFile POSTs the old path in the query and the new one as {to}", async () => {
const fetchSpy = fakeFetch({ path: "world", to: "world_old", status: "renamed" });
vi.stubGlobal("fetch", fetchSpy);
expect(await api.renameServerFile("survival", "world", "world old")).toEqual({
path: "world",
to: "world_old",
status: "renamed",
});
const [url, opts] = sent(fetchSpy);
expect(url).toBe("/servers/survival/files/rename?path=world");
expect(opts.method).toBe("POST");
expect(opts.body).toBe(JSON.stringify({ to: "world old" }));
});
it("uploadServerFile PUTs the raw bytes with the session cookie and reports progress", async () => {
const file = new Blob(["jar bytes"]);
const seen: number[] = [];
const done = api.uploadServerFile("survival", "plugins/Chunky 1.4.jar", file, false, {
onProgress: (n) => seen.push(n),
});
const xhr = await sentXHR();
expect(xhr.method).toBe("PUT");
expect(xhr.url).toBe("/servers/survival/files/upload?path=plugins%2FChunky%201.4.jar");
expect(xhr.withCredentials).toBe(true);
expect(xhr.headers).toEqual({ "Content-Type": "application/octet-stream" });
expect(xhr.body).toBe(file);
xhr.upload.onprogress?.({ loaded: 4 });
xhr.respond(200, JSON.stringify({ path: "plugins/Chunky 1.4.jar", status: "uploaded", sha256: "d".repeat(64), size: 9 }));
expect(await done).toEqual({ path: "plugins/Chunky 1.4.jar", status: "uploaded", sha256: "d".repeat(64), size: 9 });
expect(seen).toEqual([4]);
});
it("uploadServerFile asks to replace only when told to overwrite", async () => {
const done = api.uploadServerFile("survival", "server-icon.png", new Blob(["png"]), true);
const xhr = await sentXHR();
expect(xhr.url).toBe("/servers/survival/files/upload?path=server-icon.png&overwrite=true");
xhr.respond(200, JSON.stringify({ path: "server-icon.png", status: "uploaded", sha256: "e".repeat(64), size: 3 }));
await done;
});
it("an upload refused because the file is there reads as file_exists", async () => {
const done = api.uploadServerFile("survival", "server-icon.png", new Blob(["png"]), false);
(await sentXHR()).respond(
409,
JSON.stringify({ error: { code: "file_exists", message: "something is already at server-icon.png" } }),
"Conflict",
);
await expect(done).rejects.toEqual({ status: 409, code: "file_exists", message: "something is already at server-icon.png" });
});
it("refuses a server name that is not one path segment before sending anything", async () => {
const fetchSpy = fakeFetch({});
vi.stubGlobal("fetch", fetchSpy);
await expect(api.renameServerFile("..", "a", "b")).rejects.toMatchObject({ code: "bad_path_param" });
await expect(api.uploadServerFile(".", "x", new Blob(["x"]), false)).rejects.toMatchObject({ code: "bad_path_param" });
expect(fetchSpy).not.toHaveBeenCalled();
expect(FakeXHR.last).toBeUndefined();
});
});
// The API's generic codes carry an English developer message ("user not found",
// "invalid request"); the panel words them itself so a Chinese UI never shows it.
describe("copy for the generic server codes", () => {
@@ -1352,6 +1461,15 @@ describe("copy for the generic server codes", () => {
expect(humanizeError({ status: 400, code: "bad_request", message: "" })).toBe("Something went wrong.");
});
it("words the file manager's refusals itself", () => {
expect(humanizeError({ status: 409, code: "file_exists", message: "something is already at a.txt" })).toBe(
"Something with that name is already there. Pick another name, or rename or delete the one that is there first.",
);
expect(humanizeError({ status: 507, code: "upload_staging_full", message: "raw" })).toMatch(/upload space is nearly full/);
expect(humanizeError({ status: 400, code: "upload_incomplete", message: "raw" })).toMatch(/stopped before the whole file arrived/);
expect(humanizeError({ status: 411, code: "length_required", message: "raw" })).toMatch(/did not say how large it is/);
});
it("reads a full upload store as full, not as an outage", () => {
expect(humanizeError({ status: 507, code: "uploads_full", message: "" })).toMatch(/upload store is full/);
});
+68 -2
View File
@@ -681,8 +681,8 @@ export const api = rejectingSync({
urlPath`/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
// Server file manager (spec §7). Every route is owner-or-admin gated and
// refuses 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
@@ -714,6 +714,59 @@ export const api = rejectingSync({
expectSha256 ? { content, expect_sha256: expectSha256 } : { content },
),
// createServerFile makes a new file with content, and only if nothing is at the
// path yet: something that appeared meanwhile is 409 file_exists, never replaced.
createServerFile: (name: string, path: string, content: string) =>
request<{ path: string; status: string; sha256: string }>(
"PUT",
urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`,
{ content, create_only: true },
),
// deleteServerFile deletes a file, a link (never what it names) or a folder with
// everything in it. The route does not ask; the page confirms first.
deleteServerFile: (name: string, path: string) =>
request<{ path: string; status: string }>(
"DELETE",
urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`,
),
// mkdirServerFolder makes one folder in an existing parent (404 otherwise);
// anything already at the path is 409 file_exists.
mkdirServerFolder: (name: string, path: string) =>
request<{ path: string; status: string }>(
"POST",
urlPath`/servers/${name}/files/mkdir` + `?path=${encodeURIComponent(path)}`,
),
// renameServerFile moves path to `to`, never over something: an existing `to`
// is 409 file_exists. The files felis manages refuse with 400 bad_path.
renameServerFile: (name: string, path: string, to: string) =>
request<{ path: string; to: string; status: string }>(
"POST",
urlPath`/servers/${name}/files/rename` + `?path=${encodeURIComponent(path)}`,
{ to },
),
// uploadServerFile sends a file's raw bytes (the browser sets Content-Length
// from the Blob) with progress. Without overwrite an existing file is 409
// file_exists; with it the file is replaced whole or not at all.
uploadServerFile: (
name: string,
path: string,
file: Blob,
overwrite: boolean,
opts?: { onProgress?: (sent: number) => void; signal?: AbortSignal },
) =>
sendWithProgress<{ path: string; status: string; sha256: string; size: number }>(
"PUT",
urlPath`/servers/${name}/files/upload` +
`?path=${encodeURIComponent(path)}` +
(overwrite ? "&overwrite=true" : ""),
file,
opts,
),
// 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
@@ -1168,6 +1221,19 @@ export function humanizeError(e: unknown): string {
return t("files_timeout");
case "files_unavailable":
return t("files_unavailable");
// File manager: a create, folder, rename or upload never replaces what is
// already at the path (unless an upload asked to).
case "file_exists":
return t("file_exists");
// Uploads are staged on felis-api's disk before the file Job lands them: that
// disk can be at its floor (507), the body can stop short of its length, or a
// client can send none.
case "upload_staging_full":
return t("upload_staging_full");
case "upload_incomplete":
return t("upload_incomplete");
case "length_required":
return t("length_required");
case "jobs_unavailable":
return t("jobs_unavailable");
// Builds, uploads and review: terminal-state conflicts and unwired subsystems.
+433 -1
View File
@@ -139,6 +139,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/internal/file-uploads/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Stream one staged file upload to the Job landing it (one-time bearer token).
* @description PUT /api/v1/servers/{name}/files/upload stages the body on felis-api's disk and creates a Job to land it in the world volume; the Job fetches the bytes here. The Job holds no service token, so the route is public on the internal face and the bearer token minted with the upload is the whole check. The token opens its upload once. An unknown id, a wrong or missing token and a spent token are all the same 404, so the route says nothing about which uploads exist.
*/
get: operations["internalFileUpload"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/internal/servers/{name}/join-event": {
parameters: {
query?: never;
@@ -1292,6 +1312,70 @@ export interface paths {
*/
put: operations["writeServerFile"];
post?: never;
/**
* Delete a file or folder in a server's world volume (owner-or-admin; server must be stopped).
* @description Deletes a file, a symlink (never what it points at) or a folder with everything in it. The world root itself is refused (400 bad_path). Same stopped-gate, world lock and os.Root containment as a write. The panel confirms first; this route does not. Audited as file.delete.
*/
delete: operations["deleteServerFile"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/files/mkdir": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Make a folder in a server's world volume (owner-or-admin; server must be stopped).
* @description Makes one folder. Its parent must already exist (404), and nothing may be at the path yet (409 file_exists). Same stopped-gate, world lock and os.Root containment as a write. Audited as file.mkdir.
*/
post: operations["makeServerFolder"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/files/rename": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Move or rename a file or folder in a server's world volume (owner-or-admin; server must be stopped).
* @description Moves the file or folder at path to to. It never replaces: an existing destination is 409 file_exists, and a missing destination folder is 404. server.properties, config/paper-global.yml and config/ cannot be moved under any name they are reached by (400 bad_path), because elsewhere the read path would no longer withhold their secrets. Same stopped-gate, world lock and os.Root containment as a write. Audited as file.rename.
*/
post: operations["renameServerFile"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/files/upload": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Upload a file into a server's world volume (owner-or-admin; server must be stopped).
* @description Lands the raw request body as the file at path, up to 64 MiB — a plugin jar, a datapack, a world region. Content-Length is required (411 length_required). An existing file is 409 file_exists unless overwrite=true; a folder at the path is 400 bad_path either way. The body is staged on felis-api's disk first and then fetched by the file Job with a one-time token, so the world lock is taken only after the body has arrived and a slow upload holds off no backup. The file lands atomically: a synced temporary sibling is checked against the staged size and SHA-256, then renamed into place, so a failed upload leaves the old file whole. Same stopped-gate and os.Root containment as a write. Audited as file.upload.
*/
put: operations["uploadServerFile"];
post?: never;
delete?: never;
options?: never;
head?: never;
@@ -3246,6 +3330,32 @@ export interface operations {
503: components["responses"]["ServiceUnavailable"];
};
};
internalFileUpload: {
parameters: {
query?: never;
header: {
/** @description Bearer followed by the token minted with the upload. */
Authorization: string;
};
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The staged bytes, verbatim, with their Content-Length. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/octet-stream": string;
};
};
404: components["responses"]["NotFound"];
};
};
joinEvent: {
parameters: {
query?: never;
@@ -6130,6 +6240,8 @@ export interface operations {
content: string;
/** @description The sha256 a read returned. When present, the write is refused with 409 file_changed if the file has changed (or been deleted) since. Omit it to write unconditionally. */
expect_sha256?: string;
/** @description true writes only if nothing is at the path yet (409 file_exists otherwise), for making a new file without replacing one that appeared meanwhile. Cannot be combined with expect_sha256. */
create_only?: boolean;
};
};
};
@@ -6169,7 +6281,7 @@ export interface operations {
"application/json": components["schemas"]["Error"];
};
};
/** @description Server is not stopped (not_stopped), or a restore, backup or file write already holds its world volume (maintenance_in_progress). */
/** @description The file changed since expect_sha256 was read (file_changed), something is already at the path with create_only (file_exists), the server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress). */
409: {
headers: {
[name: string]: unknown;
@@ -6208,6 +6320,326 @@ export interface operations {
};
};
};
deleteServerFile: {
parameters: {
query: {
/** @description File or folder to delete, relative to the world root. */
path: string;
};
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Deleted. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
path: string;
/** @constant */
status: "deleted";
};
};
};
/** @description Missing path, invalid server name, the world root, or a path that escapes it. */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description Unknown server, or nothing at the path. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
503: components["responses"]["ServiceUnavailable"];
/** @description The file Job did not finish in time; retry. */
504: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
makeServerFolder: {
parameters: {
query: {
/** @description Folder to make, relative to the world root. */
path: string;
};
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Folder made. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
path: string;
/** @constant */
status: "created";
};
};
};
/** @description Missing path, invalid server name, the world root, or a path that escapes it. */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description Unknown server, or the parent folder does not exist. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Something is already at the path (file_exists), the server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
503: components["responses"]["ServiceUnavailable"];
/** @description The file Job did not finish in time; retry. */
504: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
renameServerFile: {
parameters: {
query: {
/** @description File or folder to move, relative to the world root. */
path: string;
};
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description The new path */
to: string;
};
};
};
responses: {
/** @description Moved. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
path: string;
to: string;
/** @constant */
status: "renamed";
};
};
};
/** @description Missing path or to, malformed body, invalid server name, the world root, a file felis manages, or a path that escapes the world root. */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description Unknown server, nothing at path, or the destination folder does not exist. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Something is already at to (file_exists), the server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
503: components["responses"]["ServiceUnavailable"];
/** @description The file Job did not finish in time; retry. */
504: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
uploadServerFile: {
parameters: {
query: {
/** @description File to create, relative to the world root. Its folder must exist. */
path: string;
/** @description true replaces an existing file, keeping its mode. Anything else refuses to. */
overwrite?: "true" | "false";
};
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/octet-stream": string;
};
};
responses: {
/** @description File uploaded. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
path: string;
/** @constant */
status: "uploaded";
/** @description SHA-256 of the bytes landed. */
sha256: string;
/**
* Format: int64
* @description Bytes landed.
*/
size: number;
};
};
};
/** @description Missing path, invalid server name, a folder or the world root at the path, a path that escapes the world root, or a body that ended before Content-Length bytes arrived (upload_incomplete). */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description Unknown server, or the folder does not exist. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description A file is already at the path and overwrite is not true (file_exists), the server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description The request has no Content-Length (length_required). */
411: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description The file is over 64 MiB (too_large). */
413: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
503: components["responses"]["ServiceUnavailable"];
/** @description The file Job did not finish in time; retry. */
504: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description felis-api's staging disk has no room for the upload right now (upload_staging_full), or the world volume has no room for it (volume_full); nothing was changed. */
507: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
listUsers: {
parameters: {
query?: {
+520 -2
View File
@@ -1,10 +1,12 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import i18next from "i18next";
import { ServerFiles } from "./ServerFiles";
import { humanizeError } from "@/lib/api";
import { MAX_UPLOAD_BYTES } from "@/components/files/useUploads";
import { STATUS_POLL_FAST_MS } from "@/lib/hooks";
const mocks = vi.hoisted(() => ({
@@ -13,6 +15,11 @@ const mocks = vi.hoisted(() => ({
stop: vi.fn(),
listServerFiles: vi.fn(),
readServerFile: vi.fn(),
createServerFile: vi.fn(),
deleteServerFile: vi.fn(),
mkdirServerFolder: vi.fn(),
renameServerFile: vi.fn(),
uploadServerFile: vi.fn(),
}));
vi.mock("@/lib/api", async (importOriginal) => {
@@ -26,6 +33,11 @@ vi.mock("@/lib/api", async (importOriginal) => {
listServerFiles: mocks.listServerFiles,
readServerFile: mocks.readServerFile,
writeServerFile: mocks.writeServerFile,
createServerFile: mocks.createServerFile,
deleteServerFile: mocks.deleteServerFile,
mkdirServerFolder: mocks.mkdirServerFolder,
renameServerFile: mocks.renameServerFile,
uploadServerFile: mocks.uploadServerFile,
},
};
});
@@ -34,7 +46,7 @@ vi.mock("@/lib/tier", () => ({ useTier: () => ({ isAdmin: true, loading: false }
const t = (key: string) => i18next.t(key);
function renderFiles() {
render(
return render(
<MemoryRouter initialEntries={["/servers/lobby/files"]}>
<Routes>
<Route path="/servers/:name/files" element={<ServerFiles />} />
@@ -69,6 +81,9 @@ beforeEach(() => {
mocks.readServerFile.mockImplementation((_name: string, path: string) =>
Promise.resolve({ path, content: btoa("motd=hi\n"), sha256: "abc" }),
);
for (const m of [mocks.createServerFile, mocks.deleteServerFile, mocks.mkdirServerFolder, mocks.renameServerFile, mocks.uploadServerFile]) {
m.mockReset();
}
mocks.stop.mockReset();
mocks.status.mockReset();
mocks.status.mockResolvedValue(stopped);
@@ -290,3 +305,506 @@ describe("ServerFiles folder answers arriving out of order", () => {
expect(refresh().disabled).toBe(false);
});
});
const button = (key: string, name?: string) =>
screen.getByRole("button", { name: i18next.t(`files:${key}`, name === undefined ? {} : { name }) }) as HTMLButtonElement;
describe("ServerFiles changes", () => {
it("offers no rename for what Felis manages, and one for everything else", async () => {
renderFiles();
await screen.findByText("world");
expect(button("rename_item", "server.properties").disabled).toBe(true);
expect(button("rename_item", "world").disabled).toBe(false);
expect(button("delete_item", "server.properties").disabled).toBe(false);
});
it("renames within the folder and rereads it", async () => {
mocks.renameServerFile.mockResolvedValue({ path: "world/world", to: "world/world_old", status: "renamed" });
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "world" }) }));
await screen.findByRole("button", { name: "world" });
await userEvent.click(screen.getByRole("button", { name: i18next.t("files:rename_item", { name: "world" }) }));
const dialog = screen.getByRole("dialog");
const field = within(dialog).getByRole("textbox") as HTMLInputElement;
expect(field.value).toBe("world");
const lists = mocks.listServerFiles.mock.calls.length;
await userEvent.clear(field);
await userEvent.type(field, " world_old {Enter}");
expect(mocks.renameServerFile.mock.calls).toEqual([["lobby", "world/world", "world/world_old"]]);
expect(await screen.findByText(i18next.t("files:renamed", { from: "world", to: "world_old" }))).toBeTruthy();
expect(screen.queryByRole("dialog")).toBeNull();
expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1);
expect(mocks.listServerFiles).toHaveBeenLastCalledWith("lobby", "world");
});
it("keeps rename off until the name changes", async () => {
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:rename_item", { name: "world" }) }));
const dialog = screen.getByRole("dialog");
const submit = within(dialog).getByRole("button", { name: t("files:rename") }) as HTMLButtonElement;
expect(submit.disabled).toBe(true);
await userEvent.type(within(dialog).getByRole("textbox"), "2");
expect(submit.disabled).toBe(false);
});
it("says why a name cannot be used and sends nothing", async () => {
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: t("files:new_folder") }));
const dialog = screen.getByRole("dialog");
const field = within(dialog).getByRole("textbox");
const submit = within(dialog).getByRole("button", { name: t("files:create") }) as HTMLButtonElement;
// An empty field has nothing to say yet, and nothing to create.
expect(within(dialog).queryByText(t("files:name_required"))).toBeNull();
expect(submit.disabled).toBe(true);
await userEvent.type(field, "world");
expect(within(dialog).getByText(t("files:name_taken"))).toBeTruthy();
expect(submit.disabled).toBe(true);
await userEvent.clear(field);
await userEvent.type(field, "a/b{Enter}");
expect(within(dialog).getByText(t("files:name_slash"))).toBeTruthy();
expect(field.getAttribute("aria-invalid")).toBe("true");
expect(mocks.mkdirServerFolder).not.toHaveBeenCalled();
});
it("makes a folder in the folder on screen", async () => {
mocks.mkdirServerFolder.mockResolvedValue({ path: "world/datapacks", status: "created" });
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "world" }) }));
await screen.findByRole("button", { name: "world" });
const lists = mocks.listServerFiles.mock.calls.length;
await userEvent.click(screen.getByRole("button", { name: t("files:new_folder") }));
await userEvent.type(within(screen.getByRole("dialog")).getByRole("textbox"), "datapacks{Enter}");
expect(mocks.mkdirServerFolder.mock.calls).toEqual([["lobby", "world/datapacks"]]);
expect(await screen.findByText(i18next.t("files:folder_created", { path: "world/datapacks" }))).toBeTruthy();
expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1);
expect(mocks.listServerFiles).toHaveBeenLastCalledWith("lobby", "world");
});
it("sends a name once, and stays open until the answer comes", async () => {
let answer!: (v: unknown) => void;
mocks.mkdirServerFolder.mockReturnValue(new Promise((res) => (answer = res)));
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: t("files:new_folder") }));
const dialog = screen.getByRole("dialog");
await userEvent.type(within(dialog).getByRole("textbox"), "datapacks");
const create = within(dialog).getByRole("button", { name: t("files:create") });
await userEvent.click(create);
await userEvent.click(create);
await userEvent.keyboard("{Escape}");
expect(mocks.mkdirServerFolder).toHaveBeenCalledTimes(1);
expect(screen.getByRole("dialog")).toBe(dialog);
await act(async () => answer({ path: "datapacks", status: "created" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("keeps the dialog open with the server's refusal", async () => {
const refusal = { status: 409, code: "file_exists", message: "something is already at datapacks" };
mocks.mkdirServerFolder.mockRejectedValue(refusal);
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: t("files:new_folder") }));
const dialog = screen.getByRole("dialog");
await userEvent.type(within(dialog).getByRole("textbox"), "datapacks{Enter}");
expect(await within(dialog).findByText(humanizeError(refusal))).toBeTruthy();
expect(screen.getByRole("dialog")).toBe(dialog);
});
it("opens a new file in the editor, and its first save expects the file it made", async () => {
mocks.createServerFile.mockResolvedValue({ path: "notes.txt", status: "written", sha256: "e".repeat(64) });
mocks.writeServerFile.mockResolvedValue({ path: "notes.txt", status: "written", sha256: "f".repeat(64) });
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: t("files:new_file") }));
await userEvent.type(within(screen.getByRole("dialog")).getByRole("textbox"), "notes.txt{Enter}");
expect(mocks.createServerFile.mock.calls).toEqual([["lobby", "notes.txt", ""]]);
const editor = await screen.findByRole("dialog");
expect(within(editor).getByText("notes.txt")).toBeTruthy();
await userEvent.type(within(editor).getByRole("textbox"), "hi");
await userEvent.click(within(editor).getByRole("button", { name: t("files:save") }));
expect(mocks.writeServerFile.mock.calls).toEqual([["lobby", "notes.txt", btoa("hi"), "e".repeat(64)]]);
});
it("asks before deleting a folder with everything in it, then rereads", async () => {
mocks.deleteServerFile.mockResolvedValue({ path: "world/world", status: "deleted" });
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "world" }) }));
await screen.findByRole("button", { name: "world" });
await userEvent.click(screen.getByRole("button", { name: i18next.t("files:delete_item", { name: "world" }) }));
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText(i18next.t("files:delete_folder_title", { name: "world" }))).toBeTruthy();
expect(within(dialog).getByText(t("files:delete_folder_body"))).toBeTruthy();
expect(mocks.deleteServerFile).not.toHaveBeenCalled();
const lists = mocks.listServerFiles.mock.calls.length;
await userEvent.click(within(dialog).getByRole("button", { name: t("files:delete") }));
expect(mocks.deleteServerFile.mock.calls).toEqual([["lobby", "world/world"]]);
expect(await screen.findByText(i18next.t("files:deleted", { path: "world/world" }))).toBeTruthy();
expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1);
expect(mocks.listServerFiles).toHaveBeenLastCalledWith("lobby", "world");
});
it("words a file's deletion as a file's", async () => {
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:delete_item", { name: "server.properties" }) }));
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText(i18next.t("files:delete_file_title", { name: "server.properties" }))).toBeTruthy();
expect(within(dialog).getByText(t("files:delete_file_body"))).toBeTruthy();
// The row's own click never fired: the file did not open behind it.
expect(mocks.readServerFile).not.toHaveBeenCalled();
});
it("says why the proxy secret's file does not open, and reads nothing", async () => {
mocks.listServerFiles.mockImplementation((_name: string, path: string) =>
Promise.resolve({
path,
truncated: false,
entries:
path === "config"
? [{ name: "paper-global.yml", size: 900, is_dir: false, mod_time: "2026-09-01T00:00:00Z" }]
: [{ name: "config", size: 0, is_dir: true, mod_time: "2026-09-01T00:00:00Z" }],
}),
);
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "config" }) }));
await userEvent.click(await screen.findByText("paper-global.yml"));
expect(screen.getByText(t("files:secret_config_unreadable"))).toBeTruthy();
expect(mocks.readServerFile).not.toHaveBeenCalled();
expect(button("rename_item", "paper-global.yml").disabled).toBe(true);
});
it("lists folders first and numbers in counting order", async () => {
mocks.listServerFiles.mockResolvedValue({
path: "",
truncated: false,
entries: ["r.10.0.mca", "world", "r.2.0.mca", "logs"].map((name) => ({
name,
size: 1,
is_dir: !name.endsWith(".mca"),
mod_time: "2026-09-01T00:00:00Z",
})),
});
renderFiles();
await screen.findByText("logs");
const names = screen
.getAllByRole("button", { name: /^(Open|打开)/ })
.map((b) => b.textContent);
expect(names).toEqual(["logs", "world", "r.2.0.mca", "r.10.0.mca"]);
});
});
describe("ServerFiles uploads", () => {
type Pending = {
resolve: () => void;
reject: (e: unknown) => void;
signal?: AbortSignal;
progress?: (sent: number) => void;
};
let pending: Pending[];
beforeEach(() => {
pending = [];
mocks.uploadServerFile.mockImplementation(
(
_name: string,
path: string,
file: File,
_overwrite: boolean,
opts?: { signal?: AbortSignal; onProgress?: (sent: number) => void },
) =>
new Promise((resolve, reject) => {
pending.push({
resolve: () => resolve({ path, status: "uploaded", sha256: "a", size: file.size }),
reject,
signal: opts?.signal,
progress: opts?.onProgress,
});
opts?.signal?.addEventListener("abort", () => reject(new DOMException("cancelled", "AbortError")));
}),
);
});
const file = (name: string, body = "bytes") => new File([body], name);
const pick = (...files: File[]) => fireEvent.change(screen.getByTestId("upload-input"), { target: { files } });
const queue = () => screen.getByRole("region", { name: t("files:uploads_label") });
const sentAs = () => mocks.uploadServerFile.mock.calls.map((c) => [c[1], c[3]]);
it("sends one at a time and rereads the folder once the queue is empty", async () => {
renderFiles();
await screen.findByText("world");
const lists = mocks.listServerFiles.mock.calls.length;
pick(file("a.jar"), file("b.jar"));
await waitFor(() => expect(sentAs()).toEqual([["a.jar", false]]));
expect(within(queue()).getByText(t("files:upload_queued"))).toBeTruthy();
await act(async () => pending[0].resolve());
await waitFor(() => expect(sentAs()).toEqual([["a.jar", false], ["b.jar", false]]));
expect(mocks.listServerFiles.mock.calls.length).toBe(lists);
await act(async () => pending[1].resolve());
await waitFor(() => expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1));
expect(within(queue()).getAllByText(t("files:upload_done"))).toHaveLength(2);
});
it("rereads once at the end even when the folder changes on the way", async () => {
renderFiles();
await screen.findByText("world");
pick(file("a.jar"), file("b.jar"));
await waitFor(() => expect(pending).toHaveLength(1));
await act(async () => pending[0].resolve());
await waitFor(() => expect(pending).toHaveLength(2));
const lists = mocks.listServerFiles.mock.calls.length;
await userEvent.click(screen.getByRole("button", { name: i18next.t("files:open_folder", { name: "world" }) }));
await screen.findByRole("button", { name: "world" });
expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1);
await act(async () => pending[1].resolve());
await waitFor(() => expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 2));
expect(mocks.listServerFiles).toHaveBeenLastCalledWith("lobby", "world");
// That reread was the landing's: going back is one listing, as always.
await userEvent.click(screen.getByRole("button", { name: t("files:up") }));
await waitFor(() => expect(screen.queryByRole("button", { name: "world" })).toBeNull());
expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 3);
});
it("lands in the folder on screen when it was added", async () => {
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "world" }) }));
await screen.findByRole("button", { name: "world" });
pick(file("level.dat_old"));
await waitFor(() => expect(sentAs()).toEqual([["world/level.dat_old", false]]));
});
it("waits on a file already there until told to replace it", async () => {
renderFiles();
await screen.findByText("world");
pick(file("server.properties"));
expect(await within(queue()).findByText(t("files:upload_exists"))).toBeTruthy();
expect(mocks.uploadServerFile).not.toHaveBeenCalled();
await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_replace") }));
await waitFor(() => expect(sentAs()).toEqual([["server.properties", true]]));
});
it("drops a skipped file without sending it", async () => {
renderFiles();
await screen.findByText("world");
pick(file("server.properties"));
await userEvent.click(await within(queue()).findByRole("button", { name: t("files:upload_skip") }));
expect(screen.queryByRole("region", { name: t("files:uploads_label") })).toBeNull();
expect(mocks.uploadServerFile).not.toHaveBeenCalled();
});
it("refuses a name a folder holds, and a file over the limit, without sending or offering a retry", async () => {
renderFiles();
await screen.findByText("world");
const big = file("world-backup.zip");
Object.defineProperty(big, "size", { value: MAX_UPLOAD_BYTES + 1 });
const exact = file("exact.zip");
Object.defineProperty(exact, "size", { value: MAX_UPLOAD_BYTES });
pick(file("world"), big, exact);
expect(await within(queue()).findByText(t("files:upload_folder_there"))).toBeTruthy();
expect(within(queue()).getByText(i18next.t("files:upload_too_large", { limit: "64 MiB" }))).toBeTruthy();
expect(within(queue()).queryByRole("button", { name: t("files:upload_retry") })).toBeNull();
await waitFor(() => expect(sentAs()).toEqual([["exact.zip", false]]));
});
it("asks about a file that appeared since the listing", async () => {
renderFiles();
await screen.findByText("world");
pick(file("icon.png"));
await waitFor(() => expect(pending).toHaveLength(1));
await act(async () => pending[0].reject({ status: 409, code: "file_exists", message: "something is already at icon.png" }));
expect(await within(queue()).findByRole("button", { name: t("files:upload_replace") })).toBeTruthy();
expect(within(queue()).getByText(t("files:upload_exists"))).toBeTruthy();
});
it("offers a retry after a failure and sends it again", async () => {
const full = { status: 507, code: "volume_full", message: "no space left" };
renderFiles();
await screen.findByText("world");
pick(file("big.jar"));
await waitFor(() => expect(pending).toHaveLength(1));
await act(async () => pending[0].reject(full));
expect(await within(queue()).findByText(humanizeError(full))).toBeTruthy();
await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_retry") }));
await waitFor(() => expect(sentAs()).toEqual([["big.jar", false], ["big.jar", false]]));
});
it("cancels the upload in flight and rereads nothing", async () => {
renderFiles();
await screen.findByText("world");
const lists = mocks.listServerFiles.mock.calls.length;
pick(file("a.jar"));
await waitFor(() => expect(pending).toHaveLength(1));
await userEvent.click(within(queue()).getByRole("button", { name: i18next.t("files:upload_cancel", { name: "a.jar" }) }));
expect(pending[0].signal?.aborted).toBe(true);
await waitFor(() => expect(screen.queryByRole("region", { name: t("files:uploads_label") })).toBeNull());
expect(mocks.listServerFiles.mock.calls.length).toBe(lists);
});
it("stops the upload in flight when the page goes away", async () => {
const { unmount } = renderFiles();
await screen.findByText("world");
pick(file("a.jar"));
await waitFor(() => expect(pending).toHaveLength(1));
unmount();
expect(pending[0].signal?.aborted).toBe(true);
});
it("holds every other change, and leaving the page, while an upload runs", async () => {
renderFiles();
await screen.findByText("world");
pick(file("a.jar"));
await waitFor(() => expect(pending).toHaveLength(1));
for (const b of [button("new_file"), button("new_folder"), button("rename_item", "world"), button("delete_item", "world")]) {
expect(b.disabled).toBe(true);
}
const leave = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(leave);
expect(leave.defaultPrevented).toBe(true);
await act(async () => pending[0].resolve());
await waitFor(() => expect(button("new_file").disabled).toBe(false));
expect(button("delete_item", "world").disabled).toBe(false);
});
describe("with several files already there", () => {
beforeEach(() => {
mocks.listServerFiles.mockImplementation((_name: string, path: string) =>
Promise.resolve({
path,
truncated: false,
entries: ["a.yml", "b.yml"].map((name) => ({ name, size: 8, is_dir: false, mod_time: "2026-09-01T00:00:00Z" })),
}),
);
});
it("replaces every waiting file at once", async () => {
renderFiles();
await screen.findByText("a.yml");
pick(file("a.yml"), file("b.yml"), file("c.yml"));
await waitFor(() => expect(sentAs()).toEqual([["c.yml", false]]));
await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_replace_all") }));
await act(async () => pending[0].resolve());
await waitFor(() => expect(pending).toHaveLength(2));
await act(async () => pending[1].resolve());
await waitFor(() => expect(sentAs()).toEqual([["c.yml", false], ["a.yml", true], ["b.yml", true]]));
});
it("skips every waiting file at once and keeps the rest", async () => {
renderFiles();
await screen.findByText("a.yml");
pick(file("a.yml"), file("b.yml"), file("c.yml"));
await waitFor(() => expect(pending).toHaveLength(1));
await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_skip_all") }));
expect(within(queue()).queryByText(t("files:upload_exists"))).toBeNull();
expect(within(queue()).getByText("c.yml")).toBeTruthy();
expect(sentAs()).toEqual([["c.yml", false]]);
});
});
it("clears the finished uploads and keeps the ones still asking", async () => {
renderFiles();
await screen.findByText("world");
pick(file("a.jar"), file("server.properties"));
await waitFor(() => expect(pending).toHaveLength(1));
await act(async () => pending[0].resolve());
await within(queue()).findByText(t("files:upload_done"));
await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_clear_done") }));
expect(within(queue()).queryByText("a.jar")).toBeNull();
expect(within(queue()).getByText("server.properties")).toBeTruthy();
});
it("shows the file being written once all of it is sent, and no longer offers a cancel", async () => {
renderFiles();
await screen.findByText("world");
pick(file("a.jar", "0123456789"));
await waitFor(() => expect(pending).toHaveLength(1));
const cancel = i18next.t("files:upload_cancel", { name: "a.jar" });
await act(async () => pending[0].progress?.(4));
expect(within(queue()).getByRole("progressbar").getAttribute("aria-valuenow")).toBe("40");
expect(within(queue()).getByRole("button", { name: cancel })).toBeTruthy();
await act(async () => pending[0].progress?.(10));
expect(within(queue()).getByText(t("files:upload_landing"))).toBeTruthy();
expect(within(queue()).queryByRole("button", { name: cancel })).toBeNull();
});
it("takes dropped files and names the folders it skipped", async () => {
renderFiles();
const table = await screen.findByRole("table");
const dataTransfer = {
types: ["Files"],
files: [],
items: [
{ kind: "file", webkitGetAsEntry: () => ({ isDirectory: true }), getAsFile: () => null },
{ kind: "file", webkitGetAsEntry: () => ({ isDirectory: false }), getAsFile: () => file("a.jar") },
],
};
fireEvent.drop(table, { dataTransfer });
await waitFor(() => expect(sentAs()).toEqual([["a.jar", false]]));
expect(screen.getByText(i18next.t("files:upload_no_folders", { count: 1 }))).toBeTruthy();
});
it("keeps a file dropped beside the list from replacing the page", async () => {
renderFiles();
const table = await screen.findByRole("table");
const beside = { types: ["Files"], dropEffect: "move" };
const onList = { types: ["Files"], dropEffect: "move" };
expect(fireEvent.dragOver(document.body, { dataTransfer: beside })).toBe(false);
expect(beside.dropEffect).toBe("none");
expect(fireEvent.dragOver(table, { dataTransfer: onList })).toBe(false);
expect(onList.dropEffect).toBe("copy");
// Dragging text is the browser's own business.
expect(fireEvent.dragOver(document.body, { dataTransfer: { types: ["text/plain"], dropEffect: "move" } })).toBe(true);
});
});
+344 -54
View File
@@ -4,13 +4,18 @@ import {
AlertTriangle,
ArrowUp,
ChevronRight,
FilePlus,
FileText,
Folder,
FolderOpen,
FolderPlus,
Loader2,
Pencil,
RefreshCw,
Save,
Square,
Trash2,
Upload,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { BackLink } from "@/components/BackLink";
@@ -18,6 +23,19 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { MessageLine } from "@/components/MessageLine";
import { InlineConfirm } from "@/components/InlineConfirm";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { NameDialog } from "@/components/files/NameDialog";
import { UploadQueue } from "@/components/files/UploadQueue";
import { MAX_UPLOAD_BYTES, useUploads } from "@/components/files/useUploads";
import {
SECRET_CONFIG_PATH,
isManaged,
joinPath,
nameProblem,
parentOf,
sortEntries,
type NameProblem,
} from "@/components/files/names";
import {
Dialog,
DialogContent,
@@ -73,19 +91,16 @@ function decodeText(bytes: Uint8Array): string | null {
}
}
function joinPath(dir: string, name: string): string {
return dir === "" ? name : `${dir}/${name}`;
}
/** The name questions the page asks, each tied to the folder it was asked in. */
type Naming =
| { kind: "file" | "folder"; dir: string }
| { kind: "rename"; dir: string; entry: ServerFileEntry };
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. */
/** ServerFiles is the world-volume file manager: browse, edit a config (the "one
* wrong line in server.properties" repair), make, rename, delete and upload.
* 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");
@@ -120,7 +135,7 @@ export function ServerFiles() {
try {
const r = await api.listServerFiles(name, p);
if (ticket !== loadSeq.current) return;
setEntries(r.entries ?? []);
setEntries(sortEntries(r.entries ?? []));
setTruncated(r.truncated === true);
setDir(p);
} catch (e) {
@@ -201,6 +216,12 @@ export function ServerFiles() {
async function openFile(entry: ServerFileEntry) {
const p = joinPath(dir, entry.name);
// The read path refuses this one outright; saying why beats a Job that
// answers "invalid path".
if (p === SECRET_CONFIG_PATH) {
setMsg({ kind: "error", text: t("secret_config_unreadable") });
return;
}
setOpening(p);
setMsg(null);
try {
@@ -254,6 +275,115 @@ export function ServerFiles() {
}
}
// Uploads run one at a time. The listing is reread once the queue has drained
// rather than after each file: every listing is a Job of its own.
const landedSince = useRef(false);
const uploads = useUploads(name, () => {
landedSince.current = true;
});
useEffect(() => {
if (uploads.busy || !landedSince.current) return;
landedSince.current = false;
void load(dir);
}, [uploads.busy, dir, load]);
// A browser leaving the page takes the uploads with it.
useUnsavedGuard(uploads.busy);
// A file dropped anywhere else on the page would be opened by the browser in
// place of the panel, taking the queue with it; outside the list a drop does
// nothing. The list itself claims its drops first.
useEffect(() => {
const refuse = (e: DragEvent) => {
if (e.defaultPrevented || !e.dataTransfer?.types.includes("Files")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "none";
};
window.addEventListener("dragover", refuse);
window.addEventListener("drop", refuse);
return () => {
window.removeEventListener("dragover", refuse);
window.removeEventListener("drop", refuse);
};
}, []);
const [naming, setNaming] = useState<Naming | null>(null);
// The entry outlives the dialog closing, so its title holds through the
// closing animation.
const [deleting, setDeleting] = useState<ServerFileEntry | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const fileInput = useRef<HTMLInputElement>(null);
// dragenter and dragleave fire for every child crossed, so the overlay counts
// them rather than flickering at each row.
const [dragDepth, setDragDepth] = useState(0);
// The name dialog mounts per question, so each one starts from its own name.
function ask(next: Naming) {
setMsg(null);
setNaming(next);
}
function problemText(p: NameProblem | null): string | null {
return p === null ? null : t(p);
}
function addFiles(files: readonly File[]) {
if (files.length === 0) return;
setMsg(null);
uploads.add(files, dir, entries);
}
// addDropped queues what was dropped. A folder arrives as an entry the browser
// cannot read as a file, so it is named as skipped instead of failing later.
function addDropped(data: DataTransfer) {
const files: File[] = [];
let folders = 0;
for (const item of Array.from(data.items ?? [])) {
if (item.kind !== "file") continue;
if (item.webkitGetAsEntry?.()?.isDirectory) {
folders++;
continue;
}
const f = item.getAsFile();
if (f) files.push(f);
}
if (data.items === undefined || data.items.length === 0) files.push(...Array.from(data.files));
addFiles(files);
if (folders > 0) setMsg({ kind: "error", text: t("upload_no_folders", { count: folders }) });
}
const draggingFiles = (e: React.DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
async function createFile(n: string) {
if (!naming) return;
const p = joinPath(naming.dir, n);
const r = await api.createServerFile(name, p, "");
setConfirmDiscard(false);
setOpen({ path: p, text: "", original: "", editable: true, sha256: r.sha256 ?? "", conflict: false, error: null });
void load(naming.dir);
}
async function makeFolder(n: string) {
if (!naming) return;
const p = joinPath(naming.dir, n);
await api.mkdirServerFolder(name, p);
setMsg({ kind: "success", text: t("folder_created", { path: p }) });
void load(naming.dir);
}
async function renameEntry(n: string) {
if (naming?.kind !== "rename") return;
await api.renameServerFile(name, joinPath(naming.dir, naming.entry.name), joinPath(naming.dir, n));
setMsg({ kind: "success", text: t("renamed", { from: naming.entry.name, to: n }) });
void load(naming.dir);
}
async function deleteEntry() {
if (!deleting) return;
const p = joinPath(dir, deleting.name);
await api.deleteServerFile(name, p);
setMsg({ kind: "success", text: t("deleted", { path: p }) });
void load(dir);
}
const back = <BackLink to={`/servers/${name}`} label={t("back_to_console")} />;
if (statusQ.loading && !statusQ.data) {
return (
@@ -338,9 +468,39 @@ export function ServerFiles() {
) : listErr ? (
<ErrorState error={listErr} onRetry={() => load(dir)} />
) : (
<Card className="overflow-hidden">
<Card
className="relative overflow-hidden"
onDragEnter={(e) => {
if (!draggingFiles(e)) return;
e.preventDefault();
setDragDepth((d) => d + 1);
}}
onDragOver={(e) => {
if (!draggingFiles(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(e) => {
if (!draggingFiles(e)) return;
setDragDepth((d) => Math.max(0, d - 1));
}}
onDrop={(e) => {
if (!draggingFiles(e)) return;
e.preventDefault();
setDragDepth(0);
addDropped(e.dataTransfer);
}}
>
{dragDepth > 0 && (
<div className="pointer-events-none absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-primary bg-background/85 text-center backdrop-blur-[1px]">
<Upload className="h-7 w-7 text-primary" />
<p className="px-4 text-sm font-medium">
{t("drop_here", { dir: dir === "" ? t("root") : dir })}
</p>
</div>
)}
<CardContent className="p-0">
{/* Location bar: parent button + clickable breadcrumbs + refresh */}
{/* Location bar: parent button + clickable breadcrumbs + actions */}
<div className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-3">
<Button
size="sm"
@@ -351,7 +511,7 @@ export function ServerFiles() {
<ArrowUp className="h-4 w-4" />
{t("up")}
</Button>
<nav className="flex flex-wrap items-center gap-1 text-sm">
<nav className="flex min-w-0 flex-wrap items-center gap-1 text-sm">
<button
type="button"
onClick={() => void load("")}
@@ -382,7 +542,52 @@ export function ServerFiles() {
);
})}
</nav>
<div className="ml-auto">
<div className="ml-auto flex items-center gap-1">
{/* Each change is a Job holding the world lock, so while an
upload holds it a change could only be refused. */}
<Button
size="sm"
variant="ghost"
onClick={() => ask({ kind: "file", dir })}
disabled={uploads.busy}
aria-label={t("new_file")}
title={uploads.busy ? t("wait_for_uploads") : t("new_file")}
>
<FilePlus className="h-4 w-4" />
<span className="hidden md:inline">{t("new_file")}</span>
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => ask({ kind: "folder", dir })}
disabled={uploads.busy}
aria-label={t("new_folder")}
title={uploads.busy ? t("wait_for_uploads") : t("new_folder")}
>
<FolderPlus className="h-4 w-4" />
<span className="hidden md:inline">{t("new_folder")}</span>
</Button>
<Button
size="sm"
variant="outline"
onClick={() => fileInput.current?.click()}
aria-label={t("upload")}
title={t("upload_hint", { limit: formatBytes(MAX_UPLOAD_BYTES) })}
>
<Upload className="h-4 w-4" />
<span className="hidden sm:inline">{t("upload")}</span>
</Button>
<input
ref={fileInput}
type="file"
multiple
hidden
data-testid="upload-input"
onChange={(e) => {
addFiles(Array.from(e.target.files ?? []));
e.target.value = "";
}}
/>
<Button
size="sm"
variant="ghost"
@@ -396,6 +601,17 @@ export function ServerFiles() {
</div>
</div>
<UploadQueue
items={uploads.items}
dir={dir}
onReplace={uploads.replace}
onRetry={uploads.retry}
onRemove={uploads.remove}
onReplaceAll={uploads.replaceAll}
onSkipAll={uploads.skipAll}
onClearDone={uploads.clearDone}
/>
{entries && entries.length === 0 ? (
<div className="p-4">
<EmptyState title={t("empty_dir_title")} hint={t("empty_dir_hint")} />
@@ -407,47 +623,92 @@ export function ServerFiles() {
<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>
<th className="hidden px-4 py-2.5 font-medium sm:table-cell">{t("col_modified")}</th>
<th className="w-px px-2 py-2.5">
<span className="sr-only">{t("col_actions")}</span>
</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">
{/* The name is a button so the keyboard and a screen
reader reach every row; its click bubbles to the
row, which opens it from anywhere on the row. */}
<button
type="button"
aria-label={t(e.is_dir ? "open_folder" : "open_file", { name: e.name })}
className="flex min-w-0 max-w-full items-center gap-2 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{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" />
)}
</button>
</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}
{(entries ?? []).map((e) => {
const p = joinPath(dir, e.name);
const managed = isManaged(p);
return (
<tr
key={e.name}
onClick={() => (e.is_dir ? void load(p) : void openFile(e))}
className="cursor-pointer transition-colors hover:bg-muted/30"
>
{e.mod_time ? formatRelative(e.mod_time, now, locale) : "—"}
</td>
</tr>
))}
<td className="px-4 py-3">
{/* The name is a button so the keyboard and a screen
reader reach every row; its click bubbles to the
row, which opens it from anywhere on the row. */}
<button
type="button"
aria-label={t(e.is_dir ? "open_folder" : "open_file", { name: e.name })}
className="flex min-w-0 max-w-full items-center gap-2 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{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 === p && (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
)}
</button>
</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">
{e.is_dir ? "—" : formatBytes(e.size)}
</td>
<td
className="hidden px-4 py-3 whitespace-nowrap text-xs text-muted-foreground sm:table-cell"
title={e.mod_time}
>
{e.mod_time ? formatRelative(e.mod_time, now, locale) : "—"}
</td>
<td className="px-2 py-1.5">
{/* The actions stop at their own buttons: a click
here must not also open the row. */}
<div className="flex items-center justify-end gap-0.5" onClick={(ev) => ev.stopPropagation()}>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
onClick={() => ask({ kind: "rename", dir, entry: e })}
disabled={managed || uploads.busy}
aria-label={t("rename_item", { name: e.name })}
title={
managed
? t("managed_no_rename")
: uploads.busy
? t("wait_for_uploads")
: t("rename_item", { name: e.name })
}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={() => {
setMsg(null);
setDeleting(e);
setDeleteOpen(true);
}}
disabled={uploads.busy}
aria-label={t("delete_item", { name: e.name })}
title={uploads.busy ? t("wait_for_uploads") : t("delete_item", { name: e.name })}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
@@ -575,6 +836,35 @@ export function ServerFiles() {
)}
</DialogContent>
</Dialog>
{naming && (
<NameDialog
open
onOpenChange={(v) => !v && setNaming(null)}
title={
naming.kind === "rename"
? t("rename_title", { name: naming.entry.name })
: t(naming.kind === "file" ? "new_file" : "new_folder")
}
description={t("in_folder", { dir: naming.dir === "" ? t("root") : naming.dir })}
label={t("name_label")}
confirmLabel={t(naming.kind === "rename" ? "rename" : "create")}
initial={naming.kind === "rename" ? naming.entry.name : ""}
problem={(v) =>
problemText(nameProblem(v, entries, naming.kind === "rename" ? naming.entry.name : undefined))
}
onSubmit={naming.kind === "file" ? createFile : naming.kind === "folder" ? makeFolder : renameEntry}
/>
)}
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title={t(deleting?.is_dir ? "delete_folder_title" : "delete_file_title", { name: deleting?.name ?? "" })}
description={t(deleting?.is_dir ? "delete_folder_body" : "delete_file_body")}
confirmLabel={t("delete")}
onConfirm={deleteEntry}
/>
</>
);
}