mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(studio): native session import is a dialog with a dashed drop area, theme and session import results are the recovery board's green and warning strips
This commit is contained in:
1 parent
851f5fe19f
commit
c8eb8693fa
7 files changed
+116
-14
No files matched your search
@@ -40,3 +40,28 @@
|
||||
.bk-fields > label { display: block; }
|
||||
.bk-fields > label > span { display: block; margin: 10px 0 6px; font: var(--w-med) var(--fs-meta)/1.4 var(--ui); color: var(--muted); }
|
||||
.bk-fields input { width: 100%; height: 32px; box-sizing: border-box; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface); color: var(--text); font: var(--w-reg) var(--fs-ui) var(--ui); }
|
||||
.ns-done, .ns-fail { display: flex; align-items: center; gap: 8px; margin-top: 8px; padding: 10px 12px; border-radius: var(--r-md); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); }
|
||||
.ns-done { background: color-mix(in srgb, var(--ok) 10%, transparent); }
|
||||
.ns-done .studio-icon { color: var(--ok-ink); }
|
||||
.ns-fail { background: color-mix(in srgb, var(--warn) 12%, transparent); }
|
||||
.ns-fail .studio-icon { color: var(--warn-ink); }
|
||||
.ns-done .studio-icon, .ns-fail .studio-icon { flex: none; width: 16px; height: 16px; }
|
||||
.ns-veil { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 20px; background: color-mix(in oklab, var(--page) 80%, transparent); }
|
||||
.ns-dlg { width: 440px; max-width: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float); }
|
||||
.ns-dlg > header { display: flex; align-items: center; gap: 8px; padding: 14px 16px 0; }
|
||||
.ns-dlg > header h3 { flex: 1; margin: 0; font: var(--w-semi) var(--fs-lead)/1.4 var(--ui); }
|
||||
.ns-dlg .ns-x { width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--muted); display: inline-grid; place-items: center; cursor: pointer; }
|
||||
.ns-dlg .ns-x:hover { background: var(--overlay); color: var(--text); }
|
||||
.ns-dlg > p { margin: 0; padding: 10px 16px 0; color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.7 var(--ui); }
|
||||
.ns-dlg .ns-drop { display: flex; flex-direction: column; align-items: center; gap: 8px; margin: 12px 16px 16px; padding: 22px 16px; border: 1px dashed var(--border); border-radius: var(--r-md); color: var(--muted); font: var(--w-reg) var(--fs-ui)/1.5 var(--ui); }
|
||||
.ns-dlg .ns-drop[data-over] { border-color: var(--accent); background: var(--overlay); }
|
||||
.ns-dlg .ns-drop .studio-icon { width: 18px; height: 18px; color: var(--faint); }
|
||||
.ns-dlg .ns-drop .btn { 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 > footer { display: flex; justify-content: flex-end; padding: 10px 16px; border-top: 1px solid var(--hair); }
|
||||
.ns-dlg .ns-cancel { 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; }
|
||||
.ns-dlg .ns-cancel:hover { background: var(--overlay); color: var(--text); }
|
||||
.themeacts[data-landed] { padding: 8px 8px 8px 12px; border-radius: var(--r-md); background: color-mix(in srgb, var(--ok) 10%, transparent); }
|
||||
.themeacts[data-landed] .note { color: var(--text); }
|
||||
.themeacts .btn.sm, .themeacts .btn.sm:hover { 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); }
|
||||
.themefail { display: flex; align-items: center; margin-top: 8px; padding: 10px 12px; border: 0; border-radius: var(--r-md); background: color-mix(in srgb, var(--warn) 12%, transparent); color: var(--text); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); }
|
||||
.pal-rm[data-confirming], .pal-rm.btn.sm[aria-busy] { color: var(--err-ink); }
|
||||
@@ -36,3 +36,19 @@ describe("the export chips follow the board", () => {
|
||||
expect(rule(".bk-fields")).toMatch(/grid-template-columns:\s*1fr 1fr/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the import results follow the board", () => {
|
||||
it("draws a landed theme as a green strip and a refused one as a warning strip", () => {
|
||||
expect(rule(".themeacts[data-landed]")).toMatch(/var\(--ok\) 10%/);
|
||||
expect(rule(".themefail")).toMatch(/var\(--warn\) 12%/);
|
||||
});
|
||||
|
||||
it("draws the armed delete in the error tone", () => {
|
||||
expect(css).toContain('.pal-rm[data-confirming]');
|
||||
});
|
||||
|
||||
it("draws the session-import placeholder as a 440px dialog with a dashed drop area", () => {
|
||||
expect(rule(".ns-dlg")).toMatch(/width:\s*440px/);
|
||||
expect(rule(".ns-dlg .ns-drop")).toMatch(/border:\s*1px dashed var\(--border\)/);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ export const DATA: Messages = {
|
||||
"data.decrypting": { zh: "正在解密…", en: "Decrypting…" },
|
||||
"data.differs_from_this_machine": { zh: "与本机不同", en: "Differs from this machine" },
|
||||
"data.drive_free_free_of_total": { zh: "{drive} 剩余 {free} / {total}", en: "{drive} — {free} free of {total}" },
|
||||
"data.drop_a_session_json_file_here": { zh: "把会话 JSON 文件拖到这里", en: "Drop a session JSON file here" },
|
||||
"data.e_g_work_laptop": { zh: "例如:公司笔记本", en: "e.g. work laptop" },
|
||||
"data.encrypting": { zh: "正在加密…", en: "Encrypting…" },
|
||||
"data.encrypting_and_uploading": { zh: "正在加密上传…", en: "Encrypting and uploading…" },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { MockHub } from "../port/mock_hub";
|
||||
@@ -48,9 +48,39 @@ it("offers the file through the app's own button, with the native input out of s
|
||||
expect(input.hidden).toBe(true);
|
||||
const open = vi.spyOn(input, "click");
|
||||
await userEvent.click(screen.getByRole("button", { name: "选择会话文件…" }));
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog.textContent).toContain("把会话 JSON 文件拖到这里");
|
||||
await userEvent.click(screen.getByRole("button", { name: "选择会话 JSON 文件" }));
|
||||
expect(open).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("takes a file dropped on the dashed area and closes the dialog", async () => {
|
||||
const hub = new MockHub();
|
||||
const imported = vi.spyOn(hub, "importNativeSession");
|
||||
render(<NativeSessionImport hub={hub} workspace="/workspace" onImported={vi.fn()}/>);
|
||||
await userEvent.click(screen.getByRole("button", { name: "选择会话文件…" }));
|
||||
const drop = document.querySelector(".ns-drop")!;
|
||||
const file = new File(["{}"], "s.json", { type: "application/json" });
|
||||
fireEvent.drop(drop, { dataTransfer: { files: [file] } });
|
||||
await screen.findByRole("status");
|
||||
expect(imported).toHaveBeenCalledWith(file, "/workspace");
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes the dialog with Cancel and with Escape without importing anything", async () => {
|
||||
const hub = new MockHub();
|
||||
const imported = vi.spyOn(hub, "importNativeSession");
|
||||
render(<NativeSessionImport hub={hub} workspace="/workspace" onImported={vi.fn()}/>);
|
||||
await userEvent.click(screen.getByRole("button", { name: "选择会话文件…" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "取消" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
await userEvent.click(screen.getByRole("button", { name: "选择会话文件…" }));
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(imported).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("holds the button while an import is running", async () => {
|
||||
const hub = new MockHub();
|
||||
let finish: () => void = () => {};
|
||||
|
||||
@@ -1,36 +1,65 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { HubPort } from "../port/hub";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
|
||||
export function NativeSessionImport({ hub, workspace, onImported }: { hub: HubPort; workspace: string; onImported: () => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [asking, setAsking] = useState(false);
|
||||
const [over, setOver] = useState(false);
|
||||
const take = (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
setAsking(false);
|
||||
setBusy(true); setError(""); setMessage("");
|
||||
void (async () => {
|
||||
await hub.importNativeSession(file, workspace);
|
||||
setMessage(t("data.session_imported_with_its_original"));
|
||||
onImported();
|
||||
})().catch((e) => setError(reason(e))).finally(() => setBusy(false));
|
||||
};
|
||||
return <section className="grp">
|
||||
<h3 className="lbl">{t("data.import_a_native_session")}</h3>
|
||||
<div className="item">
|
||||
<div className="l">
|
||||
<b>{t("data.import_from_a_session_file")}</b>
|
||||
<span className="hint">{t("data.keep_the_composition_version_and")}</span>
|
||||
{message && <span className="ready" role="status">{message}</span>}
|
||||
{error && <span className="held" role="alert">{error}</span>}
|
||||
</div>
|
||||
<input ref={input} hidden type="file" accept="application/json,.json" aria-label={t("data.choose_a_session_json_file")} data-action-change="session.import-native" disabled={busy || !workspace} onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setBusy(true); setError(""); setMessage("");
|
||||
void (async () => {
|
||||
await hub.importNativeSession(file, workspace);
|
||||
setMessage(t("data.session_imported_with_its_original"));
|
||||
onImported();
|
||||
})().catch((e) => setError(reason(e))).finally(() => setBusy(false));
|
||||
take(event.target.files?.[0]);
|
||||
event.target.value = "";
|
||||
}} />
|
||||
<button className="btn" data-action="session.import-native" disabled={busy || !workspace} onClick={() => input.current?.click()}>
|
||||
<button className="btn" data-action="session.import-native" disabled={busy || !workspace} onClick={() => setAsking(true)}>
|
||||
{t(busy ? "common.importing" : "data.choose_a_session_file")}
|
||||
</button>
|
||||
</div>
|
||||
{message && <div className="ns-done" role="status"><StudioIcon name="check" />{message}</div>}
|
||||
{error && <div className="ns-fail" role="alert"><StudioIcon name="warning" />{error}</div>}
|
||||
{asking && createPortal(
|
||||
<div className="ns-veil" onMouseDown={(e) => { if (e.target === e.currentTarget) setAsking(false); }}>
|
||||
<div className="ns-dlg" role="dialog" aria-modal="true" aria-labelledby="ns-title"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") { e.stopPropagation(); setAsking(false); } }}>
|
||||
<header>
|
||||
<h3 id="ns-title">{t("data.import_a_native_session")}</h3>
|
||||
<button type="button" className="ns-x" aria-label={t("common.close")} title={t("common.close")} onClick={() => setAsking(false)}><StudioIcon name="close" /></button>
|
||||
</header>
|
||||
<p>{t("data.keep_the_composition_version_and")}</p>
|
||||
<div className="ns-drop" data-over={over ? "" : undefined}
|
||||
onDragOver={(e) => { e.preventDefault(); setOver(true); }}
|
||||
onDragLeave={() => setOver(false)}
|
||||
onDrop={(e) => { e.preventDefault(); setOver(false); take(e.dataTransfer.files?.[0]); }}>
|
||||
<StudioIcon name="file" />
|
||||
<span>{t("data.drop_a_session_json_file_here")}</span>
|
||||
<button type="button" className="btn" autoFocus data-action="session.import-native-choose" onClick={() => input.current?.click()}>{t("data.choose_a_session_json_file")}</button>
|
||||
</div>
|
||||
<footer><button type="button" className="ns-cancel" onClick={() => setAsking(false)}>{t("common.cancel")}</button></footer>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</section>;
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export function ThemeImport({ port, empty, onImported, onUse }: Props) {
|
||||
</span>
|
||||
{t(busy ? "common.importing" : "look.import_theme")}
|
||||
</button>
|
||||
<div className="themeacts">
|
||||
<div className="themeacts" data-landed={landed ? "" : undefined}>
|
||||
<p className="note" role="status" aria-atomic="true">
|
||||
{note || t(empty ? "look.no_themes_installed_choose_a" : "look.choose_a_zip_or_select")}
|
||||
</p>
|
||||
@@ -121,7 +121,7 @@ export function ThemeImport({ port, empty, onImported, onUse }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
{failed && (
|
||||
<div className="find" data-lvl="err" role="alert">
|
||||
<div className="find themefail" data-lvl="err" role="alert">
|
||||
<span className="t">{failed}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,7 @@ export function ThemeRemove({ name, onRemove }: { name: string; onRemove: () =>
|
||||
type="button"
|
||||
className="btn sm pal-rm"
|
||||
data-action="theme.remove"
|
||||
data-confirming={phase === "confirm" ? "" : undefined}
|
||||
aria-label={t("look.delete_theme_name", { name })}
|
||||
disabled={phase === "pending"}
|
||||
aria-busy={phase === "pending" || undefined}
|
||||
|
||||
Reference in new issue
Block a user