Skip to content
Open
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
39 changes: 33 additions & 6 deletions clients/desktop/src-tauri/crates/lookout-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,21 @@ impl Core {
self.api_get(session_url(s, "/units")).await
}

/// `PUT /api/sessions/:token/cuts` — replace the cut list. `cuts` is the
/// JSON array of `CutInterval`s; `[]` clears all edits.
pub async fn session_set_cuts(&self, s: &SessionConfig, cuts: Value) -> ApiResult<Value> {
/// `PUT /api/sessions/:token/cuts` — replace the cut and mask lists. `cuts` is the
/// JSON array of `CutInterval`s; `masks` is the JSON array of `MaskRegion`s.
pub async fn session_set_cuts(
&self,
s: &SessionConfig,
cuts: Value,
masks: Option<Value>,
) -> ApiResult<Value> {
self.api_json(
reqwest::Method::PUT,
session_url(s, "/cuts"),
Some(serde_json::json!({ "cuts": cuts })),
Some(serde_json::json!({
"cuts": cuts,
"masks": masks.unwrap_or_else(|| serde_json::json!([])),
})),
API_TIMEOUT,
)
.await
Expand Down Expand Up @@ -632,12 +640,31 @@ mod tests {
assert_eq!(r["target"], "/api/sessions/tok/name");
assert_eq!(r["body"], r#"{"name":"My cut"}"#);
let c = core
.session_set_cuts(&cfg(&base), serde_json::json!([{ "start": 1, "end": 2 }]))
.session_set_cuts(
&cfg(&base),
serde_json::json!([{ "start": 1, "end": 2 }]),
None,
)
.await
.unwrap();
assert_eq!(c["method"], "PUT");
assert_eq!(c["target"], "/api/sessions/tok/cuts");
assert_eq!(c["body"], r#"{"cuts":[{"end":2,"start":1}]}"#);
assert_eq!(c["body"], r#"{"cuts":[{"end":2,"start":1}],"masks":[]}"#);

let c_masks = core
.session_set_cuts(
&cfg(&base),
serde_json::json!([{ "start": 1, "end": 2 }]),
Some(serde_json::json!([{ "x": 0.1, "y": 0.2 }])),
)
.await
.unwrap();
assert_eq!(c_masks["method"], "PUT");
assert_eq!(c_masks["target"], "/api/sessions/tok/cuts");
assert_eq!(
c_masks["body"],
r#"{"cuts":[{"end":2,"start":1}],"masks":[{"x":0.1,"y":0.2}]}"#
);
}

#[tokio::test]
Expand Down
6 changes: 5 additions & 1 deletion clients/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -640,9 +640,13 @@ async fn api_session_set_cuts(
token: String,
api_base_url: String,
cuts: Value,
masks: Option<Value>,
state: State<'_, AppState>,
) -> Result<Value, ApiError> {
state.core.session_set_cuts(&session_ref(token, api_base_url), cuts).await
state
.core
.session_set_cuts(&session_ref(token, api_base_url), cuts, masks)
.await
}

#[tauri::command]
Expand Down
4 changes: 3 additions & 1 deletion clients/desktop/src/api/tauriClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
ConfirmScreenshotResponse,
CutInterval,
EditHeartbeatResponse,
MaskRegion,
PauseResponse,
RenameSessionResponse,
ResumeResponse,
Expand Down Expand Up @@ -178,10 +179,11 @@ export function createTauriLookoutClient({
return call<UnitsResponse>("api_session_units", await session());
},

async setCuts(cuts: CutInterval[]) {
async setCuts(cuts: CutInterval[], masks?: MaskRegion[]) {
return call<SetCutsResponse>("api_session_set_cuts", {
...(await session()),
cuts,
masks: masks ?? [],
});
},

Expand Down
53 changes: 53 additions & 0 deletions clients/desktop/src/components/EditorWindow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { buildClosePromptMessage } from "./EditorWindow.js";

describe("buildClosePromptMessage", () => {
it("reports single cut when dirty", () => {
expect(buildClosePromptMessage(1, 0, true)).toBe(
"Closing publishes this timelapse with 1 cut applied. This can't be undone.",
);
});

it("reports multiple cuts when dirty", () => {
expect(buildClosePromptMessage(3, 0, true)).toBe(
"Closing publishes this timelapse with 3 cuts applied. This can't be undone.",
);
});

it("reports single mask when dirty", () => {
expect(buildClosePromptMessage(0, 1, true)).toBe(
"Closing publishes this timelapse with 1 mask applied. This can't be undone.",
);
});

it("reports multiple masks when dirty", () => {
expect(buildClosePromptMessage(0, 2, true)).toBe(
"Closing publishes this timelapse with 2 masks applied. This can't be undone.",
);
});

it("reports both cuts and masks when dirty", () => {
expect(buildClosePromptMessage(2, 3, true)).toBe(
"Closing publishes this timelapse with 2 cuts and 3 masks applied. This can't be undone.",
);
expect(buildClosePromptMessage(1, 1, true)).toBe(
"Closing publishes this timelapse with 1 cut and 1 mask applied. This can't be undone.",
);
});

it("reports published as recorded when cuts and masks are zero", () => {
expect(buildClosePromptMessage(0, 0, true)).toBe(
"Closing publishes this timelapse as recorded. This can't be undone.",
);
});

it("reports published as recorded when not dirty", () => {
expect(buildClosePromptMessage(2, 2, false)).toBe(
"Closing publishes this timelapse as recorded. This can't be undone.",
);
expect(buildClosePromptMessage(0, 0, false)).toBe(
"Closing publishes this timelapse as recorded. This can't be undone.",
);
});
});
161 changes: 85 additions & 76 deletions clients/desktop/src/components/EditorWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { emit } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { confirm } from "@tauri-apps/plugin-dialog";
import type { CutInterval } from "@lookout/react";
import type { CutInterval, MaskRegion } from "@lookout/react";
import { TimelapseEditor, colors, fontSize, fontWeight, spacing } from "@lookout/react";
import { invoke } from "../logger.js";
import { getApiBase } from "../serverConfig.js";
Expand Down Expand Up @@ -243,6 +243,21 @@ export function useEditorWindowOpen(): string | null {
* be pixel-matched against a position that varies by OS version, and it
* was visibly off.
*/
export function buildClosePromptMessage(
cutsCount: number,
masksCount: number,
isDirty: boolean,
): string {
const parts: string[] = [];
if (cutsCount > 0)
parts.push(`${cutsCount} cut${cutsCount === 1 ? "" : "s"}`);
if (masksCount > 0)
parts.push(`${masksCount} mask${masksCount === 1 ? "" : "s"}`);
if (isDirty && parts.length > 0)
return `Closing publishes this timelapse with ${parts.join(" and ")} applied. This can't be undone.`;
return "Closing publishes this timelapse as recorded. This can't be undone.";
}

export function EditorWindow({ token }: { token: string }) {
const isMacOS = navigator.userAgent.includes("Mac");
// Undecorated on Linux, same as the main window, so it owns its corners and
Expand Down Expand Up @@ -311,63 +326,54 @@ export function EditorWindow({ token }: { token: string }) {
// the session is unpublished until someone decides, so an editor that
// could be dismissed without deciding would just strand it until the
// lease lapsed. Hence: confirm, publish, then close.
const cutsRef = useRef<CutInterval[]>([]);
const dirtyRef = useRef(false);
const finishedRef = useRef(false);
const cutsRef = useRef<CutInterval[]>([]);
const masksRef = useRef<MaskRegion[]>([]);
const cutsDirtyRef = useRef(false);
const masksDirtyRef = useRef(false);
const finishedRef = useRef(false);

const finishAndClose = useCallback(async () => {
finishedRef.current = true;
let published: Awaited<ReturnType<typeof client.applyCuts>> | null = null;
try {
await client.setCuts(cutsRef.current);
published = await client.applyCuts();
} catch (e) {
console.error("[editor] publish on close failed:", e);
// Don't trap the user in a window they asked to close: the hold
// lapses on its own and publishes as recorded shortly after.
}
// Fire-and-forget: the close must not wait on the notification. Carry
// the publish result so the main window can fire the redirect the
// instant it's done (`complete`) or watch the compile to completion.
emit(EDITED_EVENT, {
token,
status: published?.status ?? null,
redirectUrl: published?.redirectUrl ?? null,
// Forwarded so the main window's edit-published handler can pick between
// panel and redirect the same way `handleCompleted` does. Without these,
// a session that has BOTH ends up with the redirect firing (guard sees
// null panelUrl) *and* the panel opening from the session page's own
// status poll — the reporter's "browser and sheet both opened" symptom.
panelUrl: published?.panelUrl ?? null,
panelResolved: published?.panelResolved ?? false,
}).catch((e) => console.error("[editor] emit failed:", e));
await closeEditorWindow();
}, [client, token]);
const finishAndClose = useCallback(async () => {
finishedRef.current = true;
let published: Awaited<ReturnType<typeof client.applyCuts>> | null = null;
try {
await client.setCuts(cutsRef.current, masksRef.current);
published = await client.applyCuts();
}
catch (e) {
console.error("[editor] publish on close failed:", e);
}
emit(EDITED_EVENT, {
token,
status: published?.status ?? null,
redirectUrl: published?.redirectUrl ?? null,
panelUrl: published?.panelUrl ?? null,
panelResolved: published?.panelResolved ?? false,
}).catch((e) => console.error("[editor] emit failed:", e));
await closeEditorWindow();
}, [client, token]);

useEffect(() => {
let unlisten: (() => void) | undefined;
void getCurrentWindow()
.onCloseRequested(async (event) => {
if (finishedRef.current) return;
event.preventDefault();
const removed = cutsRef.current.length;
const ok = await confirm(
dirtyRef.current && removed > 0
? `Closing publishes this timelapse with ${removed} cut${
removed === 1 ? "" : "s"
} applied. This can't be undone.`
: "Closing publishes this timelapse as recorded. This can't be undone.",
{ title: "Finish timelapse?", kind: "warning" },
);
if (ok) void finishAndClose();
})
.then((fn) => {
unlisten = fn;
});
return () => {
if (unlisten) unlisten();
};
}, [finishAndClose]);
useEffect(() => {
let unlisten: (() => void) | undefined;
void getCurrentWindow()
.onCloseRequested(async (event) => {
if (finishedRef.current) return;
event.preventDefault();
const cutsCount = cutsRef.current.length;
const masksCount = masksRef.current.length;
const isDirty = cutsDirtyRef.current || masksDirtyRef.current;
const ok = await confirm(
buildClosePromptMessage(cutsCount, masksCount, isDirty),
{ title: "Finish timelapse?", kind: "warning" },
);
if (ok) void finishAndClose();
})
.then((fn) => {
unlisten = fn;
});
return () => {
if (unlisten) unlisten();
};
}, [finishAndClose]);

return (
<div
Expand Down Expand Up @@ -419,26 +425,29 @@ export function EditorWindow({ token }: { token: string }) {
}}
>
<TimelapseEditor
token={token}
apiBaseUrl={getApiBase()}
client={client}
onCutsChange={(cuts, dirty) => {
cutsRef.current = cuts;
dirtyRef.current = dirty;
}}
onApplied={(result) => {
// Saved from inside the editor. Flag it first so the close
// handler doesn't prompt to publish what's already published.
finishedRef.current = true;
emit(EDITED_EVENT, {
token,
status: result.status,
redirectUrl: result.redirectUrl,
panelUrl: result.panelUrl ?? null,
panelResolved: result.panelResolved ?? false,
}).catch((e) => console.error("[editor] emit failed:", e));
void closeEditorWindow();
}}
token={token}
apiBaseUrl={getApiBase()}
client={client}
onCancel={() => void closeEditorWindow()}
onCutsChange={(cuts, dirty) => {
cutsRef.current = cuts;
cutsDirtyRef.current = dirty;
}}
onMasksChange={(masks, dirty) => {
masksRef.current = masks;
masksDirtyRef.current = dirty;
}}
onApplied={(result) => {
finishedRef.current = true;
emit(EDITED_EVENT, {
token,
status: result.status,
redirectUrl: result.redirectUrl,
panelUrl: result.panelUrl ?? null,
panelResolved: result.panelResolved ?? false,
}).catch((e) => console.error("[editor] emit failed:", e));
void closeEditorWindow();
}}
/>
</div>
</div>
Expand Down
17 changes: 17 additions & 0 deletions clients/desktop/src/testSetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class MemoryStorage implements Storage {
private store = new Map<string, string>();
get length(): number { return this.store.size; }
clear(): void { this.store.clear(); }
getItem(key: string): string | null { return this.store.get(key) ?? null; }
key(index: number): string | null { return Array.from(this.store.keys())[index] ?? null; }
removeItem(key: string): void { this.store.delete(key); }
setItem(key: string, value: string): void { this.store.set(key, String(value)); }
}

if (!globalThis.localStorage || typeof globalThis.localStorage.clear !== "function") {
Object.defineProperty(globalThis, "localStorage", {
value: new MemoryStorage(),
writable: true,
configurable: true,
});
}
1 change: 1 addition & 0 deletions clients/desktop/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ export default defineConfig({
// resolves the interop. Nothing here is stubbed out — the real modules
// load, so a test importing more of the shared package still gets it.
server: { deps: { inline: [/@squircle-js/, /@lookout\/react/] } },
setupFiles: ["./src/testSetup.ts"],
},
});
Loading