feat(studio): restoring a backup asks for the passphrase and reads the differences in a dialog, and says what happened in one strip per outcome, as the recovery board draws it

This commit is contained in:
yhh committed 2026-11-06 03:34:59 -08:00
1 parent efcd3bbf51
commit 3cf430d823
4 files changed
+153 -48

No files matched your search

@@ -72,3 +72,25 @@
.toolfb { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--r-md); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); color: var(--text); }
.toolfb .toolfb-nm { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.toolfb .srcfb-tag { display: inline-grid; place-items: center; height: 20px; padding: 0 7px; border-radius: var(--r-pill); background: var(--overlay); color: var(--muted); font: var(--w-reg) var(--fs-micro)/1 var(--ui); white-space: nowrap; }
.ns-dlg.bk-dlg[data-wide] { width: 560px; }
.ns-dlg.bk-dlg .bk-dlg-body { padding: 10px 16px 14px; max-height: min(60vh, 520px); overflow-y: auto; }
.ns-dlg.bk-dlg .bk-dlg-body .acct-note { margin: 0 0 8px; color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.7 var(--ui); }
.ns-dlg.bk-dlg .bk-fields[data-one] { grid-template-columns: 1fr; }
.ns-dlg.bk-dlg > footer { align-items: center; gap: 8px; }
.ns-dlg.bk-dlg > footer .acct-note { flex: 1; margin: 0; color: var(--warn-ink); font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); }
.ns-dlg.bk-dlg > footer .btn { display: inline-flex; align-items: center; gap: 5px; height: 32px; padding: 0 14px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-ui)/1 var(--ui); cursor: pointer; }
.ns-dlg.bk-dlg > footer .btn[data-primary] { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
.ns-dlg.bk-dlg > footer .btn:disabled { opacity: .45; cursor: default; }
.ns-dlg.bk-dlg > footer .btn .studio-icon { width: 14px; height: 14px; }
.bk-result { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
.bk-strip { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-radius: var(--r-md); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); }
.bk-strip > span { flex: 1; min-width: 0; }
.bk-strip[data-tone="ok"] { background: color-mix(in srgb, var(--ok) 10%, transparent); }
.bk-strip[data-tone="ok"] > .studio-icon { color: var(--ok-ink); }
.bk-strip[data-tone="warn"] { background: color-mix(in srgb, var(--warn) 12%, transparent); }
.bk-strip[data-tone="warn"] > .studio-icon { color: var(--warn-ink); }
.bk-strip[data-tone="plain"] { flex-wrap: wrap; background: var(--overlay); color: var(--muted); }
.bk-strip .studio-icon { flex: none; width: 16px; height: 16px; }
.bk-strip .bk-plug { display: inline-flex; align-items: center; gap: 8px; }
.bk-strip .bk-plug .btn { height: 24px; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--r-pill); background: var(--raised); color: var(--text); font: var(--w-reg) var(--fs-meta)/1 var(--ui); cursor: pointer; }
.bk-strip .ns-cancel { flex: none; height: 24px; padding: 0 10px; border: 1px solid transparent; border-radius: var(--r-pill); background: transparent; color: var(--muted); font: var(--w-reg) var(--fs-meta)/1 var(--ui); cursor: pointer; }
@@ -113,6 +113,7 @@ export const DATA: Messages = {
"data.repeat_it": { zh: "再输一次", en: "Repeat it" },
"data.repeat_the_passphrase_to_confirm": { zh: "请再输一次口令", en: "Repeat the passphrase to confirm it" },
"data.restore": { zh: "恢复…", en: "Restore…" },
"data.restore_a_backup": { zh: "恢复备份", en: "Restore a backup" },
"data.restore_n_selected": { zh: "恢复所选 {n} 项", en: "Restore {n} selected" },
"data.restored_n_items": { zh: "已恢复 {n} 项。", en: "Restored {n} items." },
"data.restoring": { zh: "正在恢复…", en: "Restoring…" },
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { BackupRestore } from "./BackupRestore";
import { MockPort } from "../port/mock";
import type { AgentPort, BackupPlan } from "../port/port";
afterEach(cleanup);
const plan = {
planId: "p1", platform: "windows/amd64", samePlatform: true, omitted: [],
items: [{ id: "i1", category: "settings", name: "general", status: "changed", summary: "default_model = x" }],
} as unknown as BackupPlan;
function draw(over: Partial<{ preview: () => Promise<BackupPlan>; apply: () => Promise<unknown> }> = {}) {
const onClose = vi.fn();
const source = {
name: "公司笔记本",
preview: over.preview ?? (async () => plan),
apply: over.apply ?? (async () => ({ applied: ["i1"] })),
};
render(<BackupRestore port={new MockPort() as unknown as AgentPort} source={source as never} onClose={onClose} />);
return onClose;
}
describe("restoring a backup", () => {
it("asks for the passphrase in a dialog with Cancel and a lock on Preview differences", async () => {
draw();
const dialog = await screen.findByRole("dialog", { name: "恢复备份" });
expect(dialog.textContent).toContain("输入备份「公司笔记本」的加密口令,先预览它和本机的差异,不会立即写入。");
const foot = [...dialog.querySelectorAll("footer button")].map((b) => b.textContent);
expect(foot).toEqual(["取消", "预览差异"]);
expect(dialog.querySelector("footer .studio-icon")).toBeTruthy();
expect((screen.getByRole("button", { name: "预览差异" }) as HTMLButtonElement).disabled).toBe(true);
});
it("closes from the cross, Cancel and Escape without previewing anything", async () => {
const preview = vi.fn(async () => plan);
const onClose = draw({ preview });
await userEvent.click(await screen.findByRole("button", { name: "关闭" }));
await userEvent.click(await screen.findByRole("button", { name: "取消" }));
await userEvent.keyboard("{Escape}");
expect(onClose).toHaveBeenCalledTimes(3);
expect(preview).not.toHaveBeenCalled();
});
it("reads the differences in the same dialog, wider, and offers the restore as the main action", async () => {
draw();
await userEvent.type(await screen.findByLabelText(/口令/), "secret-pass");
await userEvent.click(screen.getByRole("button", { name: "预览差异" }));
const dialog = await screen.findByRole("dialog", { name: "恢复备份" });
await waitFor(() => expect(dialog.hasAttribute("data-wide") || dialog.querySelector(".bk-plan")).toBeTruthy());
expect(dialog.textContent).toContain("general");
expect(screen.getByRole("button", { name: /恢复所选/ }).hasAttribute("data-primary")).toBe(true);
});
it("says what came of the restore in one strip per outcome, in the page", async () => {
draw();
await userEvent.type(await screen.findByLabelText(/口令/), "secret-pass");
await userEvent.click(screen.getByRole("button", { name: "预览差异" }));
await userEvent.click(await screen.findByRole("checkbox"));
await userEvent.click(screen.getByRole("button", { name: /恢复所选/ }));
await waitFor(() => expect(document.querySelector(".bk-result")).toBeTruthy());
expect(screen.queryByRole("dialog")).toBeNull();
expect(document.querySelector('.bk-strip[data-tone="ok"]')?.textContent).toContain("已恢复 1 项。");
});
});
+61 -48
View File
@@ -1,8 +1,10 @@
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { t } from "../i18n";
import { reason } from "../i18n/kernel";
import type { AgentPort, BackupApplyResult, BackupCategory, BackupEntry, BackupPlan, BackupPlanItem, BackupPluginRef } from "../port/port";
import { AddPlugin } from "./AddPlugin";
import { StudioIcon } from "./StudioIcon";
export const CATEGORY_LABEL: Record<BackupCategory, string> = {
settings: "data.models_and_interface",
@@ -26,6 +28,25 @@ const CONSENT_LABEL: Record<string, string> = {
imports: "data.i_have_read_this_instruction",
};
// A restore is asked for and read in a dialog; what it did is said afterwards,
// in the page, in one strip per outcome.
function RestoreDialog({ title, onClose, footer, wide, children }: { title: string; onClose: () => void; footer: ReactNode; wide?: boolean; children: ReactNode }) {
return createPortal(
<div className="ns-veil" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="ns-dlg bk-dlg" data-wide={wide ? "" : undefined} role="dialog" aria-modal="true" aria-label={title}
onKeyDown={(e) => { if (e.key === "Escape") { e.stopPropagation(); onClose(); } }}>
<header>
<h3>{title}</h3>
<button type="button" className="ns-x" aria-label={t("common.close")} title={t("common.close")} onClick={onClose}><StudioIcon name="close" /></button>
</header>
<div className="bk-dlg-body">{children}</div>
<footer>{footer}</footer>
</div>
</div>,
document.body,
);
}
// The preview is the kernel's: which rows need consent, and why, is decided
// there and rechecked on apply. This panel only collects two separate answers
// per row — take it, and (where asked) allow what it does.
@@ -100,35 +121,28 @@ export function BackupRestore({ port, source, onClose }: { port: AgentPort; sour
if (result) {
return (
<div className="bk-restore" data-action="backup.result">
<p className="acct-note">{t("data.restored_n_items", { n: result.applied.length })}</p>
{result.failed?.map((f) => (
<p key={f.id} className="acct-note" data-err="">
{f.id}:{f.error}
</p>
))}
{result.reloadError && <p className="acct-note">{t("data.the_setup_was_written_but", { e: result.reloadError })}</p>}
{!!result.plugins?.length && (
<>
<p className="acct-note">{t("data.these_plugins_go_through_the")}</p>
<ul className="bk-list">
{result.plugins.map((p) => (
<li key={p.name} className="bk-row">
<div className="who">
<span className="nm">{p.name}</span>
<code className="meta">{p.source}{p.version ? " · " + p.version : ""}</code>
</div>
<button className="btn sm" data-action="backup.install-plugin" data-target={p.name} disabled={installing?.name === p.name} onClick={() => setInstalling({ ...p })}>
{t("data.install")}
</button>
</li>
))}
</ul>
</>
)}
<div className="bk-acts">
<button className="act" data-action="backup.done" onClick={onClose}>{t("common.done")}</button>
<div className="bk-result" data-action="backup.result">
<div className="bk-strip" data-tone="ok">
<StudioIcon name="check" /><span>{t("data.restored_n_items", { n: result.applied.length })}</span>
<button type="button" className="ns-cancel" data-action="backup.done" onClick={onClose}>{t("common.done")}</button>
</div>
{result.failed?.map((f) => (
<div key={f.id} className="bk-strip" data-tone="warn" role="alert"><StudioIcon name="warning" /><span>{f.id}:{f.error}</span></div>
))}
{result.reloadError && <div className="bk-strip" data-tone="warn"><StudioIcon name="warning" /><span>{t("data.the_setup_was_written_but", { e: result.reloadError })}</span></div>}
{!!result.plugins?.length && (
<div className="bk-strip" data-tone="plain" data-plugins="">
<span>{t("data.these_plugins_go_through_the")}</span>
{result.plugins.map((p) => (
<span key={p.name} className="bk-plug">
<code>{p.name}{p.version ? " " + p.version : ""}</code>
<button className="btn sm" data-action="backup.install-plugin" data-target={p.name} disabled={installing?.name === p.name} onClick={() => setInstalling({ ...p })}>
{t("data.install")}
</button>
</span>
))}
</div>
)}
{installing && (
<AddPlugin key={installing.name} port={port} source={installing.source} onClose={() => setInstalling(null)} onInstalled={() => setInstalling((current) => current === installing ? null : current)} />
)}
@@ -138,27 +152,33 @@ export function BackupRestore({ port, source, onClose }: { port: AgentPort; sour
if (!plan) {
return (
<div className="bk-restore">
<RestoreDialog title={t("data.restore_a_backup")} onClose={onClose} footer={<>
<button type="button" className="ns-cancel" data-action="backup.cancel" onClick={onClose}>{t("common.cancel")}</button>
<button type="button" className="btn" data-primary data-action="backup.preview" disabled={!pass || busy} onClick={() => void open()}>
<StudioIcon name="lock" />{t(busy ? "data.decrypting" : "data.preview_differences")}
</button>
</>}>
<p className="acct-note">{t("data.enter_the_passphrase_for_name", { name: source.name })}</p>
<div className="bk-fields">
<div className="bk-fields" data-one="">
<label className="grow">
<span>{t("data.passphrase")}</span>
<input data-action="backup.passphrase" data-target="open" type="password" autoComplete="current-password" disabled={busy} value={pass} onChange={(e) => setPass(e.target.value)} />
<input autoFocus data-action="backup.passphrase" data-target="open" type="password" autoComplete="current-password" disabled={busy} value={pass} onChange={(e) => setPass(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && pass && !busy) void open(); }} />
</label>
</div>
<div className="bk-acts">
<button className="act" data-primary data-action="backup.preview" disabled={!pass || busy} onClick={() => void open()}>
{t(busy ? "data.decrypting" : "data.preview_differences")}
</button>
<button className="act" data-action="backup.cancel" onClick={onClose}>{t("common.cancel")}</button>
</div>
{error && <p className="acct-note" data-err="">{error}</p>}
</div>
</RestoreDialog>
);
}
return (
<div className="bk-restore">
<RestoreDialog title={t("data.restore_a_backup")} onClose={onClose} wide footer={<>
{blocked.length > 0 && <span className="acct-note">{t("data.n_items_still_need_confirmation", { n: blocked.length })}</span>}
<button type="button" className="ns-cancel" data-action="backup.cancel" onClick={onClose}>{t("common.cancel")}</button>
<button type="button" className="btn" data-primary data-action="backup.apply" disabled={busy || picked.size === 0 || blocked.length > 0} onClick={() => void apply()}>
{t(busy ? "data.restoring" : "data.restore_n_selected", { n: picked.size })}
</button>
</>}>
{!plan.samePlatform && (
<div className="find" data-lvl="warn" role="note">
<span className="t">{t("data.this_backup_comes_from_p", { p: plan.platform })}</span>
@@ -195,14 +215,7 @@ export function BackupRestore({ port, source, onClose }: { port: AgentPort; sour
))}
</ul>
{!!plan.omitted?.length && <p className="acct-note">{t("data.n_files_were_too_large", { n: plan.omitted.length })}</p>}
<div className="bk-acts">
<button className="act" data-primary data-action="backup.apply" disabled={busy || picked.size === 0 || blocked.length > 0} onClick={() => void apply()}>
{t(busy ? "data.restoring" : "data.restore_n_selected", { n: picked.size })}
</button>
<button className="act" data-action="backup.cancel" onClick={onClose}>{t("common.cancel")}</button>
{blocked.length > 0 && <span className="acct-note">{t("data.n_items_still_need_confirmation", { n: blocked.length })}</span>}
</div>
{error && <p className="acct-note" data-err="">{error}</p>}
</div>
</RestoreDialog>
);
}