* feat(files): edit workspace files on web Keep source buffers synchronized with host file changes and require an explicit overwrite or reload when revisions diverge. * feat(panels): surface and protect modified tabs Expose tooltip and modification state through the generic panel boundary so tabs can show stable metadata and guard every close route consistently. * fix(tests): use portable fake timeout handle * fix(files): harden editor conflict handling Preserve modified panel state across tab eviction, use precise revisions for optimistic writes, coalesce concurrent file watchers, and localize the editor interface. * fix(files): close editor concurrency gaps Coalesce clean reloads, preserve subscriber identities and file permissions, suspend pending saves during close confirmation, and carry precise revisions through file reads. * test(files): expect read revision metadata
94 lines
2.8 KiB
TypeScript
94 lines
2.8 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
|
import type { FileVersion } from "@getpaseo/protocol/messages";
|
|
import { useFetchQuery } from "@/data/query";
|
|
|
|
export function useLiveFile(input: {
|
|
client: DaemonClient | null;
|
|
serverId: string;
|
|
cwd: string | null;
|
|
path: string | null;
|
|
enabled: boolean;
|
|
liveUpdates: boolean;
|
|
}) {
|
|
const queryClient = useQueryClient();
|
|
const [subscriptionReady, setSubscriptionReady] = useState(!input.liveUpdates);
|
|
const [version, setVersion] = useState<FileVersion | null>(null);
|
|
const latestVersion = useRef<FileVersion | null>(null);
|
|
const queryKey = useMemo(
|
|
() => ["workspaceFile", input.serverId, input.cwd, input.path] as const,
|
|
[input.cwd, input.path, input.serverId],
|
|
);
|
|
|
|
useEffect(() => {
|
|
latestVersion.current = null;
|
|
setVersion(null);
|
|
const { client, cwd, path } = input;
|
|
if (!input.liveUpdates || !client || !cwd || !path || !input.enabled) {
|
|
setSubscriptionReady(!input.liveUpdates);
|
|
return;
|
|
}
|
|
let disposed = false;
|
|
let unsubscribe: (() => void) | null = null;
|
|
setSubscriptionReady(false);
|
|
void (async () => {
|
|
try {
|
|
const subscription = await client.subscribeFile({ cwd, path }, (next) => {
|
|
if (disposed) return;
|
|
latestVersion.current = next;
|
|
setVersion(next);
|
|
void queryClient.invalidateQueries({ queryKey });
|
|
});
|
|
if (disposed) {
|
|
subscription.unsubscribe();
|
|
return;
|
|
}
|
|
unsubscribe = subscription.unsubscribe;
|
|
latestVersion.current = subscription.initial;
|
|
setVersion(subscription.initial);
|
|
setSubscriptionReady(true);
|
|
} catch {
|
|
if (!disposed) setSubscriptionReady(true);
|
|
}
|
|
})();
|
|
return () => {
|
|
disposed = true;
|
|
unsubscribe?.();
|
|
};
|
|
}, [
|
|
input.client,
|
|
input.cwd,
|
|
input.enabled,
|
|
input.liveUpdates,
|
|
input.path,
|
|
queryClient,
|
|
queryKey,
|
|
input.serverId,
|
|
]);
|
|
|
|
const query = useFetchQuery({
|
|
queryKey,
|
|
enabled: input.enabled && Boolean(input.client && input.cwd && input.path) && subscriptionReady,
|
|
queryFn: async (): Promise<FileReadResult> => {
|
|
if (!input.client || !input.cwd || !input.path) throw new Error("File unavailable.");
|
|
return input.client.readFile(input.cwd, input.path);
|
|
},
|
|
dataShape: "value",
|
|
staleTimeMs: 5_000,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const observed = latestVersion.current;
|
|
if (
|
|
query.data &&
|
|
observed?.status === "ready" &&
|
|
query.data.modifiedAt !== observed.modifiedAt
|
|
) {
|
|
void queryClient.invalidateQueries({ queryKey });
|
|
}
|
|
}, [query.data, queryClient, queryKey]);
|
|
|
|
return { query, version };
|
|
}
|