Archived
fix: show project activity for external worktrees
This commit is contained in:
@@ -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.
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user