mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
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:
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 项。");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user