mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
fix(ui): panel flicker (#8201)
* fix(ui): prevent full-page panel flicker * fix(workspace): cancel pending note reads before deletion
This commit is contained in:
1 parent
90fb37ffb7
commit
d9a62313c8
8 files changed
+351
-47
No files matched your search
@@ -22,7 +22,7 @@ interface FullScreenPanelProps {
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
/** Entry/exit motion. Nested navigation panels can opt into a horizontal transition. */
|
||||
/** Defaults to no motion to avoid revealing the underlying page during a fade. */
|
||||
motionPreset?: "none" | "fade" | "slide-from-right";
|
||||
/**
|
||||
* 覆盖内容区滚动容器的内边距/间距类。默认 `px-6 py-6 space-y-6`。
|
||||
@@ -73,7 +73,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
||||
children,
|
||||
footer,
|
||||
contentClassName,
|
||||
motionPreset = "fade",
|
||||
motionPreset = "none",
|
||||
trackUnsavedChanges = false,
|
||||
}) => {
|
||||
// 面板关掉后组件可能还挂着:只在打开时登记,关掉即撤销
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useState, useLayoutEffect, useCallback, useMemo } from "react";
|
||||
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff, RefreshCw } from "lucide-react";
|
||||
@@ -62,8 +62,8 @@ export function UniversalProviderFormModal({
|
||||
const [pendingProvider, setPendingProvider] =
|
||||
useState<UniversalProvider | null>(null);
|
||||
|
||||
// 初始化表单
|
||||
useEffect(() => {
|
||||
// Populate before paint so opening the panel never shows empty/stale fields.
|
||||
useLayoutEffect(() => {
|
||||
if (editingProvider) {
|
||||
// 编辑模式:加载现有数据
|
||||
setName(editingProvider.name);
|
||||
|
||||
@@ -53,7 +53,7 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
|
||||
// Edit state
|
||||
const [editingFile, setEditingFile] = useState<string | null>(null);
|
||||
const [content, setContent] = useState("");
|
||||
const [loadingContent, setLoadingContent] = useState(false);
|
||||
const contentRequestRef = useRef(0);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Delete state
|
||||
@@ -199,19 +199,25 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
|
||||
}, [isOpen, loadFiles]);
|
||||
|
||||
// Open file for editing
|
||||
useEffect(() => {
|
||||
// Ignore reads completed after closing the panel or unmounting it.
|
||||
return () => {
|
||||
contentRequestRef.current += 1;
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const openFile = useCallback(
|
||||
async (filename: string) => {
|
||||
setLoadingContent(true);
|
||||
setEditingFile(filename);
|
||||
const request = ++contentRequestRef.current;
|
||||
try {
|
||||
const data = await workspaceApi.readDailyMemoryFile(filename);
|
||||
if (request !== contentRequestRef.current) return;
|
||||
setContent(data ?? "");
|
||||
setEditingFile(filename);
|
||||
} catch (err) {
|
||||
if (request !== contentRequestRef.current) return;
|
||||
console.error("Failed to read daily memory file:", err);
|
||||
toast.error(t("workspace.dailyMemory.loadFailed"));
|
||||
setEditingFile(null);
|
||||
} finally {
|
||||
setLoadingContent(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
@@ -228,6 +234,7 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
|
||||
return;
|
||||
}
|
||||
// Open editor with empty content — no file created until user saves
|
||||
contentRequestRef.current += 1;
|
||||
setEditingFile(filename);
|
||||
setContent("");
|
||||
}, [files, openFile]);
|
||||
@@ -250,6 +257,8 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
|
||||
// Delete file
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deletingFile) return;
|
||||
// A pending read must not open the file during or after deletion.
|
||||
contentRequestRef.current += 1;
|
||||
try {
|
||||
await workspaceApi.deleteDailyMemoryFile(deletingFile);
|
||||
toast.success(t("workspace.dailyMemory.deleteSuccess"));
|
||||
@@ -291,6 +300,7 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
|
||||
|
||||
// Close panel entirely — clear search state
|
||||
const handleClose = useCallback(() => {
|
||||
contentRequestRef.current += 1;
|
||||
setEditingFile(null);
|
||||
setContent("");
|
||||
setIsSearchOpen(false);
|
||||
@@ -309,24 +319,18 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
|
||||
title={t("workspace.editing", { filename: editingFile })}
|
||||
onClose={handleBackToList}
|
||||
footer={
|
||||
<Button onClick={handleSave} disabled={saving || loadingContent}>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{loadingContent ? (
|
||||
<div className="flex items-center justify-center h-64 text-fg-2">
|
||||
{t("prompts.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
darkMode={isDarkMode}
|
||||
placeholder={`# ${editingFile}\n\n...`}
|
||||
minHeight="calc(100vh - 240px)"
|
||||
/>
|
||||
)}
|
||||
<MarkdownEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
darkMode={isDarkMode}
|
||||
placeholder={`# ${editingFile}\n\n...`}
|
||||
minHeight="calc(100vh - 240px)"
|
||||
/>
|
||||
</FullScreenPanel>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -490,7 +494,7 @@ const DailyMemoryPanel: React.FC<DailyMemoryPanelProps> = ({
|
||||
</div>
|
||||
)
|
||||
) : // --- Normal file list ---
|
||||
loadingList ? (
|
||||
loadingList && files.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-48 text-fg-2">
|
||||
{t("prompts.loading")}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -19,7 +19,9 @@ const WorkspaceFileEditor: React.FC<WorkspaceFileEditorProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [content, setContent] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadedFile, setLoadedFile] = useState<string | null>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
@@ -36,19 +38,26 @@ const WorkspaceFileEditor: React.FC<WorkspaceFileEditorProps> = ({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLoadedFile(null);
|
||||
if (!isOpen || !filename) return;
|
||||
|
||||
setLoading(true);
|
||||
let cancelled = false;
|
||||
workspaceApi
|
||||
.readFile(filename)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setContent(data ?? "");
|
||||
setLoadedFile(filename);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
console.error("Failed to read workspace file:", err);
|
||||
toast.error(t("workspace.loadFailed"));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
onCloseRef.current();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen, filename, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
@@ -66,28 +75,23 @@ const WorkspaceFileEditor: React.FC<WorkspaceFileEditorProps> = ({
|
||||
|
||||
return (
|
||||
<FullScreenPanel
|
||||
isOpen={isOpen}
|
||||
// Keep the file list visible until the editor content is ready.
|
||||
isOpen={isOpen && loadedFile === filename}
|
||||
title={t("workspace.editing", { filename })}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<Button onClick={handleSave} disabled={saving || loading}>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64 text-fg-2">
|
||||
{t("prompts.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
darkMode={isDarkMode}
|
||||
placeholder={`# ${filename}\n\n...`}
|
||||
minHeight="calc(100vh - 240px)"
|
||||
/>
|
||||
)}
|
||||
<MarkdownEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
darkMode={isDarkMode}
|
||||
placeholder={`# ${filename}\n\n...`}
|
||||
minHeight="calc(100vh - 240px)"
|
||||
/>
|
||||
</FullScreenPanel>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import DailyMemoryPanel from "@/components/workspace/DailyMemoryPanel";
|
||||
import { workspaceApi } from "@/lib/api/workspace";
|
||||
|
||||
vi.mock("@/lib/api/workspace", () => ({
|
||||
workspaceApi: {
|
||||
listDailyMemoryFiles: vi.fn(),
|
||||
readDailyMemoryFile: vi.fn(),
|
||||
deleteDailyMemoryFile: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/MarkdownEditor", () => ({
|
||||
default: ({ value }: { value: string }) => (
|
||||
<textarea aria-label="Content" readOnly value={value} />
|
||||
),
|
||||
}));
|
||||
|
||||
function deferred() {
|
||||
let resolve!: (value: string) => void;
|
||||
let reject!: (reason: Error) => void;
|
||||
const promise = new Promise<string>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(workspaceApi.listDailyMemoryFiles).mockResolvedValue([
|
||||
{
|
||||
filename: "2026-10-10.md",
|
||||
date: "2026-10-10",
|
||||
sizeBytes: 10,
|
||||
modifiedAt: 0,
|
||||
preview: "First note",
|
||||
},
|
||||
{
|
||||
filename: "2026-10-11.md",
|
||||
date: "2026-10-11",
|
||||
sizeBytes: 10,
|
||||
modifiedAt: 0,
|
||||
preview: "Second note",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("DailyMemoryPanel opening", () => {
|
||||
it.each(["before", "after"])(
|
||||
"ignores a pending read that resolves %s deletion completes",
|
||||
async (readTiming) => {
|
||||
const read = deferred();
|
||||
const deletion = deferred();
|
||||
vi.mocked(workspaceApi.readDailyMemoryFile).mockReturnValueOnce(
|
||||
read.promise,
|
||||
);
|
||||
vi.mocked(workspaceApi.deleteDailyMemoryFile).mockReturnValueOnce(
|
||||
deletion.promise.then(() => undefined),
|
||||
);
|
||||
render(<DailyMemoryPanel isOpen onClose={vi.fn()} />);
|
||||
const row = await screen.findByRole("button", { name: /First note/ });
|
||||
fireEvent.click(row);
|
||||
// The row's trailing delete control stops propagation to openFile.
|
||||
fireEvent.click(row.lastElementChild!);
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.confirm" }));
|
||||
expect(workspaceApi.deleteDailyMemoryFile).toHaveBeenCalledWith(
|
||||
"2026-10-10.md",
|
||||
);
|
||||
|
||||
if (readTiming === "before") {
|
||||
await act(async () => read.resolve("Deleted content"));
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
}
|
||||
vi.mocked(workspaceApi.listDailyMemoryFiles).mockResolvedValue([]);
|
||||
await act(async () => deletion.resolve(""));
|
||||
if (readTiming === "after") {
|
||||
await act(async () => read.resolve("Deleted content"));
|
||||
}
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "common.save" })).toBeNull();
|
||||
expect(screen.queryByText("First note")).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the list visible until the latest selected note is ready", async () => {
|
||||
const first = deferred();
|
||||
const second = deferred();
|
||||
vi.mocked(workspaceApi.readDailyMemoryFile)
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise);
|
||||
render(<DailyMemoryPanel isOpen onClose={vi.fn()} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: /First note/ }));
|
||||
expect(screen.getByText("Second note")).toBeVisible();
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Second note/ }));
|
||||
await act(async () => second.resolve("Second content"));
|
||||
expect(screen.getByRole("textbox")).toHaveValue("Second content");
|
||||
await act(async () => first.resolve("First content"));
|
||||
expect(screen.getByRole("textbox")).toHaveValue("Second content");
|
||||
});
|
||||
|
||||
it("ignores a read completed after closing and reopening", async () => {
|
||||
const read = deferred();
|
||||
vi.mocked(workspaceApi.readDailyMemoryFile).mockReturnValueOnce(
|
||||
read.promise,
|
||||
);
|
||||
const onClose = vi.fn();
|
||||
const view = render(<DailyMemoryPanel isOpen onClose={onClose} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: /First note/ }));
|
||||
view.rerender(<DailyMemoryPanel isOpen={false} onClose={onClose} />);
|
||||
await act(async () => read.resolve("Late content"));
|
||||
view.rerender(<DailyMemoryPanel isOpen onClose={onClose} />);
|
||||
expect(await screen.findByText("First note")).toBeVisible();
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the list visible when reading fails", async () => {
|
||||
const read = deferred();
|
||||
vi.mocked(workspaceApi.readDailyMemoryFile).mockReturnValueOnce(
|
||||
read.promise,
|
||||
);
|
||||
render(<DailyMemoryPanel isOpen onClose={vi.fn()} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: /First note/ }));
|
||||
await act(async () => read.reject(new Error("read failed")));
|
||||
expect(screen.getByText("First note")).toBeVisible();
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||
import { DRAG_REGION_ENABLED } from "@/lib/platform";
|
||||
@@ -14,6 +14,48 @@ const Panels = ({ innerOpen }: { innerOpen: boolean }) => (
|
||||
</>
|
||||
);
|
||||
|
||||
describe("FullScreenPanel motion", () => {
|
||||
it("is opaque from the first render, including after reopening", async () => {
|
||||
const panel = (isOpen: boolean) => (
|
||||
<FullScreenPanel isOpen={isOpen} title="Editor" onClose={() => undefined}>
|
||||
editor content
|
||||
</FullScreenPanel>
|
||||
);
|
||||
const view = render(panel(true));
|
||||
const surface = () => screen.getByText("editor content").closest(".bg-app");
|
||||
expect(surface()).toHaveStyle({ opacity: "1" });
|
||||
|
||||
view.rerender(panel(false));
|
||||
// If AnimatePresence retains the closing page, it must remain opaque.
|
||||
const closing = screen.queryByText("editor content")?.closest(".bg-app");
|
||||
if (closing) expect(closing).toHaveStyle({ opacity: "1" });
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("editor content")).toBeNull(),
|
||||
);
|
||||
|
||||
view.rerender(panel(true));
|
||||
expect(surface()).toHaveStyle({ opacity: "1" });
|
||||
});
|
||||
|
||||
it("keeps the explicit horizontal navigation transition", () => {
|
||||
render(
|
||||
<FullScreenPanel
|
||||
isOpen
|
||||
title="Authorization"
|
||||
onClose={() => undefined}
|
||||
motionPreset="slide-from-right"
|
||||
>
|
||||
authorization content
|
||||
</FullScreenPanel>,
|
||||
);
|
||||
const surface = screen
|
||||
.getByText("authorization content")
|
||||
.closest(".bg-app");
|
||||
expect(surface).toHaveStyle({ transform: "translateX(100%)" });
|
||||
expect(surface).not.toHaveStyle({ opacity: "0" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("FullScreenPanel body scroll locking", () => {
|
||||
afterEach(() => {
|
||||
document.body.style.overflow = "";
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import UsageScriptModal from "@/components/UsageScriptModal";
|
||||
import { PricingEditModal } from "@/components/usage/PricingEditModal";
|
||||
|
||||
vi.mock("@/lib/query", () => ({
|
||||
useSettingsQuery: () => ({ data: { usageConfirmed: true } }),
|
||||
}));
|
||||
vi.mock("@/lib/query/usage", () => ({
|
||||
useUpdateModelPricing: () => ({ mutateAsync: vi.fn(), isPending: false }),
|
||||
}));
|
||||
vi.mock("@/components/JsonEditor", () => ({ default: () => <div /> }));
|
||||
|
||||
describe("Usage pages render without a transparent opening frame", () => {
|
||||
it("opens usage query configuration fully opaque", () => {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<UsageScriptModal
|
||||
isOpen
|
||||
provider={{ id: "test", name: "Test Provider", settingsConfig: {} }}
|
||||
appId="claude"
|
||||
onClose={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(screen.getByRole("heading").closest(".bg-app")).toHaveStyle({
|
||||
opacity: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([true, false])("opens pricing fully opaque (isNew=%s)", (isNew) => {
|
||||
render(
|
||||
<PricingEditModal
|
||||
open
|
||||
isNew={isNew}
|
||||
model={{
|
||||
modelId: "test",
|
||||
displayName: "Test",
|
||||
inputCostPerMillion: "1",
|
||||
outputCostPerMillion: "2",
|
||||
cacheReadCostPerMillion: "0",
|
||||
cacheCreationCostPerMillion: "0",
|
||||
}}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("heading").closest(".bg-app")).toHaveStyle({
|
||||
opacity: "1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import WorkspaceFileEditor from "@/components/workspace/WorkspaceFileEditor";
|
||||
import { workspaceApi } from "@/lib/api/workspace";
|
||||
|
||||
vi.mock("@/lib/api/workspace", () => ({
|
||||
workspaceApi: { readFile: vi.fn(), writeFile: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/components/MarkdownEditor", () => ({
|
||||
default: ({ value }: { value: string }) => (
|
||||
<textarea aria-label="Content" readOnly value={value} />
|
||||
),
|
||||
}));
|
||||
|
||||
function deferred() {
|
||||
let resolve!: (value: string) => void;
|
||||
let reject!: (reason: Error) => void;
|
||||
const promise = new Promise<string>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("WorkspaceFileEditor opening", () => {
|
||||
it("waits for content and ignores an older file read", async () => {
|
||||
const first = deferred();
|
||||
const second = deferred();
|
||||
vi.mocked(workspaceApi.readFile)
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise);
|
||||
const onClose = vi.fn();
|
||||
const view = render(
|
||||
<WorkspaceFileEditor isOpen filename="A.md" onClose={onClose} />,
|
||||
);
|
||||
expect(screen.queryByRole("heading")).toBeNull();
|
||||
expect(screen.queryByText("prompts.loading")).toBeNull();
|
||||
view.rerender(
|
||||
<WorkspaceFileEditor isOpen filename="B.md" onClose={onClose} />,
|
||||
);
|
||||
await act(async () => second.resolve("B content"));
|
||||
expect(screen.getByRole("textbox")).toHaveValue("B content");
|
||||
expect(screen.getByRole("textbox").closest(".bg-app")).toHaveStyle({
|
||||
opacity: "1",
|
||||
});
|
||||
await act(async () => first.resolve("A content"));
|
||||
expect(screen.getByRole("textbox")).toHaveValue("B content");
|
||||
});
|
||||
|
||||
it("does not reopen after closing during a read", async () => {
|
||||
const read = deferred();
|
||||
vi.mocked(workspaceApi.readFile).mockReturnValueOnce(read.promise);
|
||||
const onClose = vi.fn();
|
||||
const view = render(
|
||||
<WorkspaceFileEditor isOpen filename="A.md" onClose={onClose} />,
|
||||
);
|
||||
view.rerender(
|
||||
<WorkspaceFileEditor isOpen={false} filename="A.md" onClose={onClose} />,
|
||||
);
|
||||
await act(async () => read.resolve("late content"));
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns to the list on failure instead of opening an empty editor", async () => {
|
||||
const read = deferred();
|
||||
vi.mocked(workspaceApi.readFile).mockReturnValueOnce(read.promise);
|
||||
const onClose = vi.fn();
|
||||
render(<WorkspaceFileEditor isOpen filename="A.md" onClose={onClose} />);
|
||||
await act(async () => read.reject(new Error("read failed")));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user