fix(sql-file): cache preview highlighting during progress updates

Refs: #9537
This commit is contained in:
t8y2 committed 2026-09-22 19:05:00 +08:00
1 parent c1f2bbda44
commit fc19cae39c
2 files changed
+72 -2

No files matched your search

@@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({
ensureConnected: vi.fn(),
executeSqlFiles: vi.fn(),
fetchSqlFileTargetOptions: vi.fn(),
highlight: vi.fn(),
highlighterState: undefined as undefined | { ready: boolean; appearance: string },
listenSqlFileProgress: vi.fn(),
openFileDialog: vi.fn(),
previewSqlFile: vi.fn(),
@@ -40,7 +42,7 @@ vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/lib/common/utils", () => ({ uuid: mocks.uuid }));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.openFileDialog }));
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: (sql: string) => sql }) }));
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: mocks.highlight }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
vi.mock("@/composables/useExportTracker", () => ({
useExportTracker: () => ({ addSqlFileTask: mocks.addSqlFileTask, updateSqlFileTask: mocks.updateSqlFileTask }),
@@ -175,6 +177,8 @@ function deferred() {
beforeEach(() => {
vi.clearAllMocks();
mocks.progressHandler = undefined;
mocks.highlighterState = reactive({ ready: false, appearance: "light" });
mocks.highlight.mockReset().mockImplementation((sql: string) => (mocks.highlighterState!.ready ? `<span data-theme="${mocks.highlighterState!.appearance}">${sql}</span>` : sql));
mocks.trackerTask = undefined;
mocks.addSqlFileTask.mockImplementation((exportId: string, tableName: string, filePath: string) => {
mocks.trackerTask = reactive({
@@ -241,6 +245,71 @@ afterEach(() => {
});
describe("SqlFileExecutionDialog retries", () => {
it("reuses the unchanged preview across progress and failure updates while keeping controls responsive", async () => {
const onOpenChange = vi.fn();
await mountReadyDialog(onOpenChange);
const gate = deferred();
mocks.executeSqlFiles.mockImplementationOnce(() => gate.promise);
findButton("sqlFile.execute").click();
await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalledOnce());
mocks.highlight.mockClear();
try {
for (let statementIndex = 1; statementIndex <= 100; statementIndex += 1) {
mocks.progressHandler?.(progress("run-1", "statementDone", { statementIndex, successCount: statementIndex, bytesRead: statementIndex, totalBytes: 200 }));
await nextTick();
}
mocks.progressHandler?.(progress("run-1", "statementFailed", { statementIndex: 101, failureCount: 1, error: "statement rejected", statementSummary: "insert into missing_table values (1)" }));
await nextTick();
expect(mocks.highlight).not.toHaveBeenCalled();
expect(root!.textContent).toContain("statement rejected");
findButton("sqlFile.runInBackground").click();
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(mocks.cancelSqlFileExecution).not.toHaveBeenCalled();
findButton("sqlFile.cancel").click();
await vi.waitFor(() => expect(mocks.cancelSqlFileExecution).toHaveBeenCalledWith("run-1"));
} finally {
mocks.progressHandler?.(progress("run-1", "cancelled"));
gate.resolve();
}
await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
});
it("refreshes the preview after highlighter initialization and appearance changes", async () => {
await mountReadyDialog();
mocks.highlight.mockClear();
expect(root!.querySelector("pre")!.textContent).toBe("select 1;");
mocks.highlighterState!.ready = true;
await nextTick();
expect(mocks.highlight).toHaveBeenCalledOnce();
expect(root!.querySelector("pre span")!.getAttribute("data-theme")).toBe("light");
mocks.highlighterState!.appearance = "dark";
await nextTick();
expect(mocks.highlight).toHaveBeenCalledTimes(2);
expect(root!.querySelector("pre span")!.getAttribute("data-theme")).toBe("dark");
expect(root!.querySelector("pre")!.textContent).toBe("select 1;");
});
it("refreshes the preview when switching files and reloading the same path", async () => {
mocks.previewSqlFile.mockImplementation(async (filePath: string) => ({ filePath, fileName: filePath.split("/").pop()!, sizeBytes: 9, preview: filePath.endsWith("first.sql") ? "select 1;" : "select 2;", canExecuteWithoutSelectedDatabase: true }));
await mountReadyDialog();
mocks.highlight.mockClear();
findButton("second.sql").click();
await nextTick();
expect(mocks.highlight).toHaveBeenCalledOnce();
expect(root!.querySelector("pre")!.textContent).toBe("select 2;");
mocks.openFileDialog.mockResolvedValueOnce(["/tmp/second.sql"]);
mocks.previewSqlFile.mockResolvedValueOnce({ filePath: "/tmp/second.sql", fileName: "second.sql", sizeBytes: 9, preview: "select 3;", canExecuteWithoutSelectedDatabase: true });
findButton("sqlFile.browse").click();
await vi.waitFor(() => expect(root!.querySelector("pre")!.textContent).toBe("select 3;"));
expect(mocks.highlight).toHaveBeenLastCalledWith("select 3;");
});
it("allows an ordinary execution to continue in the background and close after completion", async () => {
const onOpenChange = vi.fn();
await mountReadyDialog(onOpenChange);
@@ -62,6 +62,7 @@ watch(previews, (list) => {
const activePreview = computed<SqlFilePreview | null>(() => {
return previews.value.find((item) => item.filePath === activePreviewPath.value) ?? previews.value[0] ?? null;
});
const activePreviewHtml = computed(() => (activePreview.value ? highlight(activePreview.value.preview) : ""));
// Disambiguate files that share the same fileName.
// Desktop: prepend parent directory segments until unique (e.g. migration/create.sql).
@@ -847,7 +848,7 @@ watch(
<div class="sticky left-0 z-10 select-none border-r bg-background/95 px-2 py-3 text-right font-mono leading-5 text-muted-foreground/70">
<div v-for="n in previewLineCount(activePreview)" :key="n">{{ n }}</div>
</div>
<pre class="min-w-max flex-1 p-3 font-mono leading-5 whitespace-pre" v-html="highlight(activePreview.preview)"></pre>
<pre class="min-w-max flex-1 p-3 font-mono leading-5 whitespace-pre" v-html="activePreviewHtml"></pre>
</div>
</div>
</div>