diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 6aa042a169..f92f2f628c 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -309,6 +309,42 @@ describe("orderItemsByPreferredIds", () => { ProjectId.make("project-1"), ]); }); + + it("honors projectOrder physical keys via getProjectOrderKey", async () => { + // Regression guard for #1904 / the regression introduced by #2055: + // `projectOrder` is populated with physical keys (envId + cwd-derived) + // by the store and by drag-end handlers. Readers must identify projects + // with the same key format, or manual sort silently snaps back. + const { getProjectOrderKey } = await import("../logicalProject"); + const projects = [ + { + environmentId: EnvironmentId.make("environment-local"), + id: ProjectId.make("id-alpha"), + cwd: "/work/alpha", + }, + { + environmentId: EnvironmentId.make("environment-local"), + id: ProjectId.make("id-beta"), + cwd: "/work/beta", + }, + { + environmentId: EnvironmentId.make("environment-local"), + id: ProjectId.make("id-gamma"), + cwd: "/work/gamma", + }, + ]; + const ordered = orderItemsByPreferredIds({ + items: projects, + preferredIds: [getProjectOrderKey(projects[2]!), getProjectOrderKey(projects[0]!)], + getId: getProjectOrderKey, + }); + + expect(ordered.map((project) => project.cwd)).toEqual([ + "/work/gamma", + "/work/alpha", + "/work/beta", + ]); + }); }); describe("resolveAdjacentThreadId", () => { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8335042c11..d25f930c0d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -168,7 +168,11 @@ import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey } from "../logicalProject"; +import { + derivePhysicalProjectKey, + deriveProjectGroupingOverrideKey, + getProjectOrderKey, +} from "../logicalProject"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, @@ -2708,7 +2712,7 @@ export default function Sidebar() { return orderItemsByPreferredIds({ items: projects, preferredIds: projectOrder, - getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + getId: getProjectOrderKey, }); }, [projectOrder, projects]); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index 40f8f8f2c0..775df0184e 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -61,7 +61,11 @@ import { useTerminalStateStore } from "~/terminalStateStore"; import { useUiStateStore } from "~/uiStateStore"; import { WsTransport } from "../../rpc/wsTransport"; import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient"; -import { derivePhysicalProjectKey } from "../../logicalProject"; +import { + deriveLogicalProjectKeyFromSettings, + derivePhysicalProjectKey, +} from "../../logicalProject"; +import { getClientSettings } from "~/hooks/useSettings"; type EnvironmentServiceState = { readonly queryClient: QueryClient; @@ -468,9 +472,11 @@ function coalesceOrchestrationUiEvents( function syncProjectUiFromStore() { const projects = selectProjectsAcrossEnvironments(useStore.getState()); + const clientSettings = getClientSettings(); useUiStateStore.getState().syncProjects( projects.map((project) => ({ key: derivePhysicalProjectKey(project), + logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings), cwd: project.cwd, })), ); @@ -541,9 +547,11 @@ function applyRecoveredEventBatch( useStore.getState().applyOrchestrationEvents(uiEvents, environmentId); if (needsProjectUiSync) { const projects = selectProjectsAcrossEnvironments(useStore.getState()); + const clientSettings = getClientSettings(); useUiStateStore.getState().syncProjects( projects.map((project) => ({ key: derivePhysicalProjectKey(project), + logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings), cwd: project.cwd, })), ); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index d512b6c7e7..3630171bf5 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -10,7 +10,7 @@ import { } from "../composerDraftStore"; import { newDraftId, newThreadId } from "../lib/utils"; import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; -import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; +import { deriveLogicalProjectKeyFromSettings, getProjectOrderKey } from "../logicalProject"; import { selectProjectsAcrossEnvironments, useStore } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteTarget } from "../threadRoutes"; @@ -169,7 +169,7 @@ export function useHandleNewThread() { return orderItemsByPreferredIds({ items: projects, preferredIds: projectOrder, - getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + getId: getProjectOrderKey, }); }, [projectOrder, projects]); const handleNewThread = useNewThreadState(); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 18b2668a60..37a6872bd9 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -123,6 +123,15 @@ function splitPatch(patch: Partial): { * only re-render when the slice they care about changes. */ +/** + * Non-hook accessor for the current merged client settings snapshot. + * Used by non-React code paths (e.g. runtime services) that need the latest + * settings without subscribing. + */ +export function getClientSettings(): ClientSettings { + return getClientSettingsSnapshot(); +} + export function useSettings(selector?: (s: UnifiedSettings) => T): T { const serverSettings = useServerSettings(); const clientSettings = useSyncExternalStore( diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index d30bb60ca0..f19b64ac80 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -65,6 +65,13 @@ export function deriveProjectGroupingOverrideKey( return derivePhysicalProjectKey(project); } +// Key under which a project's manual sort order (projectOrder) is stored. +// Must stay aligned with the writer side in `uiStateStore.syncProjects` and +// the drag handlers in `Sidebar` so readers and writers agree. +export function getProjectOrderKey(project: Pick): string { + return derivePhysicalProjectKey(project); +} + export function resolveProjectGroupingMode( project: Pick, settings: ProjectGroupingSettings, diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index c906bbc1d7..f63208d9ba 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -1,9 +1,13 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearThreadUi, + hydratePersistedProjectState, markThreadUnread, + PERSISTED_STATE_KEY, + type PersistedUiState, + persistState, reorderProjects, setProjectExpanded, setThreadChangedFilesExpanded, @@ -183,40 +187,43 @@ describe("uiStateStore pure functions", () => { }); const next = syncProjects(initialState, [ - { key: project1, cwd: "/tmp/project-1" }, - { key: project2, cwd: "/tmp/project-2" }, - { key: project3, cwd: "/tmp/project-3" }, + { key: project1, logicalKey: project1, cwd: "/tmp/project-1" }, + { key: project2, logicalKey: project2, cwd: "/tmp/project-2" }, + { key: project3, logicalKey: project3, cwd: "/tmp/project-3" }, ]); expect(next.projectOrder).toEqual([project2, project1, project3]); expect(next.projectExpandedById[project2]).toBe(false); }); - it("syncProjects preserves manual order when a project is recreated with the same cwd", () => { - const oldProject1 = ProjectId.make("project-1"); - const oldProject2 = ProjectId.make("project-2"); - const recreatedProject2 = ProjectId.make("project-2b"); + it("syncProjects preserves manual order across project id churn at the same cwd", () => { + // Under the current design, physical key and logical key are both + // cwd-derived, so an internal project-id change doesn't alter the store + // keys. This test locks in that stability: re-syncing the same cwds keeps + // manual order and collapse state. + const keyProject1 = "env-local:/tmp/project-1"; + const keyProject2 = "env-local:/tmp/project-2"; const initialState = syncProjects( makeUiState({ projectExpandedById: { - [oldProject1]: true, - [oldProject2]: false, + [keyProject1]: true, + [keyProject2]: false, }, - projectOrder: [oldProject2, oldProject1], + projectOrder: [keyProject2, keyProject1], }), [ - { key: oldProject1, cwd: "/tmp/project-1" }, - { key: oldProject2, cwd: "/tmp/project-2" }, + { key: keyProject1, logicalKey: keyProject1, cwd: "/tmp/project-1" }, + { key: keyProject2, logicalKey: keyProject2, cwd: "/tmp/project-2" }, ], ); const next = syncProjects(initialState, [ - { key: oldProject1, cwd: "/tmp/project-1" }, - { key: recreatedProject2, cwd: "/tmp/project-2" }, + { key: keyProject1, logicalKey: keyProject1, cwd: "/tmp/project-1" }, + { key: keyProject2, logicalKey: keyProject2, cwd: "/tmp/project-2" }, ]); - expect(next.projectOrder).toEqual([recreatedProject2, oldProject1]); - expect(next.projectExpandedById[recreatedProject2]).toBe(false); + expect(next.projectOrder).toEqual([keyProject2, keyProject1]); + expect(next.projectExpandedById[keyProject2]).toBe(false); }); it("syncProjects returns a new state when only project cwd changes", () => { @@ -228,16 +235,70 @@ describe("uiStateStore pure functions", () => { }, projectOrder: [project1], }), - [{ key: project1, cwd: "/tmp/project-1" }], + [{ key: project1, logicalKey: project1, cwd: "/tmp/project-1" }], ); - const next = syncProjects(initialState, [{ key: project1, cwd: "/tmp/project-1-renamed" }]); + const next = syncProjects(initialState, [ + { key: project1, logicalKey: project1, cwd: "/tmp/project-1-renamed" }, + ]); expect(next).not.toBe(initialState); expect(next.projectOrder).toEqual([project1]); expect(next.projectExpandedById[project1]).toBe(false); }); + it("syncProjects keys projectExpandedById by the logical key, not the physical key", () => { + // In repository grouping mode, multiple physical projects (different + // environments or different repo-relative paths) collapse into one + // logical group. The group's expand state must be keyed by the logical + // key so clicks on the grouped row toggle the shared state, and so the + // state survives subsequent syncProjects calls (which rebuild the map + // from incoming inputs). + const physicalLocal = "env-local:/repo/project"; + const physicalRemote = "env-remote:/repo/project"; + const logicalKey = "repo-canonical-key"; + + const initial = syncProjects(makeUiState(), [ + { key: physicalLocal, logicalKey, cwd: "/repo/project" }, + { key: physicalRemote, logicalKey, cwd: "/repo/project" }, + ]); + + expect(initial.projectExpandedById).toEqual({ [logicalKey]: true }); + + const afterCollapse = { ...initial, projectExpandedById: { [logicalKey]: false } }; + const next = syncProjects(afterCollapse, [ + { key: physicalLocal, logicalKey, cwd: "/repo/project" }, + { key: physicalRemote, logicalKey, cwd: "/repo/project" }, + ]); + + expect(next.projectExpandedById[logicalKey]).toBe(false); + }); + + it("syncProjects preserves expand state when a project's logical key changes", () => { + // Example: late-arriving repo metadata flips grouping identity from the + // physical key to a canonical repository key. The row did not actually + // change, so the user's collapse choice must carry over. + const physicalKey = "env-local:/repo/project"; + const previousLogicalKey = physicalKey; + const nextLogicalKey = "repo-canonical-key"; + + const initial = syncProjects(makeUiState(), [ + { key: physicalKey, logicalKey: previousLogicalKey, cwd: "/repo/project" }, + ]); + + expect(initial.projectExpandedById[previousLogicalKey]).toBe(true); + + const afterCollapse = { + ...initial, + projectExpandedById: { [previousLogicalKey]: false }, + }; + const next = syncProjects(afterCollapse, [ + { key: physicalKey, logicalKey: nextLogicalKey, cwd: "/repo/project" }, + ]); + + expect(next.projectExpandedById[nextLogicalKey]).toBe(false); + }); + it("syncThreads prunes missing thread UI state", () => { const thread1 = ThreadId.make("thread-1"); const thread2 = ThreadId.make("thread-2"); @@ -346,3 +407,156 @@ describe("uiStateStore pure functions", () => { expect(next.threadChangedFilesExpandedById).toEqual({}); }); }); + +describe("uiStateStore persistence round-trip", () => { + function createLocalStorageStub(): Storage { + const store = new Map(); + return { + clear: () => { + store.clear(); + }, + getItem: (key) => store.get(key) ?? null, + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + removeItem: (key) => { + store.delete(key); + }, + setItem: (key, value) => { + store.set(key, value); + }, + }; + } + + let localStorageStub: Storage; + + beforeEach(() => { + localStorageStub = createLocalStorageStub(); + vi.stubGlobal("window", { localStorage: localStorageStub }); + vi.stubGlobal("localStorage", localStorageStub); + // Reset module-level persistence state so tests don't bleed into each other. + hydratePersistedProjectState({ collapsedProjectCwds: [], expandedProjectCwds: [] }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("preserves all-collapsed project state across restart", () => { + // Regression: pre-fix, persistState only wrote `expandedProjectCwds`, so + // an empty array on rehydrate was indistinguishable from a fresh install + // and the syncProjects fallback re-expanded every row. + const projectA = { key: "kA", logicalKey: "kA", cwd: "/projA" }; + const projectB = { key: "kB", logicalKey: "kB", cwd: "/projB" }; + + let state = syncProjects(makeUiState(), [projectA, projectB]); + state = setProjectExpanded(state, projectA.key, false); + state = setProjectExpanded(state, projectB.key, false); + persistState(state); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + hydratePersistedProjectState(persisted); + const rehydrated = syncProjects(makeUiState(), [projectA, projectB]); + + expect(rehydrated.projectExpandedById).toEqual({ + [projectA.key]: false, + [projectB.key]: false, + }); + }); + + it("respects mixed expand state on rehydrate and defaults new projects to expanded", () => { + const projectA = { key: "kA", logicalKey: "kA", cwd: "/projA" }; + const projectB = { key: "kB", logicalKey: "kB", cwd: "/projB" }; + const projectC = { key: "kC", logicalKey: "kC", cwd: "/projC" }; + + let state = syncProjects(makeUiState(), [projectA, projectB]); + state = setProjectExpanded(state, projectB.key, false); + persistState(state); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + hydratePersistedProjectState(persisted); + const rehydrated = syncProjects(makeUiState(), [projectA, projectB, projectC]); + + expect(rehydrated.projectExpandedById).toEqual({ + [projectA.key]: true, + [projectB.key]: false, + [projectC.key]: true, + }); + }); + + it("preserves legacy not-in-expanded-list = collapsed for one upgrade session", () => { + // Pre-fix shape only stored expandedProjectCwds. Absence of + // collapsedProjectCwds opts the session into the legacy fallback so + // upgrade users do not see previously collapsed rows pop open. + hydratePersistedProjectState({ + expandedProjectCwds: ["/projA"], + }); + + const rehydrated = syncProjects(makeUiState(), [ + { key: "kA", logicalKey: "kA", cwd: "/projA" }, + { key: "kB", logicalKey: "kB", cwd: "/projB" }, + ]); + + expect(rehydrated.projectExpandedById).toEqual({ + kA: true, + kB: false, + }); + }); + + it("preserves manual project order across restart", () => { + const projectA = { key: "kOrderA", logicalKey: "kOrderA", cwd: "/order-projA" }; + const projectB = { key: "kOrderB", logicalKey: "kOrderB", cwd: "/order-projB" }; + const projectC = { key: "kOrderC", logicalKey: "kOrderC", cwd: "/order-projC" }; + + let state = syncProjects(makeUiState(), [projectA, projectB, projectC]); + state = reorderProjects(state, [projectC.key], [projectA.key]); + expect(state.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]); + persistState(state); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + expect(persisted.projectOrderCwds).toEqual([projectC.cwd, projectA.cwd, projectB.cwd]); + + hydratePersistedProjectState(persisted); + // Fresh state (empty projectOrder) so syncProjects derives order from + // persistedProjectOrderCwds rather than the in-memory projectOrder branch. + const rehydrated = syncProjects(makeUiState(), [projectA, projectB, projectC]); + + expect(rehydrated.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]); + }); + + it("preserves expand state across restart when project's logical key changes", () => { + // After restart, in-memory previousExpandedById is empty, so the + // previousLogicalKey-to-state bridge in syncProjects cannot help. The + // persisted-cwd fallback is the only mechanism that can carry collapse + // state across a restart that also flips a project into a new logical + // group (e.g. late-arriving repo metadata). This locks in that path. + const physicalKey = "env-local:/lk-restart-proj"; + const previousLogicalKey = physicalKey; + const cwd = "/lk-restart-proj"; + + let state = syncProjects(makeUiState(), [ + { key: physicalKey, logicalKey: previousLogicalKey, cwd }, + ]); + state = setProjectExpanded(state, previousLogicalKey, false); + persistState(state); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + hydratePersistedProjectState(persisted); + + const nextLogicalKey = "lk-restart-canonical"; + const rehydrated = syncProjects(makeUiState(), [ + { key: physicalKey, logicalKey: nextLogicalKey, cwd }, + ]); + + expect(rehydrated.projectExpandedById[nextLogicalKey]).toBe(false); + }); +}); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 7ae7232063..8bd65ffc56 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,7 +1,7 @@ import { Debouncer } from "@tanstack/react-pacer"; import { create } from "zustand"; -const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; +export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; const LEGACY_PERSISTED_STATE_KEYS = [ "t3code:renderer-state:v8", "t3code:renderer-state:v7", @@ -15,7 +15,8 @@ const LEGACY_PERSISTED_STATE_KEYS = [ "codething:renderer-state:v1", ] as const; -interface PersistedUiState { +export interface PersistedUiState { + collapsedProjectCwds?: string[]; expandedProjectCwds?: string[]; projectOrderCwds?: string[]; threadChangedFilesExpandedById?: Record>; @@ -34,7 +35,10 @@ export interface UiThreadState { export interface UiState extends UiProjectState, UiThreadState {} export interface SyncProjectInput { + /** Physical project key (env + cwd). Used for manual sort order. */ key: string; + /** Logical group key. Used for expand/collapse state. */ + logicalKey: string; cwd: string; } @@ -50,9 +54,17 @@ const initialState: UiState = { threadChangedFilesExpandedById: {}, }; +const persistedCollapsedProjectCwds = new Set(); const persistedExpandedProjectCwds = new Set(); const persistedProjectOrderCwds: string[] = []; +// Pre-fix persisted shape only listed expanded cwds, so anything not listed +// was treated as collapsed. Track whether the loaded blob carried the new +// `collapsedProjectCwds` field so we can preserve that legacy semantic for +// one session after upgrade, until persistState rewrites in the new shape. +let persistedProjectStateUsesLegacyShape = false; const currentProjectCwdById = new Map(); +const currentProjectCwdsByLogicalKey = new Map(); +const currentLogicalKeyByPhysicalKey = new Map(); let legacyKeysCleanedUp = false; function readPersistedState(): UiState { @@ -113,9 +125,16 @@ function sanitizePersistedThreadChangedFilesExpanded( return nextState; } -function hydratePersistedProjectState(parsed: PersistedUiState): void { +export function hydratePersistedProjectState(parsed: PersistedUiState): void { + persistedCollapsedProjectCwds.clear(); persistedExpandedProjectCwds.clear(); persistedProjectOrderCwds.length = 0; + persistedProjectStateUsesLegacyShape = !Array.isArray(parsed.collapsedProjectCwds); + for (const cwd of parsed.collapsedProjectCwds ?? []) { + if (typeof cwd === "string" && cwd.length > 0) { + persistedCollapsedProjectCwds.add(cwd); + } + } for (const cwd of parsed.expandedProjectCwds ?? []) { if (typeof cwd === "string" && cwd.length > 0) { persistedExpandedProjectCwds.add(cwd); @@ -128,17 +147,20 @@ function hydratePersistedProjectState(parsed: PersistedUiState): void { } } -function persistState(state: UiState): void { +export function persistState(state: UiState): void { if (typeof window === "undefined") { return; } try { + // Persist collapsed cwds explicitly so an empty/missing field unambiguously + // means "first install" rather than "user collapsed everything"; without + // this, the syncProjects fallback would re-expand all rows on next launch. + const collapsedProjectCwds = Object.entries(state.projectExpandedById) + .filter(([, expanded]) => !expanded) + .flatMap(([logicalKey]) => currentProjectCwdsByLogicalKey.get(logicalKey) ?? []); const expandedProjectCwds = Object.entries(state.projectExpandedById) .filter(([, expanded]) => expanded) - .flatMap(([projectId]) => { - const cwd = currentProjectCwdById.get(projectId); - return cwd ? [cwd] : []; - }); + .flatMap(([logicalKey]) => currentProjectCwdsByLogicalKey.get(logicalKey) ?? []); const projectOrderCwds = state.projectOrder.flatMap((projectId) => { const cwd = currentProjectCwdById.get(projectId); return cwd ? [cwd] : []; @@ -154,6 +176,7 @@ function persistState(state: UiState): void { window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ + collapsedProjectCwds, expandedProjectCwds, projectOrderCwds, threadChangedFilesExpandedById, @@ -211,12 +234,40 @@ function nestedBooleanRecordsEqual( export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState { const previousProjectCwdById = new Map(currentProjectCwdById); - const previousProjectIdByCwd = new Map( - [...previousProjectCwdById.entries()].map(([projectId, cwd]) => [cwd, projectId] as const), - ); + const previousLogicalKeyByPhysicalKey = new Map(currentLogicalKeyByPhysicalKey); currentProjectCwdById.clear(); + currentLogicalKeyByPhysicalKey.clear(); for (const project of projects) { currentProjectCwdById.set(project.key, project.cwd); + currentLogicalKeyByPhysicalKey.set(project.key, project.logicalKey); + } + currentProjectCwdsByLogicalKey.clear(); + for (const project of projects) { + const cwds = currentProjectCwdsByLogicalKey.get(project.logicalKey); + if (cwds) { + if (!cwds.includes(project.cwd)) { + cwds.push(project.cwd); + } + } else { + currentProjectCwdsByLogicalKey.set(project.logicalKey, [project.cwd]); + } + } + // Build reverse map: for each new logical key, which previous logical keys + // did its member projects live under? Lets us preserve expand state when a + // project's logical key changes (e.g. late-arriving repo metadata flips the + // group identity). + const previousLogicalKeysByNewLogicalKey = new Map>(); + for (const project of projects) { + const previousLogicalKey = previousLogicalKeyByPhysicalKey.get(project.key); + if (!previousLogicalKey || previousLogicalKey === project.logicalKey) { + continue; + } + const set = previousLogicalKeysByNewLogicalKey.get(project.logicalKey); + if (set) { + set.add(previousLogicalKey); + } else { + previousLogicalKeysByNewLogicalKey.set(project.logicalKey, new Set([previousLogicalKey])); + } } const cwdMappingChanged = previousProjectCwdById.size !== currentProjectCwdById.size || @@ -228,14 +279,38 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const), ); const mappedProjects = projects.map((project, index) => { - const previousProjectIdForCwd = previousProjectIdByCwd.get(project.cwd); - const expanded = - previousExpandedById[project.key] ?? - (previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ?? - (persistedExpandedProjectCwds.size > 0 - ? persistedExpandedProjectCwds.has(project.cwd) - : true); - nextExpandedById[project.key] = expanded; + if (!(project.logicalKey in nextExpandedById)) { + const groupCwds = currentProjectCwdsByLogicalKey.get(project.logicalKey) ?? [project.cwd]; + const fallbackFromPreviousLogicalKey = (() => { + const previousKeys = previousLogicalKeysByNewLogicalKey.get(project.logicalKey); + if (!previousKeys) { + return undefined; + } + for (const previousKey of previousKeys) { + if (previousKey in previousExpandedById) { + return previousExpandedById[previousKey]; + } + } + return undefined; + })(); + const fallbackFromPersistedShape = (() => { + if (groupCwds.some((cwd) => persistedExpandedProjectCwds.has(cwd))) { + return true; + } + if (groupCwds.some((cwd) => persistedCollapsedProjectCwds.has(cwd))) { + return false; + } + if (persistedProjectStateUsesLegacyShape && persistedExpandedProjectCwds.size > 0) { + return false; + } + return true; + })(); + const expanded = + previousExpandedById[project.logicalKey] ?? + fallbackFromPreviousLogicalKey ?? + fallbackFromPersistedShape; + nextExpandedById[project.logicalKey] = expanded; + } return { id: project.key, cwd: project.cwd, @@ -246,6 +321,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput const nextProjectOrder = state.projectOrder.length > 0 ? (() => { + const currentProjectIds = new Set(mappedProjects.map((project) => project.id)); const nextProjectIdByCwd = new Map( mappedProjects.map((project) => [project.cwd, project.id] as const), ); @@ -254,7 +330,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput for (const projectId of state.projectOrder) { const matchedProjectId = - (projectId in nextExpandedById ? projectId : undefined) ?? + (currentProjectIds.has(projectId) ? projectId : undefined) ?? (() => { const previousCwd = previousProjectCwdById.get(projectId); return previousCwd ? nextProjectIdByCwd.get(previousCwd) : undefined;