mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 23:01:26 +02:00
refactor: cleanup
This commit is contained in:
1 parent
598d3bd513
commit
dd42d46753
249 files changed
+67417
-6659
No files matched your search
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
type AgentRunView,
|
||||
type AgentStep,
|
||||
getAgentRun,
|
||||
isRunOver,
|
||||
onAgentStatus,
|
||||
onAgentStep,
|
||||
onAgentStream,
|
||||
startAgentRunEvents,
|
||||
stopAgentRunEvents,
|
||||
} from "@/lib/agent";
|
||||
|
||||
/**
|
||||
* One run, watched.
|
||||
*
|
||||
* Two sources feed the same list and they overlap on purpose. The transcript is
|
||||
* READ FIRST, over HTTP, so a page opened halfway through a run shows the steps
|
||||
* that already happened instead of an empty panel waiting for the next one. The
|
||||
* stream then replays the same transcript and continues live, and both are
|
||||
* merged by step index — the index is the server's own ordinal, so a replayed
|
||||
* step and its live twin collapse into one row rather than doubling it.
|
||||
*/
|
||||
export interface AgentRunSnapshot {
|
||||
/** The run as last described, or null before the first read. */
|
||||
run: AgentRunView | null;
|
||||
/** Everything it has done, in server order. */
|
||||
steps: AgentStep[];
|
||||
/** True only while the first read of a newly watched run is in flight. */
|
||||
isLoading: boolean;
|
||||
/** The last read failure, as a backend error envelope. */
|
||||
error: unknown;
|
||||
/** Whether steps are currently arriving. */
|
||||
streamConnected: boolean;
|
||||
}
|
||||
|
||||
export interface UseAgentRunResult extends AgentRunSnapshot {
|
||||
/** Re-read the run and its transcript. */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const EMPTY: AgentRunSnapshot = {
|
||||
run: null,
|
||||
steps: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
streamConnected: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Which run each mounted panel wants streamed, coalesced into one decision.
|
||||
*
|
||||
* The desktop streams a single run, and React runs an unmounting panel's
|
||||
* cleanup and the next panel's effect back to back. Sending a stop and a start
|
||||
* as two separate commands makes that swap a race: they are handled on the Rust
|
||||
* side as independent tasks, so a stop landing second kills the stream the new
|
||||
* panel just asked for and the run sits under "steps are not arriving" while
|
||||
* nothing reconnects. Coalescing into a microtask means only the settled wish
|
||||
* is ever sent — and `start_agent_run_events` already replaces whatever it was
|
||||
* watching, so switching runs is one command, not two.
|
||||
*/
|
||||
const wishes = new Map<string, string>();
|
||||
let applied: string | null = null;
|
||||
let applyScheduled = false;
|
||||
|
||||
function applyWishes() {
|
||||
let next: string | null = null;
|
||||
for (const wish of wishes.values()) next = wish;
|
||||
if (next === applied) return;
|
||||
applied = next;
|
||||
if (next === null) {
|
||||
void stopAgentRunEvents().catch((error: unknown) => {
|
||||
console.error("Failed to stop the agent step stream:", error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
void startAgentRunEvents(next).catch((error: unknown) => {
|
||||
// A signed-out or unentitled desktop refuses the subscription. The steps
|
||||
// already read over HTTP still render, so this is logged, not shown.
|
||||
console.error("Failed to subscribe to agent run events:", error);
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleApplyWishes() {
|
||||
if (applyScheduled) return;
|
||||
applyScheduled = true;
|
||||
queueMicrotask(() => {
|
||||
applyScheduled = false;
|
||||
applyWishes();
|
||||
});
|
||||
}
|
||||
|
||||
function mergeStep(steps: AgentStep[], incoming: AgentStep): AgentStep[] {
|
||||
const next = steps.filter((step) => step.index !== incoming.index);
|
||||
next.push(incoming);
|
||||
next.sort((a, b) => a.index - b.index);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param runId the run to watch, or null to watch nothing. Changing it swaps
|
||||
* the whole subscription: the desktop streams one run at a time, because the
|
||||
* page only ever shows one.
|
||||
*/
|
||||
export function useAgentRun(runId: string | null): UseAgentRunResult {
|
||||
const [snapshot, setSnapshot] = useState<AgentRunSnapshot>(EMPTY);
|
||||
const consumerId = useId();
|
||||
// Every async continuation checks this before it writes. React runs mount
|
||||
// effects twice in development and a user can switch runs faster than a read
|
||||
// completes, so without it a stale transcript lands on top of a newer one.
|
||||
const generation = useRef(0);
|
||||
|
||||
const load = useCallback(
|
||||
async (id: string, forGeneration: number, withSpinner: boolean) => {
|
||||
if (withSpinner) {
|
||||
setSnapshot((prev) => ({ ...prev, isLoading: true, error: null }));
|
||||
}
|
||||
try {
|
||||
const detail = await getAgentRun(id);
|
||||
if (generation.current !== forGeneration) return;
|
||||
const { transcript, ...run } = detail;
|
||||
setSnapshot((prev) => ({
|
||||
...prev,
|
||||
run,
|
||||
// The transcript is authoritative for what has already happened.
|
||||
// Live steps that arrived while this read was in flight keep their
|
||||
// place, because merging is by index either way.
|
||||
steps: (transcript ?? []).reduce(mergeStep, prev.steps),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (generation.current !== forGeneration) return;
|
||||
// A background refresh that fails leaves the previous steps on screen:
|
||||
// a run's history does not stop being true because one read did.
|
||||
setSnapshot((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: withSpinner ? error : prev.error,
|
||||
}));
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const forGeneration = ++generation.current;
|
||||
if (!runId) {
|
||||
setSnapshot(EMPTY);
|
||||
wishes.delete(consumerId);
|
||||
scheduleApplyWishes();
|
||||
return;
|
||||
}
|
||||
|
||||
setSnapshot({ ...EMPTY, isLoading: true });
|
||||
|
||||
const offs: Promise<() => void>[] = [
|
||||
onAgentStep((event) => {
|
||||
if (generation.current !== forGeneration || event.runId !== runId) {
|
||||
return;
|
||||
}
|
||||
if (!event.step || typeof event.step.index !== "number") return;
|
||||
setSnapshot((prev) => ({
|
||||
...prev,
|
||||
steps: mergeStep(prev.steps, event.step),
|
||||
}));
|
||||
}),
|
||||
onAgentStatus((event) => {
|
||||
if (generation.current !== forGeneration || event.runId !== runId) {
|
||||
return;
|
||||
}
|
||||
setSnapshot((prev) => ({
|
||||
...prev,
|
||||
run: prev.run
|
||||
? {
|
||||
...prev.run,
|
||||
status: event.status,
|
||||
closeReason: event.closeReason ?? prev.run.closeReason,
|
||||
errorCode: event.errorCode ?? prev.run.errorCode,
|
||||
result: event.result ?? prev.run.result,
|
||||
}
|
||||
: prev.run,
|
||||
}));
|
||||
// A finished run has final counters — tokens, cost, ended_at — that the
|
||||
// status frame does not carry, so the view is re-read once rather than
|
||||
// left showing the figures it had a second before the end.
|
||||
if (isRunOver({ status: event.status })) {
|
||||
void load(runId, forGeneration, false);
|
||||
}
|
||||
}),
|
||||
onAgentStream((status) => {
|
||||
if (generation.current !== forGeneration || status.runId !== runId) {
|
||||
return;
|
||||
}
|
||||
setSnapshot((prev) => ({ ...prev, streamConnected: status.connected }));
|
||||
}),
|
||||
];
|
||||
|
||||
// The transcript read and the subscription start together. The read is what
|
||||
// fills the panel; the stream is what keeps it filling.
|
||||
void load(runId, forGeneration, true);
|
||||
wishes.set(consumerId, runId);
|
||||
scheduleApplyWishes();
|
||||
|
||||
return () => {
|
||||
generation.current += 1;
|
||||
for (const off of offs) {
|
||||
void off.then((unlisten) => {
|
||||
unlisten();
|
||||
});
|
||||
}
|
||||
wishes.delete(consumerId);
|
||||
scheduleApplyWishes();
|
||||
};
|
||||
}, [runId, load, consumerId]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!runId) return;
|
||||
await load(runId, generation.current, true);
|
||||
}, [runId, load]);
|
||||
|
||||
return { ...snapshot, refresh };
|
||||
}
|
||||
@@ -67,9 +67,7 @@ export function useAutoHeight<T extends HTMLElement = HTMLDivElement>(
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
const next = measure();
|
||||
requestAnimationFrame(() => {
|
||||
setHeight(next);
|
||||
});
|
||||
setHeight((previous) => (previous === next ? previous : next));
|
||||
});
|
||||
|
||||
ro.observe(el);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Event as TauriEvent } from "@tauri-apps/api/event";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import i18n from "@/i18n";
|
||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||
import { isOnboardingActive } from "@/lib/onboarding-signal";
|
||||
@@ -58,6 +58,11 @@ export function useBrowserDownload() {
|
||||
);
|
||||
const [downloadProgress, setDownloadProgress] =
|
||||
useState<DownloadProgress | null>(null);
|
||||
// Last non-terminal stage seen per browser-version. The backend's terminal
|
||||
// "error" event does not say which phase failed, and only a failed
|
||||
// extraction deletes the archive, so the phase it interrupted is what
|
||||
// decides which message the user gets.
|
||||
const lastDownloadStageRef = useRef(new Map<string, string>());
|
||||
|
||||
const formatTime = useCallback((seconds: number): string => {
|
||||
if (seconds < 60) {
|
||||
@@ -247,12 +252,15 @@ export function useBrowserDownload() {
|
||||
|
||||
// Ensure the long-running download toast is dismissed, and show a finite error toast
|
||||
dismissToast(`download-${browserStr}-${version}`);
|
||||
// Same id as the progress listener's error toast, so the concrete
|
||||
// backend message replaces the generic one instead of stacking on it.
|
||||
showErrorToast(
|
||||
i18n.t("browserDownload.toast.downloadFailed", {
|
||||
browser: browserName,
|
||||
version,
|
||||
}),
|
||||
{
|
||||
id: `download-error-${browserStr}-${version}`,
|
||||
description: errorMessage,
|
||||
duration: 8000,
|
||||
},
|
||||
@@ -304,15 +312,22 @@ export function useBrowserDownload() {
|
||||
const progress = event.payload;
|
||||
setDownloadProgress(progress);
|
||||
|
||||
const downloadKey = `${progress.browser}-${progress.version}`;
|
||||
const interruptedStage =
|
||||
lastDownloadStageRef.current.get(downloadKey);
|
||||
|
||||
if (
|
||||
progress.stage === "downloading" ||
|
||||
progress.stage === "extracting" ||
|
||||
progress.stage === "verifying"
|
||||
) {
|
||||
lastDownloadStageRef.current.set(downloadKey, progress.stage);
|
||||
setDownloadingBrowsers((prev) => {
|
||||
if (prev.has(progress.browser)) return prev;
|
||||
return new Set(prev).add(progress.browser);
|
||||
});
|
||||
} else {
|
||||
lastDownloadStageRef.current.delete(downloadKey);
|
||||
}
|
||||
|
||||
const browserName = getBrowserDisplayName(progress.browser);
|
||||
@@ -390,14 +405,23 @@ export function useBrowserDownload() {
|
||||
// During first-run onboarding the welcome dialog surfaces a
|
||||
// concrete setup error itself, so suppress the global toast.
|
||||
if (!isOnboardingActive()) {
|
||||
const wasExtracting = interruptedStage === "extracting";
|
||||
showErrorToast(
|
||||
i18n.t("browserDownload.toast.extractionFailed", {
|
||||
browser: browserName,
|
||||
version: progress.version,
|
||||
}),
|
||||
i18n.t(
|
||||
wasExtracting
|
||||
? "browserDownload.toast.extractionFailed"
|
||||
: "browserDownload.toast.downloadFailed",
|
||||
{
|
||||
browser: browserName,
|
||||
version: progress.version,
|
||||
},
|
||||
),
|
||||
{
|
||||
id: `download-error-${progress.browser}-${progress.version}`,
|
||||
description: i18n.t(
|
||||
"browserDownload.toast.extractionFailedDescription",
|
||||
wasExtracting
|
||||
? "browserDownload.toast.extractionFailedDescription"
|
||||
: "browserDownload.toast.downloadFailedDescription",
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type InputModality = "pointer" | "keyboard";
|
||||
|
||||
const InputModalityContext = createContext<InputModality>("keyboard");
|
||||
|
||||
export function InputModalityProvider({ children }: { children: ReactNode }) {
|
||||
const [modality, setModality] = useState<InputModality>("keyboard");
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerDown = () => setModality("pointer");
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!["Shift", "Control", "Alt", "Meta"].includes(event.key)) {
|
||||
setModality("keyboard");
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDown, true);
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||
document.removeEventListener("keydown", onKeyDown, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<InputModalityContext.Provider value={modality}>
|
||||
{children}
|
||||
</InputModalityContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useInputModality() {
|
||||
return useContext(InputModalityContext);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/** Up, up, down, down, left, right, left, right, B, A. */
|
||||
const SEQUENCE = [
|
||||
"ArrowUp",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"b",
|
||||
"a",
|
||||
];
|
||||
|
||||
/** A pause longer than this between keys starts the code over. */
|
||||
const STEP_WINDOW_MS = 2000;
|
||||
|
||||
function isTyping(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
return (
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
tag === "SELECT" ||
|
||||
target.isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `onUnlock` once per completed code. Keys typed into a field never
|
||||
* count, and a held modifier never counts, so a shortcut that shares a letter
|
||||
* cannot advance the sequence by accident.
|
||||
*/
|
||||
export function useKonamiCode(onUnlock: () => void) {
|
||||
const unlock = useRef(onUnlock);
|
||||
unlock.current = onUnlock;
|
||||
const progress = useRef(0);
|
||||
const lastKeyAt = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
if (isTyping(event.target)) {
|
||||
progress.current = 0;
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now - lastKeyAt.current > STEP_WINDOW_MS) progress.current = 0;
|
||||
lastKeyAt.current = now;
|
||||
|
||||
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||||
if (key === SEQUENCE[progress.current]) {
|
||||
progress.current += 1;
|
||||
} else {
|
||||
// A wrong key may still be the first key of a fresh attempt.
|
||||
progress.current = key === SEQUENCE[0] ? 1 : 0;
|
||||
}
|
||||
if (progress.current === SEQUENCE.length) {
|
||||
progress.current = 0;
|
||||
unlock.current();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
export type LaunchStage =
|
||||
| "queued"
|
||||
| "preparing"
|
||||
| "network"
|
||||
| "extensions"
|
||||
| "starting"
|
||||
| "running"
|
||||
| "failed";
|
||||
export interface LaunchEvent {
|
||||
id: string;
|
||||
stage: LaunchStage;
|
||||
timestamp: number;
|
||||
error?: string | null;
|
||||
}
|
||||
const LaunchActivity = createContext<Record<string, LaunchEvent[]>>({});
|
||||
|
||||
export function LaunchActivityProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [activity, setActivity] = useState<Record<string, LaunchEvent[]>>({});
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const subscription = listen<LaunchEvent>(
|
||||
"profile-launch-stage",
|
||||
({ payload }) => {
|
||||
if (!active) return;
|
||||
setActivity((previous) => {
|
||||
const next = { ...previous };
|
||||
next[payload.id] =
|
||||
payload.stage === "queued"
|
||||
? [payload]
|
||||
: [...(previous[payload.id] ?? []), payload].slice(-16);
|
||||
// This is a recent-operation receipt, not an unbounded log store.
|
||||
const ids = Object.keys(next);
|
||||
if (ids.length > 100) {
|
||||
ids.sort(
|
||||
(a, b) =>
|
||||
(next[a].at(-1)?.timestamp ?? 0) -
|
||||
(next[b].at(-1)?.timestamp ?? 0),
|
||||
);
|
||||
delete next[ids[0]];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
active = false;
|
||||
void subscription.then((unlisten) => unlisten());
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<LaunchActivity.Provider value={activity}>
|
||||
{children}
|
||||
</LaunchActivity.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLaunchActivity(profileId: string) {
|
||||
return useContext(LaunchActivity)[profileId] ?? [];
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
|
||||
export function useProfileReferences(enabled: boolean) {
|
||||
const [profiles, setProfiles] = useState<BrowserProfile[] | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let active = true;
|
||||
let revision = 0;
|
||||
const load = async () => {
|
||||
const request = ++revision;
|
||||
try {
|
||||
const result = await invoke<BrowserProfile[]>("list_browser_profiles");
|
||||
if (active && request === revision) {
|
||||
setProfiles(result);
|
||||
setFailed(false);
|
||||
}
|
||||
} catch {
|
||||
if (active && request === revision) {
|
||||
setProfiles(null);
|
||||
setFailed(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
const subscription = listen("profiles-changed", () => void load());
|
||||
void load();
|
||||
return () => {
|
||||
active = false;
|
||||
void subscription.then((unlisten) => unlisten());
|
||||
};
|
||||
}, [enabled]);
|
||||
return { profiles, failed };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
getRemoteHandoffStates,
|
||||
onRemoteHandoffChanged,
|
||||
@@ -21,10 +21,19 @@ import {
|
||||
*/
|
||||
export function useRemoteHandoff() {
|
||||
const [states, setStates] = useState<Record<string, RemoteHandoffState>>({});
|
||||
const [ready, setReady] = useState(false);
|
||||
const revision = useRef(0);
|
||||
const mounted = useRef(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const requestedRevision = ++revision.current;
|
||||
try {
|
||||
setStates(await getRemoteHandoffStates());
|
||||
const snapshot = await getRemoteHandoffStates();
|
||||
// An older command response must never replace a newer handoff event.
|
||||
if (mounted.current && revision.current === requestedRevision) {
|
||||
setStates(snapshot);
|
||||
setReady(true);
|
||||
}
|
||||
} catch (error) {
|
||||
// Not signed in, or the app is still starting. The backend gate still
|
||||
// applies; the button is simply not pre-disabled.
|
||||
@@ -33,12 +42,32 @@ export function useRemoteHandoff() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const unlisten = onRemoteHandoffChanged(setStates);
|
||||
return () => {
|
||||
void unlisten.then((off) => {
|
||||
off();
|
||||
mounted.current = true;
|
||||
let cancelled = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
void onRemoteHandoffChanged((snapshot) => {
|
||||
if (cancelled) return;
|
||||
++revision.current;
|
||||
setStates(snapshot);
|
||||
setReady(true);
|
||||
})
|
||||
.then((off) => {
|
||||
if (cancelled) {
|
||||
off();
|
||||
return;
|
||||
}
|
||||
unlisten = off;
|
||||
void refresh();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Could not listen for remote handoffs:", error);
|
||||
if (!cancelled) void refresh();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mounted.current = false;
|
||||
++revision.current;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
@@ -47,5 +76,10 @@ export function useRemoteHandoff() {
|
||||
[states],
|
||||
);
|
||||
|
||||
return { handoffStates: states, handoffFor, refreshHandoff: refresh };
|
||||
return {
|
||||
handoffStates: states,
|
||||
handoffFor,
|
||||
handoffStatesReady: ready,
|
||||
refreshHandoff: refresh,
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,24 @@ export function useSyncSessions() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fold an authoritative session record into the list.
|
||||
*
|
||||
* The pause, hold and arrange commands all return the session as the backend
|
||||
* now holds it. Applying that return value alongside the event means a
|
||||
* control never has to show what it hopes happened, and a dropped event
|
||||
* cannot leave a button lying about the state.
|
||||
*/
|
||||
const applySession = useCallback((session: SyncSessionInfo) => {
|
||||
setSessions((prev) => {
|
||||
const idx = prev.findIndex((s) => s.id === session.id);
|
||||
if (idx < 0) return [...prev, session];
|
||||
const next = [...prev];
|
||||
next[idx] = session;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let changedUnlisten: (() => void) | undefined;
|
||||
let endedUnlisten: (() => void) | undefined;
|
||||
@@ -85,5 +103,5 @@ export function useSyncSessions() {
|
||||
[sessions],
|
||||
);
|
||||
|
||||
return { sessions, getProfileSyncInfo, loadSessions };
|
||||
return { sessions, getProfileSyncInfo, loadSessions, applySession };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import i18n from "@/i18n";
|
||||
import type { TrashedProfileSummary } from "@/types";
|
||||
|
||||
/**
|
||||
* The trash as the backend reports it, kept current by the `trash-changed`
|
||||
* event every trash mutation (delete, restore, purge, expiry sweep) emits.
|
||||
*/
|
||||
export function useTrashEvents() {
|
||||
const [entries, setEntries] = useState<TrashedProfileSummary[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadEntries = useCallback(async () => {
|
||||
try {
|
||||
const listed = await invoke<TrashedProfileSummary[]>(
|
||||
"list_trashed_profiles",
|
||||
);
|
||||
setEntries(listed);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to load trash:", err);
|
||||
setError(
|
||||
i18n.t("trash.loadFailed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let trashUnlisten: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const setupListeners = async () => {
|
||||
try {
|
||||
await loadEntries();
|
||||
const unlisten = await listen("trash-changed", () => {
|
||||
void loadEntries();
|
||||
});
|
||||
// The effect may already be torn down by the time listen() resolves.
|
||||
if (cancelled) {
|
||||
unlisten();
|
||||
} else {
|
||||
trashUnlisten = unlisten;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to set up trash event listener:", err);
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void setupListeners();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (trashUnlisten) trashUnlisten();
|
||||
};
|
||||
}, [loadEntries]);
|
||||
|
||||
return { entries, isLoading, error, reload: loadEntries };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface UseWayfernTermsReturn {
|
||||
@@ -35,6 +36,20 @@ export function useWayfernTerms(): UseWayfernTermsReturn {
|
||||
void checkTerms();
|
||||
}, [checkTerms]);
|
||||
|
||||
// The backend announces every acceptance, including ones the dialog did
|
||||
// not drive (the REST API, an automation session), so the gate lifts
|
||||
// without a restart.
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const subscription = listen("wayfern-terms-accepted", () => {
|
||||
if (active) void checkTerms();
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
void subscription.then((unlisten) => unlisten());
|
||||
};
|
||||
}, [checkTerms]);
|
||||
|
||||
return {
|
||||
termsAccepted,
|
||||
isLoading,
|
||||
|
||||
Reference in new issue
Block a user