feat(workspaces): refresh worktrees on browser resume

Call WorkspaceController.refreshSelectedProjectTopology() from the existing
browser-resume refresh and the plugin-facing refreshAppData path, so worktrees
created or removed outside PI WEB become visible with no user action. No new
timer, watcher, process, or push channel; the resume path is already debounced
per animation frame and collapses concurrent requests.

Document the resume-scoped detection and the hiding of gone checkouts in the
FAQ, and add the changeset for the user-visible behavior.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-26 22:25:59 +02:00
parent 1970ba14bc
commit 8a24a7c4a5
4 changed files with 132 additions and 0 deletions
+2
View File
@@ -435,6 +435,7 @@ export class PiWebApp extends LitElement {
this.sessions.refreshSelectedSession(),
this.refreshMachineActivities(),
this.refreshWorkspaceDeletionRuns(),
this.workspaces.refreshSelectedProjectTopology(),
]);
}
@@ -492,6 +493,7 @@ export class PiWebApp extends LitElement {
this.loadClientConfig(),
this.refreshWorkspaceDeletionRuns(),
this.refreshCurrentWorkspaceSurface(),
this.workspaces.refreshSelectedProjectTopology(),
]);
this.schedulePiWebStatusRefresh();
} finally {
@@ -0,0 +1,105 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { WorkspaceController } from "../controllers/workspaceController";
import { PiWebApp } from "./PiWebApp";
type RefreshCallback = () => void | Promise<void>;
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("PiWebApp workspace topology refresh wiring", () => {
it("re-lists the selected project's workspaces on the browser-resume refresh", async () => {
const app = createApp();
stubBackgroundRefreshes(app);
const refreshTopology = spyOnTopologyRefresh(app);
await browserResumeRefresh(app)();
expect(refreshTopology).toHaveBeenCalledOnce();
});
it("re-lists the selected project's workspaces on the plugin-facing app-data refresh", async () => {
const app = createApp();
stubBackgroundRefreshes(app);
const refreshTopology = spyOnTopologyRefresh(app);
await refreshAppData(app);
expect(refreshTopology).toHaveBeenCalledOnce();
});
it("still re-lists workspaces when a sibling refresh in the same resume batch fails", async () => {
const app = createApp();
stubBackgroundRefreshes(app);
failBackgroundRefresh(app, "refreshMachineActivities", new Error("machine activity unavailable"));
const refreshTopology = spyOnTopologyRefresh(app);
await expect(browserResumeRefresh(app)()).rejects.toThrow("machine activity unavailable");
expect(refreshTopology).toHaveBeenCalledOnce();
});
});
function createApp(): PiWebApp {
const storage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
};
vi.stubGlobal("window", { location: { search: "" }, localStorage: storage });
return new PiWebApp();
}
/**
* Replaces the sibling refreshes that already have their own coverage so this test
* observes only whether the resume/app-data paths include workspace topology.
*/
function stubBackgroundRefreshes(app: PiWebApp): void {
const result = () => Promise.resolve();
for (const name of [
"renegotiateUnreadMachines",
"refreshMachineActivities",
"refreshWorkspaceDeletionRuns",
"loadClientConfig",
"refreshCurrentWorkspaceSurface",
"schedulePiWebStatusRefresh",
]) {
if (!Reflect.set(app, name, result)) throw new Error(`Could not replace PiWebApp.${name}`);
}
const sessions: unknown = Reflect.get(app, "sessions");
if (typeof sessions !== "object" || sessions === null || !Reflect.set(sessions, "refreshSelectedSession", result)) {
throw new Error("Could not replace the selected-session refresh");
}
}
function failBackgroundRefresh(app: PiWebApp, name: string, error: Error): void {
if (!Reflect.set(app, name, () => Promise.reject(error))) throw new Error(`Could not fail PiWebApp.${name}`);
}
function spyOnTopologyRefresh(app: PiWebApp) {
const controller: unknown = Reflect.get(app, "workspaces");
if (!(controller instanceof WorkspaceController)) throw new Error("PiWebApp WorkspaceController was unavailable");
return vi.spyOn(controller, "refreshSelectedProjectTopology").mockResolvedValue(undefined);
}
/** The exact callback `BrowserResumeController` invokes after a focus/visibility signal. */
function browserResumeRefresh(app: PiWebApp): RefreshCallback {
const resume: unknown = Reflect.get(app, "browserResume");
if (typeof resume !== "object" || resume === null) throw new Error("PiWebApp BrowserResumeController was unavailable");
const callbacks: unknown = Reflect.get(resume, "callbacks");
if (typeof callbacks !== "object" || callbacks === null) throw new Error("Browser resume callbacks were unavailable");
const refresh: unknown = Reflect.get(callbacks, "refreshAfterResume");
if (!isRefreshCallback(refresh)) throw new Error("The browser resume refresh callback was unavailable");
return refresh;
}
async function refreshAppData(app: PiWebApp): Promise<void> {
const refresh: unknown = Reflect.get(app, "refreshAppData");
if (!isRefreshCallback(refresh)) throw new Error("PiWebApp.refreshAppData is not callable");
await refresh.call(app);
}
function isRefreshCallback(value: unknown): value is RefreshCallback {
return typeof value === "function";
}