Archived
feat: remember machine navigation
This commit is contained in:
@@ -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.
|
||||
@@ -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<string>();
|
||||
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<string>();
|
||||
private readonly terminalCommandRunRuntimes = new Map<string, TerminalCommandRunsInternalRuntime>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export class MachineController {
|
||||
selectedMachine: machine,
|
||||
projects: [],
|
||||
workspaces: [],
|
||||
isLoadingWorkspaces: false,
|
||||
selectedProject: undefined,
|
||||
selectedWorkspace: undefined,
|
||||
selectedSession: undefined,
|
||||
|
||||
@@ -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: "" };
|
||||
}
|
||||
@@ -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<string, MachineNavigationSnapshot>();
|
||||
|
||||
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 },
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Workspace[]> {
|
||||
const project = this.getState().projects.find((candidate) => candidate.id === projectId);
|
||||
if (project === undefined) throw new Error("Project not found");
|
||||
|
||||
Reference in New Issue
Block a user