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
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Pick up git worktrees created or removed outside PI WEB without any user action. The selected project's workspace list is re-read whenever the browser tab regains focus or becomes visible, on local and remote machines, keeping the current workspace, session, and scroll position untouched. Worktrees whose checkout directory no longer exists are hidden instead of being offered as selectable workspaces.
+20
View File
@@ -101,6 +101,7 @@
<a href="#remote-machines">How do remote machines work?</a>
<a href="#laptop-or-server">Laptop or server?</a>
<a href="#plugins">Can I use local plugins?</a>
<a href="#worktree-list-out-of-date">A worktree I created is missing</a>
<a href="#sessions-stop">Sessions stop unexpectedly</a>
<a href="#logs">Where are logs?</a>
</aside>
@@ -293,6 +294,25 @@
<p><a href="plugins">Read the plugin guide →</a></p>
</article>
<article id="worktree-list-out-of-date" class="faq-item">
<h2>A worktree I created or deleted outside PI WEB is missing or still listed</h2>
<p>
PI WEB does not register worktrees. It lists the git worktrees of the selected project on demand, so
worktrees you create or delete with <code>git worktree</code>, a terminal, or another tool are picked up
without any adopt or import step. This works the same way on remote machines.
</p>
<p>
The workspace list is re-read when the PI WEB tab regains focus or becomes visible again, not
continuously. If a worktree appeared while you were already looking at PI WEB, switch to another window
or tab and back, and the list updates. Your selected workspace, session, and scroll position are kept.
</p>
<p>
Worktrees whose checkout directory no longer exists are hidden, so a directory you removed with
<code>rm -rf</code> instead of <code>git worktree remove</code> stops appearing as a selectable
workspace. Git still tracks it until you run <code>git worktree prune</code>.
</p>
</article>
<article id="sessions-stop" class="faq-item">
<h2>Sessions stop unexpectedly</h2>
<p>
+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";
}