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
6 changes: 5 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,11 @@ Do not add motion for decoration. New animation must honor
names, counts, progress percentages or addition/deletion colors.
- Replace skeletons as data arrives, without a minimum display delay. Stored
manifest loading uses the same diff-frame skeleton without unmounting Pierre.
Background Sync preserves the existing review and usable controls. Errors and
Open available stored snapshots while load-time Sync continues; use skeletons
only for unavailable data. Replace each file preview independently as it arrives.
Keep the header busy indicator active through collection and preview loading.
Background Sync preserves the existing review and usable controls; newer heads
use the existing “Newer snapshot” notice and explicit “View latest” action. Errors and
empty results replace placeholders with their existing explanation and actions.

## Responsive design
Expand Down
21 changes: 18 additions & 3 deletions apps/web/src/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { FolderSyncIcon, ListTreeIcon, RefreshCwIcon } from "lucide-react";
import {
FolderSyncIcon,
ListTreeIcon,
LoaderCircleIcon,
RefreshCwIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { AccountButton } from "./account.tsx";
import { ThemeButton } from "./ThemeButton.tsx";
Expand Down Expand Up @@ -74,10 +79,20 @@ export function Header() {
size="icon"
aria-label="Sync all checkouts"
title="Sync all checkouts (Alt+R)"
aria-busy={collecting}
aria-busy={collecting || diff.busy}
onClick={refresh}
>
<FolderSyncIcon className="size-(--icon-base)" aria-hidden="true" />
{collecting || diff.busy ? (
<LoaderCircleIcon
className="size-(--icon-base)"
aria-hidden="true"
/>
) : (
<FolderSyncIcon
className="size-(--icon-base)"
aria-hidden="true"
/>
)}
</Button>
) : (
<Button
Expand Down
121 changes: 71 additions & 50 deletions apps/web/src/session-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
api,
errorDetail,
type ApiCollectionFailure,
type ApiCollectionResult,
type ApiContext,
} from "@diffx/api";
import {
Expand All @@ -14,6 +15,7 @@ import {
useRef,
useState,
} from "react";
import { LoaderCircleIcon } from "lucide-react";
import { AccountButton } from "./account.tsx";
import { ThemeButton } from "./ThemeButton.tsx";
import { Skeleton } from "@/components/ui/skeleton";
Expand Down Expand Up @@ -146,7 +148,9 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const history = useContextCatalog(false, pickerOpen);
const [collecting, setCollecting] = useState(false);
const [collectError, setCollectError] = useState("");
const collectingRef = useRef(false);
const collectionRequest = useRef<Promise<ApiCollectionResult | null> | null>(
null,
);
const selectedRef = useRef(selectedId);
useEffect(() => {
selectedRef.current = selectedId;
Expand Down Expand Up @@ -213,28 +217,33 @@ export function SessionProvider({ children }: { children: ReactNode }) {
[show],
);
const { remove: removeHead, retry: reloadHeads } = heads;
// Syncs the local checkouts; null when it fails or one is running.
// Load-time navigation joins an active collection. Explicit Sync ignores
// duplicate requests while the current collection is running.
const collectLatest = useCallback(
async (initial = false) => {
if (collectingRef.current) return null;
collectingRef.current = true;
(initial = false) => {
if (collectionRequest.current)
return initial ? collectionRequest.current : Promise.resolve(null);
setCollecting(true);
setCollectError("");
try {
const { data, error } = await api.POST("/api/v2/collections", {
params: { query: { initial } },
});
if (!data)
throw new Error(errorDetail(error, "Unable to sync changes"));
reloadHeads();
return data;
} catch (error) {
setCollectError(errorDetail(error, "Unable to sync changes"));
return null;
} finally {
collectingRef.current = false;
setCollecting(false);
}
const request = (async () => {
try {
const { data, error } = await api.POST("/api/v2/collections", {
params: { query: { initial } },
});
if (!data)
throw new Error(errorDetail(error, "Unable to sync changes"));
reloadHeads();
return data;
} catch (error) {
setCollectError(errorDetail(error, "Unable to sync changes"));
return null;
} finally {
collectionRequest.current = null;
setCollecting(false);
}
})();
collectionRequest.current = request;
return request;
},
[reloadHeads],
);
Expand Down Expand Up @@ -299,58 +308,53 @@ export function SessionProvider({ children }: { children: ReactNode }) {
window.addEventListener("popstate", handleNavigation);
return () => window.removeEventListener("popstate", handleNavigation);
}, []);
// Live routes resolve once per load or navigation. Local checkouts sync
// first and open the stream's synced head (or the newest changed one for the
// root URL); otherwise the catalog's head opens. Later arrivals only raise
// the newer-head notice. The route whose load-time sync started, and one
// whose sync failed or synced no matching head.
// Open available catalog data while local collection continues. Once selected,
// a snapshot stays open; collection only announces newer heads.
const collected = useRef<Route | null>(null);
const [uncollected, setUncollected] = useState<Route | null>(null);
const [collectionResult, setCollectionResult] = useState<{
route: Route;
contexts: ApiContext[];
} | null>(null);
const resolving = route.kind !== "pinned" && !selectedId && !missing;
const collectOnLoad = resolving && collectEnabled && uncollected !== route;
const collectionFinished = collectionResult?.route === route;
const collectOnLoad = resolving && collectEnabled && !collectionFinished;
useEffect(() => {
if (!collectOnLoad || collected.current === route) return;
// Effect replay must not treat the in-flight collection as a failed one.
// Effect replay must not start another collection. Completion only publishes
// a result; route resolution below cannot replace a user-selected review.
collected.current = route;
void collectLatest(true).then((result) => {
if (result?.failures.length)
setCollectError(
`Unable to sync ${result.failures.length} ${result.failures.length === 1 ? "checkout" : "checkouts"}: ${result.failures.map((failure) => `${failure.path} (${failure.detail})`).join(", ")}`,
);
const contexts = result?.contexts ?? [];
const head =
route.kind === "live"
? contexts.find((context) => context.streamId === route.stream)
: latestChangedContext(contexts);
if (head)
openStream(head, {
replace: true,
restoreFocus: false,
preserveNavigation: true,
});
else setUncollected(route);
setCollectionResult({ route, contexts: result?.contexts ?? [] });
});
}, [collectOnLoad, route, collectLatest, openStream]);
}, [collectOnLoad, route, collectLatest]);
useEffect(() => {
if (!resolving || !heads.loaded) return;
if (collectEnabled && uncollected !== route) return;
const head =
if (!resolving) return;
const findHead = (contexts: ApiContext[]) =>
route.kind === "live"
? heads.contexts.find((context) => context.streamId === route.stream)
: latestChangedContext(heads.contexts);
? contexts.find((context) => context.streamId === route.stream)
: latestChangedContext(contexts);
const head =
(collectionResult?.route === route
? findHead(collectionResult.contexts)
: undefined) ?? findHead(heads.contexts);
if (head)
openStream(head, {
replace: true,
restoreFocus: false,
preserveNavigation: true,
});
else if (route.kind === "live") setMissing(true);
else if (route.kind === "live" && heads.loaded && !collectOnLoad)
setMissing(true);
}, [
resolving,
heads.loaded,
heads.contexts,
collectEnabled,
uncollected,
collectOnLoad,
collectionResult,
route,
openStream,
]);
Expand Down Expand Up @@ -464,6 +468,23 @@ export function SessionProvider({ children }: { children: ReactNode }) {
)}
<div className="header-actions">
<NewChangesButton />
{collecting && (
<Button
type="button"
id="sync"
variant="ghost"
size="icon"
aria-label="Sync all checkouts"
title="Syncing checkouts"
aria-busy="true"
disabled
>
<LoaderCircleIcon
className="size-(--icon-base)"
aria-hidden="true"
/>
</Button>
)}
{compact && <AccountButton />}
<ThemeButton />
</div>
Expand Down Expand Up @@ -522,7 +543,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
collected.current = null;
setCollectError("");
setMissing(false);
setUncollected(null);
setCollectionResult(null);
}
reloadHeads();
setAttempt((current) => current + 1);
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ button[aria-busy="true"] {
opacity: 0.7;
}
@media (prefers-reduced-motion: no-preference) {
#refresh[aria-busy="true"] svg {
#refresh[aria-busy="true"] svg,
#sync[aria-busy="true"] svg {
animation: refresh-spin 800ms linear infinite;
}
}
Expand Down
36 changes: 17 additions & 19 deletions apps/web/src/use-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,19 +93,24 @@ export function useDiff(contextId: string, mode: DiffMode) {
pending.push(file);
}
}
setState({
repository: {
...data,
files: data.files.map((file) => ({ ...file })),
},
items: new Map(items),
busy: true,
notice: "",
connected: true,
});
let next = 0;
let failed = 0;
let loadedBytes = 0;
function publish(busy: boolean) {
setState({
repository: {
...data,
files: data.files.map((file) => ({ ...file })),
},
items: new Map(items),
busy,
notice: failed
? `${failed} file previews could not load. Refresh to retry.`
: "",
connected: true,
});
}
publish(true);
async function loadNext() {
while (next < pending.length && !signal.aborted) {
const file = pending[next++];
Expand Down Expand Up @@ -218,19 +223,12 @@ export function useDiff(contextId: string, mode: DiffMode) {
),
);
}
publish(true);
}
}
await Promise.all(Array.from({ length: 4 }, loadNext));
if (signal.aborted || disposed) return;
setState({
repository: data,
items,
busy: false,
connected: true,
notice: failed
? `${failed} file previews could not load. Refresh to retry.`
: "",
});
publish(false);
} catch (error) {
if (signal.aborted || disposed) return;
setState((previous) => ({
Expand Down
Loading
Loading