feat(panel): add the server files page for the world-volume repair lever

The backend could list/read/write a stopped server's world volume since the
file-editor slice, but the panel had no entry, so the one repair path for a
server that will not boot (a wrong line in server.properties) was API-only.
New /servers/:name/files page: breadcrumb browser, editor dialog with the
base64 []byte codec, binary files open read-only, the stopped gate is owned
up front (with a stop action) instead of letting every call 409, and a
doorway card on the console. i18n files namespace + wire-shape tests.
This commit is contained in:
Lemon-miaow committed 2026-09-23 06:42:45 +08:00
1 parent 72c4aa3895
commit 0a36b3fda9
11 files changed
+601 -1

No files matched your search

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