diff --git a/.changeset/remember-machine-navigation.md b/.changeset/remember-machine-navigation.md new file mode 100644 index 0000000..2702826 --- /dev/null +++ b/.changeset/remember-machine-navigation.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Remember each machine's last selected project, workspace, session, and workspace tool when switching machines in the web UI. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index fae6329..052796d 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -12,6 +12,7 @@ import { MachineController } from "../controllers/machineController"; import { ProjectController } from "../controllers/projectController"; import { SessionController } from "../controllers/sessionController"; import { WorkspaceController, canDeleteWorkspace } from "../controllers/workspaceController"; +import { emptyMachineNavigationSnapshot, InMemoryMachineNavigationMemory, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot, type MachineNavigationSnapshot, type WorkspaceRouteSurface } from "../controllers/machineNavigationMemory"; import { InMemoryTerminalSelectionMemory } from "../controllers/terminalSelection"; import { KeyboardShortcutDispatcher } from "../keyboardShortcuts"; import { selectedMachineId } from "../controllers/types"; @@ -61,6 +62,8 @@ const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const; const THEME_AUTO_ON_VALUE = "auto:on"; const THEME_AUTO_OFF_VALUE = "auto:off"; const THEME_OPTION_PREFIX = "theme:"; +const FILES_ROUTE_NAMESPACE = queryNamespace("core:workspace.files"); +const GIT_ROUTE_NAMESPACE = queryNamespace("core:workspace.git"); const TERMINAL_ROUTE_NAMESPACE = queryNamespace("core:workspace.terminal"); @customElement("pi-web-app") @@ -113,6 +116,7 @@ export class PiWebApp extends LitElement { private readonly keyboard = new KeyboardShortcutDispatcher(); private readonly realtime = new RealtimeSocket(); private readonly activeTerminalIds = new Set(); + private readonly machineNavigation = new InMemoryMachineNavigationMemory(); private readonly terminalSelection = new InMemoryTerminalSelectionMemory(); private readonly appShell = new AppShellController(this); private readonly panelCollapse = new PanelCollapseController(this); @@ -128,7 +132,9 @@ export class PiWebApp extends LitElement { private refreshingWorkspaceDeletionRuns = false; private readonly handledWorkspaceDeletionRunIds = new Set(); private readonly terminalCommandRunRuntimes = new Map(); - private routeRestoreInProgress = false; + private machineNavigationRestoreSeq = 0; + private routeRestoreSeq = 0; + private routeRestoreDepth = 0; private restoringRouteTerminalId: string | undefined; private readonly plugins = createPluginRegistry(); private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE; @@ -162,6 +168,10 @@ export class PiWebApp extends LitElement { private readonly onSystemLightThemeChange = () => { if (this.themePreference.auto) this.applyPreferredTheme(false); }; + private get routeRestoreInProgress(): boolean { + return this.routeRestoreDepth > 0; + } + private readonly onKeyDown = (event: KeyboardEvent) => { if (this.keyboard.handle(event, this.getActions())) { event.preventDefault(); @@ -229,6 +239,7 @@ export class PiWebApp extends LitElement { await this.projects.loadProjects(); if (machineFallbackMessage !== "" && this.state.error === "") this.setState({ error: machineFallbackMessage }); await this.withChatScrollTransition(() => this.restoreRouteFor(effectiveRoute, false)); + this.rememberCurrentMachineNavigation(); await this.refreshWorkspaceDeletionRuns(); } @@ -291,37 +302,67 @@ export class PiWebApp extends LitElement { private async restoreRoute(updateUrl: boolean) { await this.restoreRouteFor(readRoute(), updateUrl); + this.rememberCurrentMachineNavigation(); } - private async restoreRouteFor(route: AppRoute, updateUrl: boolean) { - await this.restoreRouteMachine(route, updateUrl); - const selectedFilePath = route.projectId === undefined ? undefined : readNamespacedString(queryNamespace("core:workspace.files"), "file"); - const selectedDiffPath = route.projectId === undefined ? undefined : readNamespacedString(queryNamespace("core:workspace.git"), "diff"); - const selectedTerminalId = route.projectId === undefined ? undefined : readNamespacedString(TERMINAL_ROUTE_NAMESPACE, "terminal"); - this.routeRestoreInProgress = true; - this.restoringRouteTerminalId = selectedTerminalId; + private async restoreRouteFor(route: AppRoute, updateUrl: boolean, surface = this.readWorkspaceRouteSurface(route), restoredMainView?: AppState["mainView"]) { + const routeSurface = route.projectId === undefined || route.projectId === "" ? emptyWorkspaceRouteSurface() : surface; + const restoreSeq = ++this.routeRestoreSeq; + this.routeRestoreDepth += 1; + this.restoringRouteTerminalId = routeSurface.selectedTerminalId; try { - this.setState({ workspaceTool: route.tool ?? this.state.workspaceTool, mainView: route.view ?? this.defaultRouteView(), selectedFilePath, selectedDiffPath, selectedTerminalId }); - if (route.projectId === undefined || route.projectId === "") return; + await this.restoreRouteMachine(route, false); + if (!this.isCurrentRouteRestore(restoreSeq)) return; + this.setState({ + workspaceTool: route.tool ?? this.state.workspaceTool, + mainView: restoredMainView ?? route.view ?? this.defaultRouteView(), + selectedFilePath: routeSurface.selectedFilePath, + selectedDiffPath: routeSurface.selectedDiffPath, + selectedTerminalId: routeSurface.selectedTerminalId, + }); + if (route.projectId === undefined || route.projectId === "") { + if (updateUrl) this.updateUrl(); + return; + } if (this.routeMatchesCurrentSelection(route)) { - if (selectedTerminalId !== undefined) this.rememberSelectedTerminal(selectedTerminalId); - await this.refreshRestoredWorkspaceTool(route.tool, selectedFilePath); + if (routeSurface.selectedTerminalId !== undefined) this.rememberSelectedTerminal(routeSurface.selectedTerminalId); + await this.refreshRestoredWorkspaceTool(route.tool, routeSurface.selectedFilePath); this.git.updatePolling(); + if (updateUrl) this.updateUrl(); return; } const project = this.state.projects.find((p) => p.id === route.projectId); - if (!project) return; - await this.workspaces.selectProject(project, { workspaceId: route.workspaceId, sessionId: route.sessionId, updateUrl }); - this.setState({ selectedFilePath, selectedDiffPath, selectedTerminalId }); - if (selectedTerminalId !== undefined) this.rememberSelectedTerminal(selectedTerminalId); - await this.refreshRestoredWorkspaceTool(route.tool, selectedFilePath); + if (!project) { + this.setState({ selectedFilePath: undefined, selectedDiffPath: undefined, selectedTerminalId: undefined }); + if (updateUrl) this.updateUrl(); + return; + } + await this.workspaces.selectProject(project, { workspaceId: route.workspaceId, sessionId: route.sessionId, updateUrl: false }); + if (!this.isCurrentRouteRestore(restoreSeq)) return; + this.setState({ selectedFilePath: routeSurface.selectedFilePath, selectedDiffPath: routeSurface.selectedDiffPath, selectedTerminalId: routeSurface.selectedTerminalId }); + if (routeSurface.selectedTerminalId !== undefined) this.rememberSelectedTerminal(routeSurface.selectedTerminalId); + await this.refreshRestoredWorkspaceTool(route.tool, routeSurface.selectedFilePath); this.git.updatePolling(); + if (updateUrl) this.updateUrl(); } finally { - this.routeRestoreInProgress = false; - this.restoringRouteTerminalId = undefined; + this.routeRestoreDepth = Math.max(0, this.routeRestoreDepth - 1); + if (this.routeRestoreDepth === 0) this.restoringRouteTerminalId = undefined; } } + private isCurrentRouteRestore(restoreSeq: number): boolean { + return restoreSeq === this.routeRestoreSeq; + } + + private readWorkspaceRouteSurface(route: AppRoute): WorkspaceRouteSurface { + if (route.projectId === undefined || route.projectId === "") return emptyWorkspaceRouteSurface(); + return { + selectedFilePath: readNamespacedString(FILES_ROUTE_NAMESPACE, "file"), + selectedDiffPath: readNamespacedString(GIT_ROUTE_NAMESPACE, "diff"), + selectedTerminalId: readNamespacedString(TERMINAL_ROUTE_NAMESPACE, "terminal"), + }; + } + private routeForSelectedMachine(route: AppRoute): AppRoute { const currentMachineId = this.state.selectedMachine?.id ?? "local"; if ((route.machineId ?? "local") === currentMachineId) return route; @@ -330,8 +371,8 @@ export class PiWebApp extends LitElement { private replaceRouteAndClearWorkspaceQuery(route: AppRoute): void { writeRoute(route, { replace: true }); - setNamespacedQueryKey(queryNamespace("core:workspace.files"), "file", undefined, { replace: true }); - setNamespacedQueryKey(queryNamespace("core:workspace.git"), "diff", undefined, { replace: true }); + setNamespacedQueryKey(FILES_ROUTE_NAMESPACE, "file", undefined, { replace: true }); + setNamespacedQueryKey(GIT_ROUTE_NAMESPACE, "diff", undefined, { replace: true }); setNamespacedQueryKey(TERMINAL_ROUTE_NAMESPACE, "terminal", undefined, { replace: true }); } @@ -383,6 +424,7 @@ export class PiWebApp extends LitElement { } private updateUrl(options?: { replace?: boolean | undefined }) { + this.rememberCurrentMachineNavigation(); writeRoute({ machineId: this.state.selectedMachine?.id, projectId: this.state.selectedProject?.id, @@ -391,6 +433,45 @@ export class PiWebApp extends LitElement { tool: this.state.workspaceTool, view: this.state.mainView === "navigation" ? undefined : this.state.mainView, }, options); + this.syncWorkspaceRouteSurfaceToUrl(); + } + + private rememberCurrentMachineNavigation(): void { + this.machineNavigation.remember(machineNavigationSnapshotFromState(this.state)); + } + + private syncWorkspaceRouteSurfaceToUrl(): void { + this.writeWorkspaceRouteSurfaceToUrl(machineNavigationSnapshotFromState(this.state).surface); + } + + private writeMachineNavigationSnapshotToUrl(snapshot: MachineNavigationSnapshot, options?: { replace?: boolean | undefined }): void { + writeRoute(routeFromMachineNavigationSnapshot(snapshot), options); + this.writeWorkspaceRouteSurfaceToUrl(snapshot.surface); + } + + private writeWorkspaceRouteSurfaceToUrl(surface: WorkspaceRouteSurface): void { + setNamespacedQueryKey(FILES_ROUTE_NAMESPACE, "file", surface.selectedFilePath, { replace: true }); + setNamespacedQueryKey(GIT_ROUTE_NAMESPACE, "diff", surface.selectedDiffPath, { replace: true }); + setNamespacedQueryKey(TERMINAL_ROUTE_NAMESPACE, "terminal", surface.selectedTerminalId, { replace: true }); + } + + private async selectMachineWithMemory(machine: Machine): Promise { + if (this.state.selectedMachine?.id === machine.id) return; + if (!this.routeRestoreInProgress) this.rememberCurrentMachineNavigation(); + const seq = ++this.machineNavigationRestoreSeq; + const snapshot = this.machineNavigation.latest(machine.id) ?? emptyMachineNavigationSnapshot(machine.id); + await this.restoreRouteFor(routeFromMachineNavigationSnapshot(snapshot), false, snapshot.surface, snapshot.view); + if (seq !== this.machineNavigationRestoreSeq || this.state.selectedMachine?.id !== machine.id) return; + if (this.shouldPreserveUnrestoredMachineNavigation(snapshot)) { + this.machineNavigation.remember(snapshot); + this.writeMachineNavigationSnapshotToUrl(snapshot); + return; + } + this.updateUrl(); + } + + private shouldPreserveUnrestoredMachineNavigation(snapshot: MachineNavigationSnapshot): boolean { + return snapshot.projectId !== undefined && this.state.selectedProject?.id !== snapshot.projectId && this.state.error !== ""; } private openWorkspaceTool(tool: QualifiedContributionId) { @@ -423,18 +504,20 @@ export class PiWebApp extends LitElement { } private async openRuntimeTerminal(machineId: string, workspace: Workspace | undefined, options?: { terminalId?: string | undefined }): Promise { - if (selectedMachineId(this.state) !== machineId) { - const machine = this.state.machines.find((candidate) => candidate.id === machineId); - if (machine === undefined) { + if (selectedMachineId(this.state) !== machineId || (workspace !== undefined && (this.state.selectedWorkspace?.id !== workspace.id || this.state.selectedProject?.id !== workspace.projectId))) { + if (!this.routeRestoreInProgress) this.rememberCurrentMachineNavigation(); + await this.restoreRouteFor({ + machineId, + projectId: workspace?.projectId, + workspaceId: workspace?.id, + sessionId: undefined, + tool: "core:workspace.terminal", + view: "core:workspace.terminal", + }, false, { selectedTerminalId: options?.terminalId }, "core:workspace.terminal"); + if (selectedMachineId(this.state) !== machineId) { this.setState({ error: "Machine not found for terminal command run" }); return; } - await this.machines.selectMachine(machine); - } - if (workspace !== undefined && (this.state.selectedWorkspace?.id !== workspace.id || this.state.selectedProject?.id !== workspace.projectId)) { - const project = this.state.projects.find((candidate) => candidate.id === workspace.projectId); - if (project !== undefined && this.state.selectedProject?.id !== project.id) await this.workspaces.selectProject(project, { workspaceId: workspace.id }); - else await this.workspaces.selectWorkspace(workspace); } this.openTerminal(options); } @@ -442,6 +525,7 @@ export class PiWebApp extends LitElement { private selectTerminal(terminalId: string | undefined, options?: { replace?: boolean | undefined }): void { this.rememberSelectedTerminal(terminalId); this.setState({ selectedTerminalId: terminalId }); + this.rememberCurrentMachineNavigation(); this.writeSelectedTerminalToUrl(terminalId, options); } @@ -495,7 +579,10 @@ export class PiWebApp extends LitElement { this.activeTerminalIds.clear(); const selectedTerminalId = this.routeRestoreInProgress ? this.restoringRouteTerminalId : next.selectedWorkspace === undefined ? undefined : this.terminalSelection.latestTerminalId(this.terminalWorkspaceKey(next.selectedWorkspace)); this.setState({ activeTerminalCount: 0, selectedTerminalId }); - if (!this.routeRestoreInProgress) this.writeSelectedTerminalToUrl(selectedTerminalId, { replace: true }); + if (!this.routeRestoreInProgress) { + this.rememberCurrentMachineNavigation(); + this.writeSelectedTerminalToUrl(selectedTerminalId, { replace: true }); + } if (next.selectedWorkspace === undefined) return; void this.refreshActiveTerminals(next.selectedWorkspace); void this.refreshWorkspaceDeletionRuns(); @@ -635,7 +722,7 @@ export class PiWebApp extends LitElement { .onToggleMachines=${() => { this.mobileNavigation.toggle("machines"); }} .onSelectMachine=${(machine: Machine) => this.withChatScrollTransition(async () => { this.mobileNavigation.expand("projects"); - await this.machines.selectMachine(machine); + await this.selectMachineWithMemory(machine); })} .onRemoveMachine=${(machine: Machine) => { void this.removeMachine(machine); }} .projects=${this.state.projects} @@ -1211,6 +1298,10 @@ function isTerminalEvent(event: RealtimeEvent): event is TerminalUiEvent { return event.type === "terminal.created" || event.type === "terminal.exited" || event.type === "terminal.closed"; } +function emptyWorkspaceRouteSurface(): WorkspaceRouteSurface { + return {}; +} + function machineScopedKey(machineId: string, value: string): string { return JSON.stringify([machineId, value]); } diff --git a/src/client/src/controllers/machineController.ts b/src/client/src/controllers/machineController.ts index 7903cfa..3324822 100644 --- a/src/client/src/controllers/machineController.ts +++ b/src/client/src/controllers/machineController.ts @@ -26,6 +26,7 @@ export class MachineController { selectedMachine: machine, projects: [], workspaces: [], + isLoadingWorkspaces: false, selectedProject: undefined, selectedWorkspace: undefined, selectedSession: undefined, diff --git a/src/client/src/controllers/machineNavigationMemory.test.ts b/src/client/src/controllers/machineNavigationMemory.test.ts new file mode 100644 index 0000000..c2d443a --- /dev/null +++ b/src/client/src/controllers/machineNavigationMemory.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { initialAppState, type AppState } from "../appState"; +import type { Machine, Project, SessionInfo, Workspace } from "../api"; +import { emptyMachineNavigationSnapshot, InMemoryMachineNavigationMemory, machineNavigationSnapshotFromState, routeFromMachineNavigationSnapshot } from "./machineNavigationMemory"; + +describe("InMemoryMachineNavigationMemory", () => { + it("remembers independent navigation snapshots per machine", () => { + const memory = new InMemoryMachineNavigationMemory(); + + memory.remember({ machineId: "local", projectId: "local-project", surface: { selectedFilePath: "README.md" } }); + memory.remember({ machineId: "remote", projectId: "remote-project", workspaceId: "remote-workspace", sessionId: "remote-session", surface: {} }); + + expect(memory.latest("local")?.projectId).toBe("local-project"); + expect(memory.latest("remote")?.workspaceId).toBe("remote-workspace"); + + memory.forget("local"); + + expect(memory.latest("local")).toBeUndefined(); + expect(memory.latest("remote")?.projectId).toBe("remote-project"); + }); + + it("returns cloned snapshots so callers cannot mutate memory", () => { + const memory = new InMemoryMachineNavigationMemory(); + + memory.remember({ machineId: "local", surface: { selectedFilePath: "README.md" } }); + const snapshot = memory.latest("local"); + if (snapshot !== undefined) snapshot.surface.selectedFilePath = "changed.ts"; + + expect(memory.latest("local")?.surface.selectedFilePath).toBe("README.md"); + }); +}); + +describe("machineNavigationSnapshotFromState", () => { + it("captures the selected machine location and workspace surface", () => { + const state: AppState = { + ...initialAppState(), + selectedMachine: machine("remote"), + selectedProject: project("project"), + selectedWorkspace: workspace("workspace", "project"), + selectedSession: session("session"), + workspaceTool: "core:workspace.files", + mainView: "core:workspace.files", + selectedFilePath: "src/main.ts", + selectedDiffPath: "README.md", + selectedTerminalId: "terminal-1", + }; + + expect(machineNavigationSnapshotFromState(state)).toEqual({ + machineId: "remote", + projectId: "project", + workspaceId: "workspace", + sessionId: "session", + tool: "core:workspace.files", + view: "core:workspace.files", + surface: { + selectedFilePath: "src/main.ts", + selectedDiffPath: "README.md", + selectedTerminalId: "terminal-1", + }, + }); + }); + + it("does not carry workspace surface without a selected workspace", () => { + const state: AppState = { + ...initialAppState(), + selectedFilePath: "src/main.ts", + selectedDiffPath: "README.md", + selectedTerminalId: "terminal-1", + }; + + expect(machineNavigationSnapshotFromState(state).surface).toEqual({ + selectedFilePath: undefined, + selectedDiffPath: undefined, + selectedTerminalId: undefined, + }); + }); +}); + +describe("routeFromMachineNavigationSnapshot", () => { + it("converts navigation snapshots to URL routes", () => { + expect(routeFromMachineNavigationSnapshot({ + machineId: "remote", + projectId: "project", + workspaceId: "workspace", + sessionId: "session", + tool: "core:workspace.git", + view: "navigation", + surface: {}, + })).toEqual({ + machineId: "remote", + projectId: "project", + workspaceId: "workspace", + sessionId: "session", + tool: "core:workspace.git", + view: undefined, + }); + }); + + it("creates an empty machine-only snapshot", () => { + expect(emptyMachineNavigationSnapshot("remote")).toEqual({ machineId: "remote", surface: {} }); + }); +}); + +function machine(id: string): Machine { + return { id, name: id, kind: id === "local" ? "local" : "remote", createdAt: "now", updatedAt: "now" }; +} + +function project(id: string): Project { + return { id, name: id, path: `/tmp/${id}`, createdAt: "now" }; +} + +function workspace(id: string, projectId: string): Workspace { + return { id, projectId, path: `/tmp/${projectId}/${id}`, label: id, isMain: true, isGitRepo: true, isGitWorktree: false }; +} + +function session(id: string): SessionInfo { + return { id, path: `/tmp/project/.pi/sessions/${id}`, cwd: "/tmp/project", created: "now", modified: "now", messageCount: 0, firstMessage: "" }; +} diff --git a/src/client/src/controllers/machineNavigationMemory.ts b/src/client/src/controllers/machineNavigationMemory.ts new file mode 100644 index 0000000..59abf00 --- /dev/null +++ b/src/client/src/controllers/machineNavigationMemory.ts @@ -0,0 +1,81 @@ +import type { AppState } from "../appState"; +import { LOCAL_MACHINE_ID } from "../machineKeys"; +import type { AppRoute } from "../route"; + +export interface WorkspaceRouteSurface { + selectedFilePath?: string | undefined; + selectedDiffPath?: string | undefined; + selectedTerminalId?: string | undefined; +} + +export interface MachineNavigationSnapshot { + machineId: string; + projectId?: string | undefined; + workspaceId?: string | undefined; + sessionId?: string | undefined; + tool?: AppRoute["tool"]; + view?: AppState["mainView"] | undefined; + surface: WorkspaceRouteSurface; +} + +export interface MachineNavigationMemory { + latest(machineId: string): MachineNavigationSnapshot | undefined; + remember(snapshot: MachineNavigationSnapshot): void; + forget(machineId: string): void; +} + +export class InMemoryMachineNavigationMemory implements MachineNavigationMemory { + private readonly snapshotsByMachine = new Map(); + + latest(machineId: string): MachineNavigationSnapshot | undefined { + const snapshot = this.snapshotsByMachine.get(machineId); + return snapshot === undefined ? undefined : cloneSnapshot(snapshot); + } + + remember(snapshot: MachineNavigationSnapshot): void { + this.snapshotsByMachine.set(snapshot.machineId, cloneSnapshot(snapshot)); + } + + forget(machineId: string): void { + this.snapshotsByMachine.delete(machineId); + } +} + +export function emptyMachineNavigationSnapshot(machineId: string): MachineNavigationSnapshot { + return { machineId, surface: {} }; +} + +export function machineNavigationSnapshotFromState(state: AppState): MachineNavigationSnapshot { + const hasWorkspace = state.selectedWorkspace !== undefined; + return { + machineId: state.selectedMachine?.id ?? LOCAL_MACHINE_ID, + projectId: state.selectedProject?.id, + workspaceId: state.selectedWorkspace?.id, + sessionId: state.selectedSession?.id, + tool: state.workspaceTool, + view: state.mainView, + surface: { + selectedFilePath: hasWorkspace ? state.selectedFilePath : undefined, + selectedDiffPath: hasWorkspace ? state.selectedDiffPath : undefined, + selectedTerminalId: hasWorkspace ? state.selectedTerminalId : undefined, + }, + }; +} + +export function routeFromMachineNavigationSnapshot(snapshot: MachineNavigationSnapshot): AppRoute { + return { + machineId: snapshot.machineId, + projectId: snapshot.projectId, + workspaceId: snapshot.workspaceId, + sessionId: snapshot.sessionId, + tool: snapshot.tool, + view: snapshot.view === "navigation" ? undefined : snapshot.view, + }; +} + +function cloneSnapshot(snapshot: MachineNavigationSnapshot): MachineNavigationSnapshot { + return { + ...snapshot, + surface: { ...snapshot.surface }, + }; +} diff --git a/src/client/src/controllers/projectController.ts b/src/client/src/controllers/projectController.ts index e382e4d..567f025 100644 --- a/src/client/src/controllers/projectController.ts +++ b/src/client/src/controllers/projectController.ts @@ -6,16 +6,18 @@ export class ProjectController { constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly workspaces: WorkspaceController) {} async loadProjects() { + const machineId = selectedMachineId(this.getState()); this.setState({ error: "", isLoadingProjects: true }); try { - const projects = await api.projects(selectedMachineId(this.getState())); + const projects = await 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 }); } catch (error) { - this.setState({ error: String(error) }); + if (selectedMachineId(this.getState()) === machineId) this.setState({ error: String(error) }); } finally { - this.setState({ isLoadingProjects: false }); + if (selectedMachineId(this.getState()) === machineId) this.setState({ isLoadingProjects: false }); } } diff --git a/src/client/src/controllers/workspaceController.ts b/src/client/src/controllers/workspaceController.ts index bfe9d5a..35f422a 100644 --- a/src/client/src/controllers/workspaceController.ts +++ b/src/client/src/controllers/workspaceController.ts @@ -37,17 +37,18 @@ export class WorkspaceController { } async selectProject(project: Project, target?: RouteTarget) { + const machineId = selectedMachineId(this.getState()); this.sessions.clearActiveSession(); this.setState({ selectedProject: project, selectedWorkspace: undefined, workspaces: [], isLoadingWorkspaces: true, ...resetWorkspaceScopedState() }); try { - const machineId = selectedMachineId(this.getState()); const workspaces = await this.api.workspaces(project.id, machineId); + if (selectedMachineId(this.getState()) !== machineId || this.getState().selectedProject?.id !== project.id) return; this.setState({ workspaces, workspacesByProjectId: { ...this.getState().workspacesByProjectId, [project.id]: workspaces }, isLoadingWorkspaces: false }); const workspace = selectPreferredWorkspace(workspaces, { targetWorkspaceId: target?.workspaceId, latestWorkspaceId: this.workspaceSelection.latestWorkspaceId(machineProjectKey(machineId, project.id)) }); if (workspace) await this.selectWorkspace(workspace, { sessionId: target?.sessionId, updateUrl: target?.updateUrl }); else if (target?.updateUrl !== false) this.updateUrl(); } catch (error) { - this.setState({ error: String(error), isLoadingWorkspaces: false }); + if (selectedMachineId(this.getState()) === machineId && this.getState().selectedProject?.id === project.id) this.setState({ error: String(error), isLoadingWorkspaces: false }); } } @@ -58,15 +59,17 @@ export class WorkspaceController { this.setState({ selectedWorkspace: workspace, isLoadingWorkspaces: false, ...resetWorkspaceScopedState() }); try { const sessions = mergeCachedNewSessions(workspace.path, await this.api.sessions(workspace.path, machineId), machineId); + if (selectedMachineId(this.getState()) !== machineId || this.getState().selectedWorkspace?.id !== workspace.id || this.getState().selectedProject?.id !== workspace.projectId) return; this.setState({ sessions }); const session = this.sessions.preferredSession(workspace.path, sessions, target?.sessionId); if (session) await this.sessions.selectSession(session, { updateUrl: target?.updateUrl }); else if (target?.updateUrl !== false) this.updateUrl(); } catch (error) { - this.setState({ error: String(error) }); + if (selectedMachineId(this.getState()) === machineId && this.getState().selectedWorkspace?.id === workspace.id) this.setState({ error: String(error) }); } } + async refreshProjectWorkspaces(projectId: string): Promise { const project = this.getState().projects.find((candidate) => candidate.id === projectId); if (project === undefined) throw new Error("Project not found");