From b85e1b9ebe25701ed0928e7ce011374eb117aec4 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 19 Jul 2026 06:13:09 +0200 Subject: [PATCH] fix: show project activity for external worktrees --- .changeset/calm-projects-glow.md | 5 + src/client/src/components/PiWebApp.ts | 14 + .../controllers/activityController.test.ts | 24 +- .../src/controllers/activityController.ts | 5 + ...rojectActivityOwnershipCoordinator.test.ts | 649 ++++++++++++++++++ .../projectActivityOwnershipCoordinator.ts | 201 ++++++ .../src/controllers/projectController.test.ts | 136 ++++ .../src/controllers/projectController.ts | 37 +- src/client/src/workspaceActivity.ts | 17 +- 9 files changed, 1070 insertions(+), 18 deletions(-) create mode 100644 .changeset/calm-projects-glow.md create mode 100644 src/client/src/controllers/projectActivityOwnershipCoordinator.test.ts create mode 100644 src/client/src/controllers/projectActivityOwnershipCoordinator.ts create mode 100644 src/client/src/controllers/projectController.test.ts diff --git a/.changeset/calm-projects-glow.md b/.changeset/calm-projects-glow.md new file mode 100644 index 0000000..9a8be96 --- /dev/null +++ b/.changeset/calm-projects-glow.md @@ -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. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 823eaeb..40312e1 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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(); diff --git a/src/client/src/controllers/activityController.test.ts b/src/client/src/controllers/activityController.test.ts index a51d248..3864545 100644 --- a/src/client/src/controllers/activityController.test.ts +++ b/src/client/src/controllers/activityController.test.ts @@ -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[] = []; + 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") }, + ]); + }); }); diff --git a/src/client/src/controllers/activityController.ts b/src/client/src/controllers/activityController.ts index df6532a..3eb8104 100644 --- a/src/client/src/controllers/activityController.ts +++ b/src/client/src/controllers/activityController.ts @@ -5,14 +5,17 @@ import { TrailingRefreshCoordinator } from "./trailingRefreshCoordinator"; export interface ActivityControllerDependencies { api?: Pick; + onActivityApplied?: (machineId: string) => void; } export class ActivityController { private readonly api: Pick; + private readonly onActivityApplied: ((machineId: string) => void) | undefined; private readonly refreshes = new TrailingRefreshCoordinator(); 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 { @@ -30,6 +33,7 @@ export class ActivityController { machineActivities: { ...state.machineActivities, [machineId]: nextMachineActivities }, ...(isSelectedMachine ? { workspaceActivities: nextMachineActivities } : {}), }); + this.onActivityApplied?.(machineId); } private applyMachineActivitySnapshot(machineId: string, activities: Record): void { @@ -38,6 +42,7 @@ export class ActivityController { machineActivities: { ...state.machineActivities, [machineId]: activities }, ...(selectedMachineId(state) === machineId ? { workspaceActivities: activities } : {}), }); + this.onActivityApplied?.(machineId); } } diff --git a/src/client/src/controllers/projectActivityOwnershipCoordinator.test.ts b/src/client/src/controllers/projectActivityOwnershipCoordinator.test.ts new file mode 100644 index 0000000..9bf9b2b --- /dev/null +++ b/src/client/src/controllers/projectActivityOwnershipCoordinator.test.ts @@ -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 { + 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(): { promise: Promise; resolve: (value: T) => void; reject: (error: unknown) => void } { + let resolveDeferred: ((value: T) => void) | undefined; + let rejectDeferred: ((error: unknown) => void) | undefined; + const promise = new Promise((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>() + .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>(); + 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>() + .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>() + .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(); + let state: AppState = { + ...initialAppState(), + selectedMachine: localMachine, + projects: [candidate], + workspaceActivities: { [unknownActivity.cwd]: unknownActivity }, + }; + const loadWorkspaces = vi.fn<(projectId: string, machineId?: string) => Promise>() + .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(); + const trailingResponse = deferred(); + const trailingStarted = deferred(); + 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>() + .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>() + .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>() + .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>() + .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(); + 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>() + .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(); + let state: AppState = { + ...initialAppState(), + selectedMachine: localMachine, + projects: [candidate], + workspaceActivities: { [externalActivity.cwd]: externalActivity }, + }; + const loadWorkspaces = vi.fn<(projectId: string, machineId?: string) => Promise>() + .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(); + const currentResponse = deferred(); + 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>() + .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(); + 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>() + .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>() + .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[] = []; + const loadWorkspaces = vi.fn<(projectId: string, machineId?: string) => Promise>() + .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(); + }, + ); +}); diff --git a/src/client/src/controllers/projectActivityOwnershipCoordinator.ts b/src/client/src/controllers/projectActivityOwnershipCoordinator.ts new file mode 100644 index 0000000..076a65c --- /dev/null +++ b/src/client/src/controllers/projectActivityOwnershipCoordinator.ts @@ -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; + 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; + trailingRequested: boolean; + promise: Promise; +} + +/** + * 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; + private readonly onError: ((failure: ProjectActivityOwnershipFailure) => void) | undefined; + private machineGeneration = 0; + private activityMachineGeneration = -1; + private projectTopologyGeneration = 0; + private nextCwdActivation = 0; + private observedActiveCwds = new Set(); + private readonly cwdActivations = new Map(); + private readonly attemptedCwdActivations = new Map(); + 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 { + if (selectedMachineId(this.getState()) !== machineId) return Promise.resolve(); + this.activityMachineGeneration = this.machineGeneration; + return this.reconcile(machineId); + } + + handleProjectsApplied(machineId: string): Promise { + 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 { + 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 { + 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): Map { + 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, cwd: string): boolean { + return projects.some((project) => projectOwnsWorkspacePath(project, workspacesByProjectId[project.id] ?? [], cwd)); +} diff --git a/src/client/src/controllers/projectController.test.ts b/src/client/src/controllers/projectController.test.ts new file mode 100644 index 0000000..9fa5a1d --- /dev/null +++ b/src/client/src/controllers/projectController.test.ts @@ -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 => { + 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(); + }); +}); diff --git a/src/client/src/controllers/projectController.ts b/src/client/src/controllers/projectController.ts index 567f025..6ca6dd3 100644 --- a/src/client/src/controllers/projectController.ts +++ b/src/client/src/controllers/projectController.ts @@ -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; + onProjectsApplied?: (machineId: string) => void; +} + export class ProjectController { - constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly workspaces: WorkspaceController) {} + private readonly api: Pick; + private readonly onProjectsApplied: ((machineId: string) => void) | undefined; + + constructor( + private readonly getState: GetState, + private readonly setState: SetState, + private readonly workspaces: Pick, + 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) }); } } } diff --git a/src/client/src/workspaceActivity.ts b/src/client/src/workspaceActivity.ts index 3fde6ce..e7c689b 100644 --- a/src/client/src/workspaceActivity.ts +++ b/src/client/src/workspaceActivity.ts @@ -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 | 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): WorkspaceActivity[] { - const knownWorkspacePaths = new Set(knownWorkspaces.filter((workspace) => workspace.projectId === project.id).map((workspace) => workspace.path)); - const matched = new Map(); - 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)); }