Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 15 additions & 45 deletions src/components/ai-edition/NewEditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,15 +302,9 @@ export function NewEditorShell() {
// 3. Handle request-save-before-close from Electron
const unsubSaveBeforeClose = window.electronAPI.onRequestSaveBeforeClose(async () => {
const doc = useProjectStore.getState().document;
if (doc) {
try {
await saveDocument(doc);
return true;
} catch {
toast.error("Failed to save before closing");
return false;
}
}
// The store already toasted the reason; answering false is what keeps the
// window open on top of it.
if (doc) return await saveDocument(doc);
return true;
});

Expand Down Expand Up @@ -618,14 +612,7 @@ export function NewEditorShell() {
const handleSave = useCallback(async () => {
const doc = useProjectStore.getState().document;
if (!doc) return;
try {
await saveDocument(doc);
toast.success("Project saved");
} catch (err) {
toast.error("Save failed", {
description: err instanceof Error ? err.message : String(err),
});
}
if (await saveDocument(doc)) toast.success("Project saved");
}, [saveDocument]);

// Native File menu (electron/main.ts) → v4 actions. The menu is shown via
Expand Down Expand Up @@ -653,13 +640,7 @@ export function NewEditorShell() {
const doc = useProjectStore.getState().document;
if (!doc) return;
if (title === doc.project.title) return;
try {
await saveDocument({ ...doc, project: { ...doc.project, title } });
} catch (err) {
toast.error("Rename failed", {
description: err instanceof Error ? err.message : String(err),
});
}
await saveDocument({ ...doc, project: { ...doc.project, title } });
},
[saveDocument],
);
Expand All @@ -680,13 +661,12 @@ export function NewEditorShell() {
}
if (choice === "save") {
const doc = useProjectStore.getState().document;
if (doc) {
try {
await saveDocument(doc);
} catch {
resolve("cancel");
return;
}
// A failed save cancels the action that prompted this dialog. The store has
// already said why -- which is what the bare `catch {}` here used to swallow,
// leaving the window refusing to close with nothing on screen explaining it.
if (doc && !(await saveDocument(doc))) {
resolve("cancel");
return;
}
}
if (action === "record") {
Expand Down Expand Up @@ -845,13 +825,8 @@ export function NewEditorShell() {
if (choice === "cancel") return;
if (choice === "save") {
const doc = useProjectStore.getState().document;
if (doc) {
try {
await saveDocument(doc);
} catch {
return;
}
}
// Stay put if the save did not land -- the store has already said why.
if (doc && !(await saveDocument(doc))) return;
}
setNewProjectOpen(true);
})();
Expand All @@ -864,13 +839,8 @@ export function NewEditorShell() {
if (choice === "cancel") return;
if (choice === "save") {
const doc = useProjectStore.getState().document;
if (doc) {
try {
await saveDocument(doc);
} catch {
return;
}
}
// Stay put if the save did not land -- the store has already said why.
if (doc && !(await saveDocument(doc))) return;
}
setOpenProjectOpen(true);
})();
Expand Down
4 changes: 4 additions & 0 deletions src/components/ai-edition/v4/V4Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,10 @@ export function V4Timeline({
return;
}
const added = await tl.addZoomsBulk(suggestions);
// A failed write returns 0 and has already toasted why. Without this the user
// got "Added 0 automatic zooms" stacked on top of "Failed to save project",
// with no zoom anywhere -- a success message for something that did not happen.
if (added === 0) return;
toast.success(
t(added === 1 ? "toolbar.addedAutoZoom" : "toolbar.addedAutoZoomPlural", { count: added }),
);
Expand Down
25 changes: 25 additions & 0 deletions src/i18n/toastText.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Translation for toasts fired OUTSIDE React -- stores, and anything else that reports
// to the user without a component around it to call `useScopedT`.

import { DEFAULT_LOCALE, type I18nNamespace, LOCALE_STORAGE_KEY, type Locale } from "./config";
import { getAvailableLocales, translate } from "./loader";

/**
* Toasts fired outside React still have to speak the user's language. Same source as
* `I18nProvider` (stored preference, else the default), validated so a stale value
* cannot push `translate` onto a locale it does not have.
*/
export function toastText(
namespace: I18nNamespace,
key: string,
vars?: Record<string, string | number>,
): string {
let locale: Locale = DEFAULT_LOCALE;
try {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored && getAvailableLocales().includes(stored as Locale)) locale = stored as Locale;
} catch {
// localStorage may be unavailable -- the default locale is a fine answer.
}
return translate(locale, namespace, key, vars);
}
52 changes: 52 additions & 0 deletions src/lib/ai-edition/store/projectStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,58 @@ describe("useProjectStore", () => {
expect(toastMocks.error.mock.calls[0][0]).toContain("video.mp4");
});

// The save boundary. Every write in the app funnels through `saveDocument`, and
// almost every caller `void`s it from a click handler, so what this function does
// with a failure IS what the user sees.
describe("saveDocument reports a failed write instead of rejecting", () => {
it("resolves false, tells the user, and leaves the document alone", async () => {
useProjectStore.setState({
projectId: "proj_test",
document: sampleDoc,
revision: 3,
status: "ready",
dirty: true,
});
bridgeMocks.save.mockResolvedValue({ success: false, error: "EACCES" });

const edited = { ...sampleDoc, project: { ...sampleDoc.project, title: "Edited" } };
await expect(useProjectStore.getState().saveDocument(edited)).resolves.toBe(false);

expect(toastMocks.error).toHaveBeenCalledWith("Failed to save project", {
description: "EACCES",
});
const state = useProjectStore.getState();
expect(state.document?.project.title).toBe("Test");
expect(state.revision).toBe(3);
// Still dirty: `dirty` is the only input to the beforeunload guard and to
// `setHasUnsavedChanges`, so a failed write is the last moment to claim clean.
expect(state.dirty).toBe(true);
});

it("never rejects, so a detached caller cannot leak an unhandled rejection", async () => {
useProjectStore.setState({ projectId: "proj_test", document: sampleDoc, dirty: true });
bridgeMocks.save.mockRejectedValue(new Error("bridge is gone"));

await expect(useProjectStore.getState().saveDocument(sampleDoc)).resolves.toBe(false);
expect(toastMocks.error).toHaveBeenCalledWith("Failed to save project", {
description: "bridge is gone",
});
});

it("resolves true and commits on success", async () => {
const saved = { ...sampleDoc, project: { ...sampleDoc.project, title: "Saved" } };
useProjectStore.setState({ projectId: "proj_test", document: sampleDoc, dirty: true });
bridgeMocks.save.mockResolvedValue({ success: true, document: saved });

await expect(useProjectStore.getState().saveDocument(saved)).resolves.toBe(true);

expect(toastMocks.error).not.toHaveBeenCalled();
const state = useProjectStore.getState();
expect(state.document?.project.title).toBe("Saved");
expect(state.dirty).toBe(false);
});
});

it("removeAsset requires a loaded project", async () => {
await expect(useProjectStore.getState().removeAsset("asset_x")).rejects.toThrow(
"No project loaded",
Expand Down
55 changes: 42 additions & 13 deletions src/lib/ai-edition/store/projectStore.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { toast } from "sonner";
import { create } from "zustand";
import { toastText } from "@/i18n/toastText";
import { nativeBridgeClient } from "@/native/client";
import {
type Interval,
Expand Down Expand Up @@ -36,7 +38,17 @@ export interface ProjectState {
refresh: () => Promise<void>;
addAsset: (path: string, label?: string) => Promise<AxcutAsset | null>;
removeAsset: (assetId: string) => Promise<void>;
saveDocument: (document: AxcutDocument) => Promise<void>;
/**
* Write the document to disk. Resolves `true` when it landed, `false` when it did
* not -- and a `false` has ALREADY been reported to the user and logged, so a
* caller that ignores it is choosing not to react, not choosing to stay silent.
*
* It never rejects, by design. Every save in the app funnels through here and
* almost all of them are `void`-ed from a click handler, so a rejection here was
* an unhandled rejection in the renderer with no toast, no log and no clue --
* change a caption font on a read-only project and the edit was simply gone.
*/
saveDocument: (document: AxcutDocument) => Promise<boolean>;
setDocument: (document: AxcutDocument) => void;
replaceTimeline: (intervals: Interval[], reason: string) => Promise<void>;
restoreFullTimeline: () => Promise<void>;
Expand Down Expand Up @@ -169,8 +181,10 @@ export const useProjectStore = create<ProjectState>((set, get) => ({
a.id === addedAsset.id ? { ...a, cameraTrack: linked } : a,
),
};
await get().saveDocument(next);
document = parseDocument(next);
// Only adopt the linked document if it actually reached disk -- otherwise
// the caller is handed a document claiming a camera link the file does not
// have. The store has already told the user the write failed.
if (await get().saveDocument(next)) document = parseDocument(next);
}
// success:false just means no camera was found for this asset —
// the normal case for a plain imported video. Nothing to surface.
Expand Down Expand Up @@ -212,17 +226,32 @@ export const useProjectStore = create<ProjectState>((set, get) => ({
},

async saveDocument(document) {
const result = await nativeBridgeClient.aiEdition.save(document);
if (!result.success || !result.document) {
throw new Error(result.error ?? "Failed to save project");
try {
const result = await nativeBridgeClient.aiEdition.save(document);
if (!result.success || !result.document) {
throw new Error(result.error ?? "Failed to save project");
}
const parsed = parseDocument(result.document);
set({
document: parsed,
revision: get().revision + 1,
dirty: false,
lastSavedAt: new Date(),
});
return true;
} catch (error) {
// Logged as well as toasted: a toast is gone in five seconds, and "my edit
// disappeared" gets reported much later than that.
console.error("[project] failed to save document:", error);
toast.error(toastText("editor", "project.failedToSave"), {
description: error instanceof Error ? error.message : String(error),
});
// `dirty` is deliberately left alone. It is the only input to the
// `beforeunload` guard and to `setHasUnsavedChanges`, so clearing it here
// would let the window close without a prompt on the one path where there is
// definitely something unsaved.
return false;
}
const parsed = parseDocument(result.document);
set({
document: parsed,
revision: get().revision + 1,
dirty: false,
lastSavedAt: new Date(),
});
},

setDocument(document) {
Expand Down
59 changes: 24 additions & 35 deletions src/lib/ai-edition/store/transcriptionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@
import { useEffect, useMemo } from "react";
import { toast } from "sonner";
import { create } from "zustand";
import { DEFAULT_LOCALE, LOCALE_STORAGE_KEY, type Locale } from "@/i18n/config";
import { getAvailableLocales, translate } from "@/i18n/loader";
import { toastText as translateToast } from "@/i18n/toastText";
import { transcribeAsset, withTranscript } from "../document/transcribe";
import type { AxcutDocument } from "../schema";
import {
Expand Down Expand Up @@ -85,21 +84,9 @@ function hasLocalSttEngine(): boolean {
return typeof window.electronAPI?.stt?.transcribe === "function";
}

/**
* Toasts fired outside React still have to speak the user's language. Same
* source as `I18nProvider` (stored preference, else the default), validated so
* a stale value can't push `translate` onto a locale it doesn't have.
*/
function toastText(key: string, vars?: Record<string, string | number>): string {
let locale: Locale = DEFAULT_LOCALE;
try {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored && getAvailableLocales().includes(stored as Locale)) locale = stored as Locale;
} catch {
// localStorage may be unavailable — the default locale is a fine answer.
}
return translate(locale, "editor", key, vars);
}
/** This store's toasts all live in the `editor` namespace. */
const toastText = (key: string, vars?: Record<string, string | number>) =>
translateToast("editor", key, vars);

export const useTranscriptionStore = create<TranscriptionState>((set, get) => ({
projectId: null,
Expand Down Expand Up @@ -321,24 +308,26 @@ async function persistPermanentFailure(
const doc = project.document;
if (!doc || doc.project.id !== projectId) return;
if (!doc.assets.some((a) => a.id === assetId)) return;
try {
await project.saveDocument({
...doc,
assets: doc.assets.map((a) =>
a.id === assetId
? {
...a,
transcriptionFailure: {
kind,
message: failure.message,
at: new Date().toISOString(),
},
}
: a,
),
});
} catch (error) {
console.warn("[transcription] could not persist the failure on the asset:", error);
// Best-effort bookkeeping: `saveDocument` reports its own failures and resolves
// false rather than throwing, and a note on the asset is not worth a second
// message on top of the one the user already got.
const persisted = await project.saveDocument({
...doc,
assets: doc.assets.map((a) =>
a.id === assetId
? {
...a,
transcriptionFailure: {
kind,
message: failure.message,
at: new Date().toISOString(),
},
}
: a,
),
});
if (!persisted) {
console.warn("[transcription] could not persist the failure on the asset");
}
}

Expand Down
Loading
Loading