fix: show project activity for external worktrees

This commit is contained in:
Federico Jaramillo Martinez
2026-07-19 06:13:09 +02:00
parent d77ca66f28
commit b85e1b9ebe
9 changed files with 1070 additions and 18 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Show project activity indicators for active sessions and terminals in external Git worktrees before the project is opened.
+14
View File
@@ -11,6 +11,7 @@ import { FileExplorerController } from "../controllers/fileExplorerController";
import { GitController } from "../controllers/gitController";
import { MachineController } from "../controllers/machineController";
import { ProjectController } from "../controllers/projectController";
import { ProjectActivityOwnershipCoordinator } from "../controllers/projectActivityOwnershipCoordinator";
import { PiWebStatusController } from "../controllers/piWebStatusController";
import { SessionController } from "../controllers/sessionController";
import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController";
@@ -106,9 +107,20 @@ export class PiWebApp extends LitElement {
() => { this.updateUrl(); },
new SessionStorageSessionSelectionMemory(),
);
private readonly projectActivityOwnership = new ProjectActivityOwnershipCoordinator(
() => this.state,
(patch) => { this.setState(patch); },
{
api: workspacesApi,
onError: ({ machineId, projectId, error }) => {
console.warn(`Failed to discover project activity ownership for ${projectId} on ${machineId}`, error);
},
},
);
private readonly activity = new ActivityController(
() => this.state,
(patch) => { this.setState(patch); },
{ onActivityApplied: (machineId) => { void this.projectActivityOwnership.handleActivityApplied(machineId); } },
);
private readonly auth = new AuthController(
() => this.state,
@@ -126,6 +138,7 @@ export class PiWebApp extends LitElement {
() => this.state,
(patch) => { this.setState(patch); },
this.workspaces,
{ onProjectsApplied: (machineId) => { void this.projectActivityOwnership.handleProjectsApplied(machineId); } },
);
private readonly machines = new MachineController(
() => this.state,
@@ -877,6 +890,7 @@ export class PiWebApp extends LitElement {
private handleMachineChange(previous: AppState, next: AppState): void {
if ((previous.selectedMachine?.id ?? "local") === (next.selectedMachine?.id ?? "local")) return;
this.projectActivityOwnership.handleSelectedMachineChanged();
const pendingMachineId = this.pendingRemoteRouteRestore?.machineId ?? "local";
if (pendingMachineId !== (next.selectedMachine?.id ?? "local")) this.clearPendingRemoteRouteRestore();
this.sessions.clearActiveSession();
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { AppState } from "../appState";
import { initialAppState } from "../appState";
import type { WorkspaceActivity, WorkspaceActivityResponse } from "../api";
@@ -83,4 +83,26 @@ describe("ActivityController", () => {
expect(state.machineActivities["remote"]).toEqual({ "/remote": activity("/remote") });
expect(state.machineActivities["local"]).toEqual({ "/local": activity("/local") });
});
it("notifies ownership discovery after snapshots and live updates are applied", async () => {
let state: AppState = { ...initialAppState(), selectedMachine: { id: "local", name: "Local", kind: "local", createdAt: "now", updatedAt: "now" } };
const observedActivities: Record<string, WorkspaceActivity>[] = [];
const onActivityApplied = vi.fn((machineId: string) => {
expect(machineId).toBe("local");
observedActivities.push(state.workspaceActivities);
});
const controller = new ActivityController(() => state, (patch) => { state = { ...state, ...patch }; }, {
api: { workspaceActivity: () => Promise.resolve(snapshot(activity("/snapshot"))) },
onActivityApplied,
});
await controller.refresh("local");
controller.applyWorkspaceActivity(activity("/live"), "local");
expect(onActivityApplied).toHaveBeenCalledTimes(2);
expect(observedActivities).toEqual([
{ "/snapshot": activity("/snapshot") },
{ "/snapshot": activity("/snapshot"), "/live": activity("/live") },
]);
});
});
@@ -5,14 +5,17 @@ import { TrailingRefreshCoordinator } from "./trailingRefreshCoordinator";
export interface ActivityControllerDependencies {
api?: Pick<typeof defaultApi, "workspaceActivity">;
onActivityApplied?: (machineId: string) => void;
}
export class ActivityController {
private readonly api: Pick<typeof defaultApi, "workspaceActivity">;
private readonly onActivityApplied: ((machineId: string) => void) | undefined;
private readonly refreshes = new TrailingRefreshCoordinator<string>();
constructor(private readonly getState: GetState, private readonly setState: SetState, deps: ActivityControllerDependencies = {}) {
this.api = deps.api ?? defaultApi;
this.onActivityApplied = deps.onActivityApplied;
}
refresh(machineId = selectedMachineId(this.getState())): Promise<void> {
@@ -30,6 +33,7 @@ export class ActivityController {
machineActivities: { ...state.machineActivities, [machineId]: nextMachineActivities },
...(isSelectedMachine ? { workspaceActivities: nextMachineActivities } : {}),
});
this.onActivityApplied?.(machineId);
}
private applyMachineActivitySnapshot(machineId: string, activities: Record<string, WorkspaceActivity>): void {
@@ -38,6 +42,7 @@ export class ActivityController {
machineActivities: { ...state.machineActivities, [machineId]: activities },
...(selectedMachineId(state) === machineId ? { workspaceActivities: activities } : {}),
});
this.onActivityApplied?.(machineId);
}
}
@@ -0,0 +1,649 @@
import { describe, expect, it, vi } from "vitest";
import type { AppState } from "../appState";
import { initialAppState } from "../appState";
import type { Machine, Project, Workspace, WorkspaceActivity } from "../api";
import { projectActivityIndicator } from "../workspaceActivity";
import { ActivityController } from "./activityController";
import { ProjectActivityOwnershipCoordinator } from "./projectActivityOwnershipCoordinator";
import { ProjectController } from "./projectController";
const localMachine: Machine = {
id: "local",
name: "Local",
kind: "local",
createdAt: "now",
updatedAt: "now",
};
function project(id = "p1", path = "/repo"): Project {
return { id, name: id, path, createdAt: "now" };
}
function workspace(projectId: string, path: string): Workspace {
return {
id: path,
projectId,
path,
label: path,
isMain: path === "/repo",
isGitRepo: true,
isGitWorktree: true,
};
}
function activity(cwd: string, patch: Partial<WorkspaceActivity> = {}): WorkspaceActivity {
return { cwd, hasSessionActivity: true, hasTerminalActivity: false, updatedAt: "now", ...patch };
}
function machine(id: string): Machine {
return {
id,
name: id,
kind: id === "local" ? "local" : "remote",
createdAt: "now",
updatedAt: "now",
};
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (error: unknown) => void } {
let resolveDeferred: ((value: T) => void) | undefined;
let rejectDeferred: ((error: unknown) => void) | undefined;
const promise = new Promise<T>((resolve, reject) => {
resolveDeferred = resolve;
rejectDeferred = reject;
});
if (resolveDeferred === undefined || rejectDeferred === undefined) throw new Error("Deferred promise was not initialized");
return { promise, resolve: resolveDeferred, reject: rejectDeferred };
}
describe("ProjectActivityOwnershipCoordinator", () => {
it("hydrates an external worktree before project selection without changing selection state", async () => {
const candidate = project();
const externalActivity = activity("/tmp/repo-worktree");
const discoveredWorkspaces = [workspace(candidate.id, candidate.path), workspace(candidate.id, externalActivity.cwd)];
let state: AppState = {
...initialAppState(),
selectedMachine: localMachine,
projects: [candidate],
workspaceActivities: { [externalActivity.cwd]: externalActivity },
};
const initialSelection = {
selectedProject: state.selectedProject,
selectedWorkspace: state.selectedWorkspace,
selectedSession: state.selectedSession,
workspaces: state.workspaces,
mainView: state.mainView,
workspaceTool: state.workspaceTool,
};
const loadWorkspaces = vi.fn<(projectId: string, machineId?: string) => Promise<Workspace[]>>()
.mockResolvedValue(discoveredWorkspaces);
const coordinator = new ProjectActivityOwnershipCoordinator(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ api: { workspaces: loadWorkspaces } },
);
expect(projectActivityIndicator(candidate, [], state.workspaceActivities)).toBeUndefined();
await coordinator.handleActivityApplied(localMachine.id);
expect(loadWorkspaces).toHaveBeenCalledOnce();
expect(loadWorkspaces).toHaveBeenCalledWith(candidate.id, localMachine.id);
expect(state.workspacesByProjectId[candidate.id]).toEqual(discoveredWorkspaces);
expect(projectActivityIndicator(candidate, state.workspacesByProjectId[candidate.id] ?? [], state.workspaceActivities)).toBe("session");
expect({
selectedProject: state.selectedProject,
selectedWorkspace: state.selectedWorkspace,
selectedSession: state.selectedSession,
workspaces: state.workspaces,
mainView: state.mainView,
workspaceTool: state.workspaceTool,
}).toEqual(initialSelection);
});
it("does not request workspace topology for activity inside a project root", async () => {
const candidate = project();
const inRootActivity = activity("/repo/packages/client");
let state: AppState = {
...initialAppState(),
selectedMachine: localMachine,
projects: [candidate],
workspaceActivities: { [inRootActivity.cwd]: inRootActivity },
};
const loadWorkspaces = vi.fn<(projectId: string, machineId?: string) => Promise<Workspace[]>>();
const coordinator = new ProjectActivityOwnershipCoordinator(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ api: { workspaces: loadWorkspaces } },
);
await coordinator.handleActivityApplied(localMachine.id);
expect(loadWorkspaces).not.toHaveBeenCalled();
expect(projectActivityIndicator(candidate, [], state.workspaceActivities)).toBe("session");
});
it("refreshes a populated but stale cache for a newly active external worktree", async () => {
const candidate = project();
const mainWorkspace = workspace(candidate.id, candidate.path);
const externalWorkspace = workspace(candidate.id, "/tmp/new-worktree");
const externalActivity = activity(externalWorkspace.path);
let state: AppState = {
...initialAppState(),
selectedMachine: localMachine,
projects: [candidate],
workspacesByProjectId: { [candidate.id]: [mainWorkspace] },
workspaceActivities: { [externalActivity.cwd]: externalActivity },
};
const loadWorkspaces = vi.fn<(projectId: string, machineId?: string) => Promise<Workspace[]>>()
.mockResolvedValue([mainWorkspace, externalWorkspace]);
const coordinator = new ProjectActivityOwnershipCoordinator(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ api: { workspaces: loadWorkspaces } },
);
await coordinator.handleActivityApplied(localMachine.id);
expect(loadWorkspaces).toHaveBeenCalledOnce();
expect(state.workspacesByProjectId[candidate.id]).toEqual([mainWorkspace, externalWorkspace]);
});
it("coalesces several unknown CWDs into one pass and resolves them from one topology response", async () => {
const candidate = project();
const firstActivity = activity("/tmp/worktree-one");
const secondActivity = activity("/tmp/worktree-two");
const discoveredWorkspaces = [
workspace(candidate.id, candidate.path),
workspace(candidate.id, firstActivity.cwd),
workspace(candidate.id, secondActivity.cwd),
];
let state: AppState = {
...initialAppState(),
selectedMachine: localMachine,
projects: [candidate],
workspaceActivities: {
[firstActivity.cwd]: firstActivity,
[secondActivity.cwd]: secondActivity,
},
};
const loadWorkspaces = vi.fn<(projectId: string, machineId?: string) => Promise<Workspace[]>>()
.mockResolvedValue(discoveredWorkspaces);
const coordinator = new ProjectActivityOwnershipCoordinator(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ api: { workspaces: loadWorkspaces } },
);
await coordinator.handleActivityApplied(localMachine.id);
state = {
...state,
workspaceActivities: {
[firstActivity.cwd]: { ...firstActivity, updatedAt: "heartbeat" },
[secondActivity.cwd]: { ...secondActivity, updatedAt: "heartbeat" },
},
};
await coordinator.handleActivityApplied(localMachine.id);
expect(loadWorkspaces).toHaveBeenCalledOnce();
expect(projectActivityIndicator(candidate, discoveredWorkspaces, state.workspaceActivities)).toBe("session");
});
it("shares an in-flight pass and does not loop after a full negative result", async () => {
const candidate = project();
const unknownActivity = activity("/tmp/unmatched");
const response = deferred<Workspace[]>();
let state: AppState = {
...initialAppState(),
selectedMachine: localMachine,
projects: [candidate],
workspaceActivities: { [unknownActivity.cwd]: unknownActivity },
};
const loadWorkspaces = vi.fn<(projectId: string, machineId?: string) => Promise<Workspace[]>>()
.mockReturnValue(response.promise);
const coordinator = new ProjectActivityOwnershipCoordinator(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ api: { workspaces: loadWorkspaces } },
);
const firstPass = coordinator.handleActivityApplied(localMachine.id);
const duplicatePasses = [
coordinator.handleActivityApplied(localMachine.id),
coordinator.handleActivityApplied(localMachine.id),
];
expect(loadWorkspaces).toHaveBeenCalledOnce();
response.resolve([]);
await Promise.all([firstPass, ...duplicatePasses]);
state = {
...state,
workspaceActivities: {
[unknownActivity.cwd]: { ...unknownActivity, updatedAt: "later-heartbeat" },
},
};
await coordinator.handleActivityApplied(localMachine.id);
expect(loadWorkspaces).toHaveBeenCalledOnce();
});
it("coalesces CWDs arriving during a pass into at most one trailing pass", async () => {
const candidate = project();
const firstResponse = deferred<Workspace[]>();
const trailingResponse = deferred<Workspace[]>();
const trailingStarted = deferred<undefined>();
const firstActivity = activity("/tmp/first");
const secondActivity = activity("/tmp/second");
const thirdActivity = activity("/tmp/third");
let state: AppState = {
...initialAppState(),
selectedMachine: localMachine,
projects: [candidate],
workspaceActivities: { [firstActivity.cwd]: firstActivity },
};
let requestCount = 0;
const loadWorkspaces = vi.fn<(projectId: string, machineId?: string) => Promise<Workspace[]>>()
.mockImplementation(() => {
requestCount += 1;
if (requestCount === 1) return firstResponse.promise;
trailingStarted.resolve(undefined);
return trailingResponse.promise;
});
const coordinator = new ProjectActivityOwnershipCoordinator(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ api: { workspaces: loadWorkspaces } },
);
const firstPass = coordinator.handleActivityApplied(localMachine.id);
state = {
...state,
workspaceActivities: {
[firstActivity.cwd]: firstActivity,
[secondActivity.cwd]: secondActivity,
[thirdActivity.cwd]: thirdActivity,
},
};
const arrivalNotifications = [
coordinator.handleActivityApplied(localMachine.id),
coordinator.handleActivityApplied(localMachine.id),
coordinator.handleActivityApplied(localMachine.id),
];
expect(loadWorkspaces).toHaveBeenCalledOnce();
firstResponse.resolve([]);
await trailingStarted.promise;
expect(loadWorkspaces).toHaveBeenCalledTimes(2);
state = {
...state,
workspaceActivities: Object.fromEntries(Object.entries(state.workspaceActivities).map(([cwd, current]) => [
cwd,
{ ...current, updatedAt: "heartbeat-during-trailing-pass" },
])),
};
const trailingHeartbeats = [
coordinator.handleActivityApplied(localMachine.id),
coordinator.handleActivityApplied(localMachine.id),
];
trailingResponse.resolve([]);
await Promise.all([firstPass, ...arrivalNotifications, ...trailingHeartbeats]);
await coordinator.handleActivityApplied(localMachine.id);
expect(loadWorkspaces).toHaveBeenCalledTimes(2);
});
it("permits one fresh pass after inactivity/reactivation and after applied topology invalidation", async () => {
const candidate = project();
const unknownActivity = activity("/tmp/reactivated");
let state: AppState = {
...initialAppState(),
selectedMachine: localMachine,
projects: [candidate],
workspaceActivities: { [unknownActivity.cwd]: unknownActivity },
};
const loadWorkspaces = vi.fn<(projectId: string, machineId?: string) => Promise<Workspace[]>>()
.mockResolvedValue([]);
const coordinator = new ProjectActivityOwnershipCoordinator(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ api: { workspaces: loadWorkspaces } },
);
await coordinator.handleActivityApplied(localMachine.id);
await coordinator.handleActivityApplied(localMachine.id);
expect(loadWorkspaces).toHaveBeenCalledTimes(1);
state = { ...state, workspaceActivities: {} };
await coordinator.handleActivityApplied(localMachine.id);
state = {
...state,
workspaceActivities: {
[unknownActivity.cwd]: { ...unknownActivity, updatedAt: "reactivated" },
},
};
await coordinator.handleActivityApplied(localMachine.id);
expect(loadWorkspaces).toHaveBeenCalledTimes(2);
await coordinator.handleProjectsApplied(localMachine.id);
await coordinator.handleActivityApplied(localMachine.id);
expect(loadWorkspaces).toHaveBeenCalledTimes(3);
});
it("discovers terminal-only activity while keeping an unmatched terminal CWD bounded", async () => {
const candidate = project();
const ownedActivity = activity("/tmp/terminal-worktree", { hasSessionActivity: false, hasTerminalActivity: true });
const unmatchedActivity = activity("/tmp/unmatched-terminal", { hasSessionActivity: false, hasTerminalActivity: true });
const discoveredWorkspaces = [workspace(candidate.id, candidate.path), workspace(candidate.id, ownedActivity.cwd)];
let state: AppState = {
...initialAppState(),
selectedMachine: localMachine,
projects: [candidate],
workspaceActivities: {
[ownedActivity.cwd]: ownedActivity,
[unmatchedActivity.cwd]: unmatchedActivity,
},
};
const loadWorkspaces = vi.fn<(projectId: string, machineId?: string) => Promise<Workspace[]>>()
.mockResolvedValue(discoveredWorkspaces);
const coordinator = new ProjectActivityOwnershipCoordinator(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ api: { workspaces: loadWorkspaces } },
);
await coordinator.handleActivityApplied(localMachine.id);
await coordinator.handleActivityApplied(localMachine.id);
expect(loadWorkspaces).toHaveBeenCalledOnce();
expect(projectActivityIndicator(candidate, discoveredWorkspaces, state.workspaceActivities)).toBe("terminal");
});
it("ignores non-selected-machine notifications without consuming the selected machine's activity gate", async () => {
const candidate = project();
const externalActivity = activity("/tmp/selected-machine-worktree");
let state: AppState = {
...initialAppState(),
selectedMachine: localMachine,
projects: [candidate],
workspaceActivities: { [externalActivity.cwd]: externalActivity },
};
const loadWorkspaces = vi.fn<(projectId: string, machineId?: string) => Promise<Workspace[]>>()
.mockResolvedValue([workspace(candidate.id, externalActivity.cwd)]);
const coordinator = new ProjectActivityOwnershipCoordinator(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ api: { workspaces: loadWorkspaces } },
);
await coordinator.handleActivityApplied("remote");
await coordinator.handleProjectsApplied(localMachine.id);
expect(loadWorkspaces).not.toHaveBeenCalled();
await coordinator.handleActivityApplied(localMachine.id);
expect(loadWorkspaces).toHaveBeenCalledOnce();
expect(loadWorkspaces).toHaveBeenCalledWith(candidate.id, localMachine.id);
});
it("rejects a deferred response after an A-to-B-to-A machine transition", async () => {
const machineA = machine("machine-a");
const machineB = machine("machine-b");
const candidate = project();
const externalActivity = activity("/tmp/machine-a-worktree");
const staleResponse = deferred<Workspace[]>();
const currentWorkspaces = [workspace(candidate.id, candidate.path), workspace(candidate.id, externalActivity.cwd)];
let state: AppState = {
...initialAppState(),
selectedMachine: machineA,
projects: [candidate],
workspaceActivities: { [externalActivity.cwd]: externalActivity },
};
const loadWorkspaces = vi.fn<(projectId: string, machineId?: string) => Promise<Workspace[]>>()
.mockImplementationOnce(() => staleResponse.promise)
.mockResolvedValueOnce(currentWorkspaces);
const coordinator = new ProjectActivityOwnershipCoordinator(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ api: { workspaces: loadWorkspaces } },
);
const stalePass = coordinator.handleActivityApplied(machineA.id);
state = {
...state,
selectedMachine: machineB,
projects: [],
workspaceActivities: {},
workspacesByProjectId: {},
};
coordinator.handleSelectedMachineChanged();
state = {
...state,
selectedMachine: machineA,
projects: [candidate],
workspaceActivities: { [externalActivity.cwd]: externalActivity },
workspacesByProjectId: {},
};
coordinator.handleSelectedMachineChanged();
staleResponse.resolve([workspace(candidate.id, externalActivity.cwd)]);
await stalePass;
expect(state.workspacesByProjectId[candidate.id]).toBeUndefined();
await coordinator.handleActivityApplied(machineA.id);
expect(loadWorkspaces).toHaveBeenCalledTimes(2);
expect(loadWorkspaces).toHaveBeenNthCalledWith(1, candidate.id, machineA.id);
expect(loadWorkspaces).toHaveBeenNthCalledWith(2, candidate.id, machineA.id);
expect(state.workspacesByProjectId[candidate.id]).toEqual(currentWorkspaces);
});
it("does not resurrect a project removed while discovery is in flight", async () => {
const candidate = project();
const externalActivity = activity("/tmp/removed-project-worktree");
const staleResponse = deferred<Workspace[]>();
let state: AppState = {
...initialAppState(),
selectedMachine: localMachine,
projects: [candidate],
workspaceActivities: { [externalActivity.cwd]: externalActivity },
};
const loadWorkspaces = vi.fn<(projectId: string, machineId?: string) => Promise<Workspace[]>>()
.mockReturnValue(staleResponse.promise);
const coordinator = new ProjectActivityOwnershipCoordinator(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ api: { workspaces: loadWorkspaces } },
);
const stalePass = coordinator.handleActivityApplied(localMachine.id);
state = { ...state, projects: [], workspacesByProjectId: {} };
await coordinator.handleProjectsApplied(localMachine.id);
staleResponse.resolve([workspace(candidate.id, externalActivity.cwd)]);
await stalePass;
expect(state.projects).toEqual([]);
expect(state.workspacesByProjectId[candidate.id]).toBeUndefined();
});
it("rejects an old pass after a project-list replacement and applies only the new pass", async () => {
const originalProject = project("p1", "/old-repo");
const replacementProject = project("p1", "/new-repo");
const externalActivity = activity("/tmp/replacement-worktree");
const staleResponse = deferred<Workspace[]>();
const currentResponse = deferred<Workspace[]>();
const staleWorkspaces = [workspace(originalProject.id, originalProject.path), workspace(originalProject.id, externalActivity.cwd)];
const currentWorkspaces = [workspace(replacementProject.id, replacementProject.path), workspace(replacementProject.id, externalActivity.cwd)];
let state: AppState = {
...initialAppState(),
selectedMachine: localMachine,
projects: [originalProject],
workspaceActivities: { [externalActivity.cwd]: externalActivity },
};
const loadWorkspaces = vi.fn<(projectId: string, machineId?: string) => Promise<Workspace[]>>()
.mockImplementationOnce(() => staleResponse.promise)
.mockImplementationOnce(() => currentResponse.promise);
const coordinator = new ProjectActivityOwnershipCoordinator(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ api: { workspaces: loadWorkspaces } },
);
const stalePass = coordinator.handleActivityApplied(localMachine.id);
state = { ...state, projects: [replacementProject] };
const replacementPass = coordinator.handleProjectsApplied(localMachine.id);
expect(loadWorkspaces).toHaveBeenCalledTimes(2);
staleResponse.resolve(staleWorkspaces);
await stalePass;
expect(state.workspacesByProjectId[replacementProject.id]).toBeUndefined();
currentResponse.resolve(currentWorkspaces);
await replacementPass;
expect(state.workspacesByProjectId[replacementProject.id]).toEqual(currentWorkspaces);
});
it("does not overwrite a newer per-project workspace-cache entry", async () => {
const candidate = project();
const externalActivity = activity("/tmp/stale-discovery-worktree");
const originalCache = [workspace(candidate.id, candidate.path)];
const newerCache = [...originalCache, workspace(candidate.id, "/tmp/newer-topology")];
const staleResponse = deferred<Workspace[]>();
let state: AppState = {
...initialAppState(),
selectedMachine: localMachine,
projects: [candidate],
workspacesByProjectId: { [candidate.id]: originalCache },
workspaceActivities: { [externalActivity.cwd]: externalActivity },
};
const loadWorkspaces = vi.fn<(projectId: string, machineId?: string) => Promise<Workspace[]>>()
.mockReturnValue(staleResponse.promise);
const coordinator = new ProjectActivityOwnershipCoordinator(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ api: { workspaces: loadWorkspaces } },
);
const stalePass = coordinator.handleActivityApplied(localMachine.id);
state = {
...state,
workspacesByProjectId: { ...state.workspacesByProjectId, [candidate.id]: newerCache },
};
staleResponse.resolve([...originalCache, workspace(candidate.id, externalActivity.cwd)]);
await stalePass;
await coordinator.handleActivityApplied(localMachine.id);
expect(state.workspacesByProjectId[candidate.id]).toBe(newerCache);
expect(loadWorkspaces).toHaveBeenCalledOnce();
});
it("reports partial failures without retries or selection/global-error side effects", async () => {
const failedProject = project("failed", "/failed");
const successfulProject = project("successful", "/successful");
const selectedWorkspace = workspace(failedProject.id, failedProject.path);
const unknownActivity = activity("/tmp/possibly-failed-owner");
const failure = new Error("topology unavailable");
const reporterFailure = new Error("reporter unavailable");
let state: AppState = {
...initialAppState(),
selectedMachine: localMachine,
projects: [failedProject, successfulProject],
selectedProject: failedProject,
selectedWorkspace,
workspaces: [selectedWorkspace],
workspaceActivities: { [unknownActivity.cwd]: unknownActivity },
error: "existing global error",
};
const initialSelection = {
selectedProject: state.selectedProject,
selectedWorkspace: state.selectedWorkspace,
selectedSession: state.selectedSession,
workspaces: state.workspaces,
mainView: state.mainView,
};
const loadWorkspaces = vi.fn<(projectId: string, machineId?: string) => Promise<Workspace[]>>()
.mockImplementation((projectId) => projectId === failedProject.id ? Promise.reject(failure) : Promise.resolve([]));
const onError = vi.fn(() => { throw reporterFailure; });
const coordinator = new ProjectActivityOwnershipCoordinator(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ api: { workspaces: loadWorkspaces }, onError },
);
await expect(coordinator.handleActivityApplied(localMachine.id)).resolves.toBeUndefined();
await coordinator.handleActivityApplied(localMachine.id);
expect(loadWorkspaces.mock.calls).toEqual([
[failedProject.id, localMachine.id],
[successfulProject.id, localMachine.id],
]);
expect(onError).toHaveBeenCalledOnce();
expect(onError).toHaveBeenCalledWith({ machineId: localMachine.id, projectId: failedProject.id, error: failure });
expect(state.error).toBe("existing global error");
expect({
selectedProject: state.selectedProject,
selectedWorkspace: state.selectedWorkspace,
selectedSession: state.selectedSession,
workspaces: state.workspaces,
mainView: state.mainView,
}).toEqual(initialSelection);
});
it.each(["activity-before-project", "project-before-activity"] as const)(
"coordinates real controller hooks in %s ordering without opening the project",
async (ordering) => {
const candidate = project();
const externalActivity = activity("/tmp/controller-ordered-worktree");
const discoveredWorkspaces = [workspace(candidate.id, candidate.path), workspace(candidate.id, externalActivity.cwd)];
let state: AppState = { ...initialAppState(), selectedMachine: localMachine };
const ownershipTasks: Promise<void>[] = [];
const loadWorkspaces = vi.fn<(projectId: string, machineId?: string) => Promise<Workspace[]>>()
.mockResolvedValue(discoveredWorkspaces);
const coordinator = new ProjectActivityOwnershipCoordinator(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ api: { workspaces: loadWorkspaces } },
);
const activityController = new ActivityController(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ onActivityApplied: (machineId) => { ownershipTasks.push(coordinator.handleActivityApplied(machineId)); } },
);
const selectProject = vi.fn();
const projectController = new ProjectController(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ selectProject, forgetProject: vi.fn(), clearSelection: vi.fn() },
{
api: {
projects: vi.fn().mockResolvedValue([candidate]),
addProject: vi.fn(),
closeProject: vi.fn(),
},
onProjectsApplied: (machineId) => { ownershipTasks.push(coordinator.handleProjectsApplied(machineId)); },
},
);
if (ordering === "activity-before-project") {
activityController.applyWorkspaceActivity(externalActivity, localMachine.id);
await projectController.loadProjects();
} else {
await projectController.loadProjects();
activityController.applyWorkspaceActivity(externalActivity, localMachine.id);
}
await Promise.all(ownershipTasks);
expect(loadWorkspaces).toHaveBeenCalledOnce();
expect(state.workspacesByProjectId[candidate.id]).toEqual(discoveredWorkspaces);
expect(projectActivityIndicator(candidate, discoveredWorkspaces, state.workspaceActivities)).toBe("session");
expect(selectProject).not.toHaveBeenCalled();
expect(state.selectedProject).toBeUndefined();
expect(state.selectedWorkspace).toBeUndefined();
expect(state.selectedSession).toBeUndefined();
},
);
});
@@ -0,0 +1,201 @@
import { workspacesApi as defaultApi, type Project, type Workspace } from "../api";
import { isWorkspaceActivityActive } from "../../../shared/activity";
import { projectOwnsWorkspacePath } from "../workspaceActivity";
import { selectedMachineId, type GetState, type SetState } from "./types";
export interface ProjectActivityOwnershipFailure {
machineId: string;
projectId: string;
error: unknown;
}
export interface ProjectActivityOwnershipCoordinatorDependencies {
api?: Pick<typeof defaultApi, "workspaces">;
onError?: (failure: ProjectActivityOwnershipFailure) => void;
}
interface ProjectTopologySnapshot {
id: string;
path: string;
startingWorkspaces: Workspace[] | undefined;
}
interface OwnershipPass {
machineGeneration: number;
projectTopologyGeneration: number;
machineId: string;
projects: ProjectTopologySnapshot[];
cwdActivations: Map<string, number>;
trailingRequested: boolean;
promise: Promise<void>;
}
/**
* Hydrates only project-to-workspace topology needed to attribute active CWDs.
* Selection and navigation remain owned by their existing controllers.
*/
export class ProjectActivityOwnershipCoordinator {
private readonly api: Pick<typeof defaultApi, "workspaces">;
private readonly onError: ((failure: ProjectActivityOwnershipFailure) => void) | undefined;
private machineGeneration = 0;
private activityMachineGeneration = -1;
private projectTopologyGeneration = 0;
private nextCwdActivation = 0;
private observedActiveCwds = new Set<string>();
private readonly cwdActivations = new Map<string, number>();
private readonly attemptedCwdActivations = new Map<string, number>();
private activePass: OwnershipPass | undefined;
constructor(
private readonly getState: GetState,
private readonly setState: SetState,
deps: ProjectActivityOwnershipCoordinatorDependencies = {},
) {
this.api = deps.api ?? defaultApi;
this.onError = deps.onError;
}
handleActivityApplied(machineId: string): Promise<void> {
if (selectedMachineId(this.getState()) !== machineId) return Promise.resolve();
this.activityMachineGeneration = this.machineGeneration;
return this.reconcile(machineId);
}
handleProjectsApplied(machineId: string): Promise<void> {
if (selectedMachineId(this.getState()) !== machineId) return Promise.resolve();
this.projectTopologyGeneration += 1;
this.attemptedCwdActivations.clear();
this.activePass = undefined;
if (this.activityMachineGeneration !== this.machineGeneration) return Promise.resolve();
return this.reconcile(machineId);
}
handleSelectedMachineChanged(): void {
this.machineGeneration += 1;
this.projectTopologyGeneration += 1;
this.observedActiveCwds.clear();
this.cwdActivations.clear();
this.attemptedCwdActivations.clear();
this.activePass = undefined;
}
private reconcile(machineId: string): Promise<void> {
const state = this.getState();
if (selectedMachineId(state) !== machineId) return Promise.resolve();
const activeCwdActivations = this.syncActiveCwds(state);
const unknownCwdActivations = new Map([...activeCwdActivations].filter(([cwd, activation]) =>
!projectOwnsCwd(state.projects, state.workspacesByProjectId, cwd)
&& this.attemptedCwdActivations.get(cwd) !== activation));
const activePass = this.activePass;
if (activePass !== undefined) {
if (activePass.machineGeneration !== this.machineGeneration
|| activePass.projectTopologyGeneration !== this.projectTopologyGeneration
|| [...unknownCwdActivations].some(([cwd, activation]) => activePass.cwdActivations.get(cwd) !== activation)) {
activePass.trailingRequested = true;
}
return activePass.promise;
}
if (unknownCwdActivations.size === 0 || state.projects.length === 0) return Promise.resolve();
const pass: OwnershipPass = {
machineGeneration: this.machineGeneration,
projectTopologyGeneration: this.projectTopologyGeneration,
machineId,
projects: state.projects.map((project) => ({
id: project.id,
path: project.path,
startingWorkspaces: state.workspacesByProjectId[project.id],
})),
cwdActivations: unknownCwdActivations,
trailingRequested: false,
promise: Promise.resolve(),
};
this.activePass = pass;
pass.promise = this.runPass(pass);
return pass.promise;
}
private async runPass(pass: OwnershipPass): Promise<void> {
await Promise.all(pass.projects.map(async (project) => {
try {
const workspaces = await this.api.workspaces(project.id, pass.machineId);
this.applyProjectWorkspaces(pass, project, workspaces);
} catch (error) {
if (this.isPassScopeCurrent(pass)) this.reportError({ machineId: pass.machineId, projectId: project.id, error });
}
}));
if (this.activePass !== pass) return;
const state = this.getState();
const activeCwdActivations = this.syncActiveCwds(state);
for (const [cwd, activation] of pass.cwdActivations) {
if (activeCwdActivations.get(cwd) === activation
&& !projectOwnsCwd(state.projects, state.workspacesByProjectId, cwd)) {
this.attemptedCwdActivations.set(cwd, activation);
}
}
const runTrailingPass = pass.trailingRequested;
this.activePass = undefined;
if (runTrailingPass) await this.reconcile(selectedMachineId(this.getState()));
}
private applyProjectWorkspaces(pass: OwnershipPass, project: ProjectTopologySnapshot, workspaces: Workspace[]): void {
if (this.activePass !== pass || !this.isPassScopeCurrent(pass)) return;
const state = this.getState();
const currentProject = state.projects.find((candidate) => candidate.id === project.id);
if (currentProject?.path !== project.path) return;
if (state.workspacesByProjectId[project.id] !== project.startingWorkspaces) return;
this.setState({
workspacesByProjectId: {
...state.workspacesByProjectId,
[project.id]: workspaces,
},
});
}
private isPassScopeCurrent(pass: OwnershipPass): boolean {
return pass.machineGeneration === this.machineGeneration
&& pass.projectTopologyGeneration === this.projectTopologyGeneration
&& selectedMachineId(this.getState()) === pass.machineId;
}
private syncActiveCwds(state: ReturnType<GetState>): Map<string, number> {
const activeCwds = new Set(Object.values(state.workspaceActivities)
.filter(isWorkspaceActivityActive)
.map((activity) => activity.cwd));
for (const cwd of this.observedActiveCwds) {
if (activeCwds.has(cwd)) continue;
this.cwdActivations.delete(cwd);
this.attemptedCwdActivations.delete(cwd);
}
for (const cwd of activeCwds) {
if (this.observedActiveCwds.has(cwd)) continue;
this.nextCwdActivation += 1;
this.cwdActivations.set(cwd, this.nextCwdActivation);
this.attemptedCwdActivations.delete(cwd);
}
this.observedActiveCwds = activeCwds;
return new Map([...activeCwds].map((cwd) => [cwd, this.cwdActivations.get(cwd) ?? 0]));
}
private reportError(failure: ProjectActivityOwnershipFailure): void {
if (this.onError === undefined) return;
try {
this.onError(failure);
} catch {
// Error reporting must not turn background ownership discovery into an unhandled rejection.
}
}
}
function projectOwnsCwd(projects: Project[], workspacesByProjectId: Record<string, Workspace[]>, cwd: string): boolean {
return projects.some((project) => projectOwnsWorkspacePath(project, workspacesByProjectId[project.id] ?? [], cwd));
}
@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from "vitest";
import type { AppState } from "../appState";
import { initialAppState } from "../appState";
import type { Project, Workspace } from "../api";
import { ProjectController } from "./projectController";
function project(id: string, path: string): Project {
return { id, name: id, path, createdAt: "now" };
}
function workspace(projectId: string, path: string): Workspace {
return { id: path, projectId, path, label: path, isMain: true, isGitRepo: true, isGitWorktree: true };
}
describe("ProjectController", () => {
it("notifies ownership discovery after an applied project reload", async () => {
const currentProject = project("current", "/current");
const removedProject = project("removed", "/removed");
let state: AppState = {
...initialAppState(),
projects: [removedProject],
workspacesByProjectId: {
[currentProject.id]: [workspace(currentProject.id, currentProject.path)],
[removedProject.id]: [workspace(removedProject.id, removedProject.path)],
},
};
const onProjectsApplied = vi.fn((machineId: string) => {
expect(machineId).toBe("local");
expect(state.projects).toEqual([currentProject]);
expect(state.workspacesByProjectId).toEqual({
[currentProject.id]: [workspace(currentProject.id, currentProject.path)],
});
});
const controller = new ProjectController(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ selectProject: vi.fn(), forgetProject: vi.fn(), clearSelection: vi.fn() },
{
api: {
projects: vi.fn().mockResolvedValue([currentProject]),
addProject: vi.fn(),
closeProject: vi.fn(),
},
onProjectsApplied,
},
);
await controller.loadProjects();
expect(onProjectsApplied).toHaveBeenCalledOnce();
});
it("notifies after adding a project and preserves the existing selection flow", async () => {
const addedProject = project("added", "/added");
let state: AppState = { ...initialAppState(), projectDialogOpen: true };
const events: string[] = [];
const selectProject = vi.fn((selected: Project): Promise<void> => {
events.push("select");
expect(selected).toBe(addedProject);
return Promise.resolve();
});
const onProjectsApplied = vi.fn((machineId: string) => {
events.push("applied");
expect(machineId).toBe("local");
expect(state.projects).toEqual([addedProject]);
expect(state.projectDialogOpen).toBe(false);
});
const controller = new ProjectController(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ selectProject, forgetProject: vi.fn(), clearSelection: vi.fn() },
{
api: {
projects: vi.fn(),
addProject: vi.fn().mockResolvedValue(addedProject),
closeProject: vi.fn(),
},
onProjectsApplied,
},
);
await controller.addProject(" /added ");
expect(events).toEqual(["applied", "select"]);
expect(onProjectsApplied).toHaveBeenCalledOnce();
expect(selectProject).toHaveBeenCalledOnce();
});
it("notifies after closing a project without changing the existing clear-selection flow", async () => {
const closedProject = project("closed", "/closed");
const remainingProject = project("remaining", "/remaining");
let state: AppState = {
...initialAppState(),
projects: [closedProject, remainingProject],
selectedProject: closedProject,
workspacesByProjectId: {
[closedProject.id]: [workspace(closedProject.id, closedProject.path)],
[remainingProject.id]: [workspace(remainingProject.id, remainingProject.path)],
},
};
const events: string[] = [];
const forgetProject = vi.fn((projectId: string) => {
events.push("forget");
state = {
...state,
workspacesByProjectId: Object.fromEntries(Object.entries(state.workspacesByProjectId).filter(([id]) => id !== projectId)),
};
});
const clearSelection = vi.fn(() => { events.push("clear"); });
const onProjectsApplied = vi.fn((machineId: string) => {
events.push("applied");
expect(machineId).toBe("local");
expect(state.projects).toEqual([remainingProject]);
expect(state.workspacesByProjectId[closedProject.id]).toBeUndefined();
});
const controller = new ProjectController(
() => state,
(patch) => { state = { ...state, ...patch }; },
{ selectProject: vi.fn(), forgetProject, clearSelection },
{
api: {
projects: vi.fn(),
addProject: vi.fn(),
closeProject: vi.fn().mockResolvedValue(undefined),
},
onProjectsApplied,
},
);
await controller.closeProject(closedProject.id);
expect(events).toEqual(["forget", "applied", "clear"]);
expect(onProjectsApplied).toHaveBeenCalledOnce();
expect(clearSelection).toHaveBeenCalledOnce();
});
});
@@ -1,19 +1,36 @@
import { api } from "../api";
import { api as defaultApi } from "../api";
import { selectedMachineId, type GetState, type SetState } from "./types";
import type { WorkspaceController } from "./workspaceController";
export interface ProjectControllerDependencies {
api?: Pick<typeof defaultApi, "projects" | "addProject" | "closeProject">;
onProjectsApplied?: (machineId: string) => void;
}
export class ProjectController {
constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly workspaces: WorkspaceController) {}
private readonly api: Pick<typeof defaultApi, "projects" | "addProject" | "closeProject">;
private readonly onProjectsApplied: ((machineId: string) => void) | undefined;
constructor(
private readonly getState: GetState,
private readonly setState: SetState,
private readonly workspaces: Pick<WorkspaceController, "selectProject" | "forgetProject" | "clearSelection">,
deps: ProjectControllerDependencies = {},
) {
this.api = deps.api ?? defaultApi;
this.onProjectsApplied = deps.onProjectsApplied;
}
async loadProjects() {
const machineId = selectedMachineId(this.getState());
this.setState({ error: "", isLoadingProjects: true });
try {
const projects = await api.projects(machineId);
const projects = await this.api.projects(machineId);
if (selectedMachineId(this.getState()) !== machineId) return;
const projectIds = new Set(projects.map((project) => project.id));
const workspacesByProjectId = Object.fromEntries(Object.entries(this.getState().workspacesByProjectId).filter(([projectId]) => projectIds.has(projectId)));
this.setState({ projects, workspacesByProjectId });
this.onProjectsApplied?.(machineId);
} catch (error) {
if (selectedMachineId(this.getState()) === machineId) this.setState({ error: String(error) });
} finally {
@@ -23,25 +40,31 @@ export class ProjectController {
async addProject(path: string, create?: boolean) {
if (path.trim() === "") return;
const machineId = selectedMachineId(this.getState());
try {
const project = await api.addProject(path.trim(), undefined, create, selectedMachineId(this.getState()));
const project = await this.api.addProject(path.trim(), undefined, create, machineId);
if (selectedMachineId(this.getState()) !== machineId) return;
const projects = this.getState().projects;
this.setState({ projects: [...projects.filter((p) => p.id !== project.id), project], projectDialogOpen: false });
this.onProjectsApplied?.(machineId);
await this.workspaces.selectProject(project);
} catch (error) {
this.setState({ error: String(error) });
if (selectedMachineId(this.getState()) === machineId) this.setState({ error: String(error) });
}
}
async closeProject(projectId: string) {
const machineId = selectedMachineId(this.getState());
try {
await api.closeProject(projectId, selectedMachineId(this.getState()));
await this.api.closeProject(projectId, machineId);
if (selectedMachineId(this.getState()) !== machineId) return;
this.workspaces.forgetProject(projectId);
const state = this.getState();
this.setState({ projects: state.projects.filter((p) => p.id !== projectId) });
this.onProjectsApplied?.(machineId);
if (state.selectedProject?.id === projectId) this.workspaces.clearSelection();
} catch (error) {
this.setState({ error: String(error) });
if (selectedMachineId(this.getState()) === machineId) this.setState({ error: String(error) });
}
}
}
+7 -10
View File
@@ -15,6 +15,12 @@ export function projectActivityIndicator(project: Project, knownWorkspaces: Work
return workspaceActivitiesIndicator(matchedProjectActivities(project, knownWorkspaces, activities));
}
export function projectOwnsWorkspacePath(project: Project, knownWorkspaces: readonly Workspace[], cwd: string): boolean {
return knownWorkspaces.some((workspace) => workspace.projectId === project.id && workspace.path === cwd)
|| cwd === project.path
|| cwd.startsWith(`${project.path}/`);
}
export function machineActivityIndicator(activities: Record<string, WorkspaceActivity> | undefined): ActivityIndicatorKind | undefined {
return workspaceActivitiesIndicator(Object.values(activities ?? {}));
}
@@ -26,14 +32,5 @@ function workspaceActivitiesIndicator(activities: WorkspaceActivity[]): Activity
}
function matchedProjectActivities(project: Project, knownWorkspaces: Workspace[], activities: Record<string, WorkspaceActivity>): WorkspaceActivity[] {
const knownWorkspacePaths = new Set(knownWorkspaces.filter((workspace) => workspace.projectId === project.id).map((workspace) => workspace.path));
const matched = new Map<string, WorkspaceActivity>();
for (const path of knownWorkspacePaths) {
const activity = activities[path];
if (activity !== undefined) matched.set(activity.cwd, activity);
}
for (const activity of Object.values(activities)) {
if (activity.cwd === project.path || activity.cwd.startsWith(`${project.path}/`)) matched.set(activity.cwd, activity);
}
return [...matched.values()];
return Object.values(activities).filter((activity) => projectOwnsWorkspacePath(project, knownWorkspaces, activity.cwd));
}