authorgravatar for mwolson@member.fsf.orgMike Olson <mwolson@member.fsf.org> 2026-04-20 02:29:56-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-19 23:29:56-07:00
logde05b0c94e257e69afc346ae6daaea85fcfb4e65
treeb531b5a6323f84e54e4764f4dac9f9beb7de91f5
parent6d1505c9fb6ef9a7e147133bb9827f2f36e6945c
signature Signed by PGP key B5690EEEBB952194

fix(web): restore manual sort drag and keep per-group expand state (#2221)


8 files changed, 398 insertions(+), 44 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+36
......@@ -309,6 +309,42 @@ describe("orderItemsByPreferredIds", () => {
309309 ProjectId.make("project-1"),
310310 ]);
311311 });
312
313 it("honors projectOrder physical keys via getProjectOrderKey", async () => {
314 // Regression guard for #1904 / the regression introduced by #2055:
315 // `projectOrder` is populated with physical keys (envId + cwd-derived)
316 // by the store and by drag-end handlers. Readers must identify projects
317 // with the same key format, or manual sort silently snaps back.
318 const { getProjectOrderKey } = await import("../logicalProject");
319 const projects = [
320 {
321 environmentId: EnvironmentId.make("environment-local"),
322 id: ProjectId.make("id-alpha"),
323 cwd: "/work/alpha",
324 },
325 {
326 environmentId: EnvironmentId.make("environment-local"),
327 id: ProjectId.make("id-beta"),
328 cwd: "/work/beta",
329 },
330 {
331 environmentId: EnvironmentId.make("environment-local"),
332 id: ProjectId.make("id-gamma"),
333 cwd: "/work/gamma",
334 },
335 ];
336 const ordered = orderItemsByPreferredIds({
337 items: projects,
338 preferredIds: [getProjectOrderKey(projects[2]!), getProjectOrderKey(projects[0]!)],
339 getId: getProjectOrderKey,
340 });
341
342 expect(ordered.map((project) => project.cwd)).toEqual([
343 "/work/gamma",
344 "/work/alpha",
345 "/work/beta",
346 ]);
347 });
312348});
313349
314350describe("resolveAdjacentThreadId", () => {
apps/web/src/components/Sidebar.tsx+6-2
......@@ -168,7 +168,11 @@ import { CommandDialogTrigger } from "./ui/command";
168168import { readEnvironmentApi } from "../environmentApi";
169169import { useSettings, useUpdateSettings } from "~/hooks/useSettings";
170170import { useServerKeybindings } from "../rpc/serverState";
171import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey } from "../logicalProject";
171import {
172 derivePhysicalProjectKey,
173 deriveProjectGroupingOverrideKey,
174 getProjectOrderKey,
175} from "../logicalProject";
172176import {
173177 useSavedEnvironmentRegistryStore,
174178 useSavedEnvironmentRuntimeStore,
......@@ -2708,7 +2712,7 @@ export default function Sidebar() {
27082712 return orderItemsByPreferredIds({
27092713 items: projects,
27102714 preferredIds: projectOrder,
2711 getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
2715 getId: getProjectOrderKey,
27122716 });
27132717 }, [projectOrder, projects]);
27142718
apps/web/src/environments/runtime/service.ts+9-1
......@@ -61,7 +61,11 @@ import { useTerminalStateStore } from "~/terminalStateStore";
6161import { useUiStateStore } from "~/uiStateStore";
6262import { WsTransport } from "../../rpc/wsTransport";
6363import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient";
64import { derivePhysicalProjectKey } from "../../logicalProject";
64import {
65 deriveLogicalProjectKeyFromSettings,
66 derivePhysicalProjectKey,
67} from "../../logicalProject";
68import { getClientSettings } from "~/hooks/useSettings";
6569
6670type EnvironmentServiceState = {
6771 readonly queryClient: QueryClient;
......@@ -468,9 +472,11 @@ function coalesceOrchestrationUiEvents(
468472
469473function syncProjectUiFromStore() {
470474 const projects = selectProjectsAcrossEnvironments(useStore.getState());
475 const clientSettings = getClientSettings();
471476 useUiStateStore.getState().syncProjects(
472477 projects.map((project) => ({
473478 key: derivePhysicalProjectKey(project),
479 logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings),
474480 cwd: project.cwd,
475481 })),
476482 );
......@@ -541,9 +547,11 @@ function applyRecoveredEventBatch(
541547 useStore.getState().applyOrchestrationEvents(uiEvents, environmentId);
542548 if (needsProjectUiSync) {
543549 const projects = selectProjectsAcrossEnvironments(useStore.getState());
550 const clientSettings = getClientSettings();
544551 useUiStateStore.getState().syncProjects(
545552 projects.map((project) => ({
546553 key: derivePhysicalProjectKey(project),
554 logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings),
547555 cwd: project.cwd,
548556 })),
549557 );
apps/web/src/hooks/useHandleNewThread.ts+2-2
......@@ -10,7 +10,7 @@ import {
1010} from "../composerDraftStore";
1111import { newDraftId, newThreadId } from "../lib/utils";
1212import { orderItemsByPreferredIds } from "../components/Sidebar.logic";
13import { deriveLogicalProjectKeyFromSettings } from "../logicalProject";
13import { deriveLogicalProjectKeyFromSettings, getProjectOrderKey } from "../logicalProject";
1414import { selectProjectsAcrossEnvironments, useStore } from "../store";
1515import { createThreadSelectorByRef } from "../storeSelectors";
1616import { resolveThreadRouteTarget } from "../threadRoutes";
......@@ -169,7 +169,7 @@ export function useHandleNewThread() {
169169 return orderItemsByPreferredIds({
170170 items: projects,
171171 preferredIds: projectOrder,
172 getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
172 getId: getProjectOrderKey,
173173 });
174174 }, [projectOrder, projects]);
175175 const handleNewThread = useNewThreadState();
apps/web/src/hooks/useSettings.ts+9
......@@ -123,6 +123,15 @@ function splitPatch(patch: Partial<UnifiedSettings>): {
123123 * only re-render when the slice they care about changes.
124124 */
125125
126/**
127 * Non-hook accessor for the current merged client settings snapshot.
128 * Used by non-React code paths (e.g. runtime services) that need the latest
129 * settings without subscribing.
130 */
131export function getClientSettings(): ClientSettings {
132 return getClientSettingsSnapshot();
133}
134
126135export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T {
127136 const serverSettings = useServerSettings();
128137 const clientSettings = useSyncExternalStore(
apps/web/src/logicalProject.ts+7
......@@ -65,6 +65,13 @@ export function deriveProjectGroupingOverrideKey(
6565 return derivePhysicalProjectKey(project);
6666}
6767
68// Key under which a project's manual sort order (projectOrder) is stored.
69// Must stay aligned with the writer side in `uiStateStore.syncProjects` and
70// the drag handlers in `Sidebar` so readers and writers agree.
71export function getProjectOrderKey(project: Pick<Project, "environmentId" | "cwd">): string {
72 return derivePhysicalProjectKey(project);
73}
74
6875export function resolveProjectGroupingMode(
6976 project: Pick<Project, "environmentId" | "cwd">,
7077 settings: ProjectGroupingSettings,
apps/web/src/uiStateStore.test.ts+233-19
......@@ -1,9 +1,13 @@
11import { ProjectId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33
44import {
55 clearThreadUi,
6 hydratePersistedProjectState,
67 markThreadUnread,
8 PERSISTED_STATE_KEY,
9 type PersistedUiState,
10 persistState,
711 reorderProjects,
812 setProjectExpanded,
913 setThreadChangedFilesExpanded,
......@@ -183,40 +187,43 @@ describe("uiStateStore pure functions", () => {
183187 });
184188
185189 const next = syncProjects(initialState, [
186 { key: project1, cwd: "/tmp/project-1" },
187 { key: project2, cwd: "/tmp/project-2" },
188 { key: project3, cwd: "/tmp/project-3" },
190 { key: project1, logicalKey: project1, cwd: "/tmp/project-1" },
191 { key: project2, logicalKey: project2, cwd: "/tmp/project-2" },
192 { key: project3, logicalKey: project3, cwd: "/tmp/project-3" },
189193 ]);
190194
191195 expect(next.projectOrder).toEqual([project2, project1, project3]);
192196 expect(next.projectExpandedById[project2]).toBe(false);
193197 });
194198
195 it("syncProjects preserves manual order when a project is recreated with the same cwd", () => {
196 const oldProject1 = ProjectId.make("project-1");
197 const oldProject2 = ProjectId.make("project-2");
198 const recreatedProject2 = ProjectId.make("project-2b");
199 it("syncProjects preserves manual order across project id churn at the same cwd", () => {
200 // Under the current design, physical key and logical key are both
201 // cwd-derived, so an internal project-id change doesn't alter the store
202 // keys. This test locks in that stability: re-syncing the same cwds keeps
203 // manual order and collapse state.
204 const keyProject1 = "env-local:/tmp/project-1";
205 const keyProject2 = "env-local:/tmp/project-2";
199206 const initialState = syncProjects(
200207 makeUiState({
201208 projectExpandedById: {
202 [oldProject1]: true,
203 [oldProject2]: false,
209 [keyProject1]: true,
210 [keyProject2]: false,
204211 },
205 projectOrder: [oldProject2, oldProject1],
212 projectOrder: [keyProject2, keyProject1],
206213 }),
207214 [
208 { key: oldProject1, cwd: "/tmp/project-1" },
209 { key: oldProject2, cwd: "/tmp/project-2" },
215 { key: keyProject1, logicalKey: keyProject1, cwd: "/tmp/project-1" },
216 { key: keyProject2, logicalKey: keyProject2, cwd: "/tmp/project-2" },
210217 ],
211218 );
212219
213220 const next = syncProjects(initialState, [
214 { key: oldProject1, cwd: "/tmp/project-1" },
215 { key: recreatedProject2, cwd: "/tmp/project-2" },
221 { key: keyProject1, logicalKey: keyProject1, cwd: "/tmp/project-1" },
222 { key: keyProject2, logicalKey: keyProject2, cwd: "/tmp/project-2" },
216223 ]);
217224
218 expect(next.projectOrder).toEqual([recreatedProject2, oldProject1]);
219 expect(next.projectExpandedById[recreatedProject2]).toBe(false);
225 expect(next.projectOrder).toEqual([keyProject2, keyProject1]);
226 expect(next.projectExpandedById[keyProject2]).toBe(false);
220227 });
221228
222229 it("syncProjects returns a new state when only project cwd changes", () => {
......@@ -228,16 +235,70 @@ describe("uiStateStore pure functions", () => {
228235 },
229236 projectOrder: [project1],
230237 }),
231 [{ key: project1, cwd: "/tmp/project-1" }],
238 [{ key: project1, logicalKey: project1, cwd: "/tmp/project-1" }],
232239 );
233240
234 const next = syncProjects(initialState, [{ key: project1, cwd: "/tmp/project-1-renamed" }]);
241 const next = syncProjects(initialState, [
242 { key: project1, logicalKey: project1, cwd: "/tmp/project-1-renamed" },
243 ]);
235244
236245 expect(next).not.toBe(initialState);
237246 expect(next.projectOrder).toEqual([project1]);
238247 expect(next.projectExpandedById[project1]).toBe(false);
239248 });
240249
250 it("syncProjects keys projectExpandedById by the logical key, not the physical key", () => {
251 // In repository grouping mode, multiple physical projects (different
252 // environments or different repo-relative paths) collapse into one
253 // logical group. The group's expand state must be keyed by the logical
254 // key so clicks on the grouped row toggle the shared state, and so the
255 // state survives subsequent syncProjects calls (which rebuild the map
256 // from incoming inputs).
257 const physicalLocal = "env-local:/repo/project";
258 const physicalRemote = "env-remote:/repo/project";
259 const logicalKey = "repo-canonical-key";
260
261 const initial = syncProjects(makeUiState(), [
262 { key: physicalLocal, logicalKey, cwd: "/repo/project" },
263 { key: physicalRemote, logicalKey, cwd: "/repo/project" },
264 ]);
265
266 expect(initial.projectExpandedById).toEqual({ [logicalKey]: true });
267
268 const afterCollapse = { ...initial, projectExpandedById: { [logicalKey]: false } };
269 const next = syncProjects(afterCollapse, [
270 { key: physicalLocal, logicalKey, cwd: "/repo/project" },
271 { key: physicalRemote, logicalKey, cwd: "/repo/project" },
272 ]);
273
274 expect(next.projectExpandedById[logicalKey]).toBe(false);
275 });
276
277 it("syncProjects preserves expand state when a project's logical key changes", () => {
278 // Example: late-arriving repo metadata flips grouping identity from the
279 // physical key to a canonical repository key. The row did not actually
280 // change, so the user's collapse choice must carry over.
281 const physicalKey = "env-local:/repo/project";
282 const previousLogicalKey = physicalKey;
283 const nextLogicalKey = "repo-canonical-key";
284
285 const initial = syncProjects(makeUiState(), [
286 { key: physicalKey, logicalKey: previousLogicalKey, cwd: "/repo/project" },
287 ]);
288
289 expect(initial.projectExpandedById[previousLogicalKey]).toBe(true);
290
291 const afterCollapse = {
292 ...initial,
293 projectExpandedById: { [previousLogicalKey]: false },
294 };
295 const next = syncProjects(afterCollapse, [
296 { key: physicalKey, logicalKey: nextLogicalKey, cwd: "/repo/project" },
297 ]);
298
299 expect(next.projectExpandedById[nextLogicalKey]).toBe(false);
300 });
301
241302 it("syncThreads prunes missing thread UI state", () => {
242303 const thread1 = ThreadId.make("thread-1");
243304 const thread2 = ThreadId.make("thread-2");
......@@ -346,3 +407,156 @@ describe("uiStateStore pure functions", () => {
346407 expect(next.threadChangedFilesExpandedById).toEqual({});
347408 });
348409});
410
411describe("uiStateStore persistence round-trip", () => {
412 function createLocalStorageStub(): Storage {
413 const store = new Map<string, string>();
414 return {
415 clear: () => {
416 store.clear();
417 },
418 getItem: (key) => store.get(key) ?? null,
419 key: (index) => [...store.keys()][index] ?? null,
420 get length() {
421 return store.size;
422 },
423 removeItem: (key) => {
424 store.delete(key);
425 },
426 setItem: (key, value) => {
427 store.set(key, value);
428 },
429 };
430 }
431
432 let localStorageStub: Storage;
433
434 beforeEach(() => {
435 localStorageStub = createLocalStorageStub();
436 vi.stubGlobal("window", { localStorage: localStorageStub });
437 vi.stubGlobal("localStorage", localStorageStub);
438 // Reset module-level persistence state so tests don't bleed into each other.
439 hydratePersistedProjectState({ collapsedProjectCwds: [], expandedProjectCwds: [] });
440 });
441
442 afterEach(() => {
443 vi.unstubAllGlobals();
444 });
445
446 it("preserves all-collapsed project state across restart", () => {
447 // Regression: pre-fix, persistState only wrote `expandedProjectCwds`, so
448 // an empty array on rehydrate was indistinguishable from a fresh install
449 // and the syncProjects fallback re-expanded every row.
450 const projectA = { key: "kA", logicalKey: "kA", cwd: "/projA" };
451 const projectB = { key: "kB", logicalKey: "kB", cwd: "/projB" };
452
453 let state = syncProjects(makeUiState(), [projectA, projectB]);
454 state = setProjectExpanded(state, projectA.key, false);
455 state = setProjectExpanded(state, projectB.key, false);
456 persistState(state);
457
458 const persisted = JSON.parse(
459 localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
460 ) as PersistedUiState;
461 hydratePersistedProjectState(persisted);
462 const rehydrated = syncProjects(makeUiState(), [projectA, projectB]);
463
464 expect(rehydrated.projectExpandedById).toEqual({
465 [projectA.key]: false,
466 [projectB.key]: false,
467 });
468 });
469
470 it("respects mixed expand state on rehydrate and defaults new projects to expanded", () => {
471 const projectA = { key: "kA", logicalKey: "kA", cwd: "/projA" };
472 const projectB = { key: "kB", logicalKey: "kB", cwd: "/projB" };
473 const projectC = { key: "kC", logicalKey: "kC", cwd: "/projC" };
474
475 let state = syncProjects(makeUiState(), [projectA, projectB]);
476 state = setProjectExpanded(state, projectB.key, false);
477 persistState(state);
478
479 const persisted = JSON.parse(
480 localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
481 ) as PersistedUiState;
482 hydratePersistedProjectState(persisted);
483 const rehydrated = syncProjects(makeUiState(), [projectA, projectB, projectC]);
484
485 expect(rehydrated.projectExpandedById).toEqual({
486 [projectA.key]: true,
487 [projectB.key]: false,
488 [projectC.key]: true,
489 });
490 });
491
492 it("preserves legacy not-in-expanded-list = collapsed for one upgrade session", () => {
493 // Pre-fix shape only stored expandedProjectCwds. Absence of
494 // collapsedProjectCwds opts the session into the legacy fallback so
495 // upgrade users do not see previously collapsed rows pop open.
496 hydratePersistedProjectState({
497 expandedProjectCwds: ["/projA"],
498 });
499
500 const rehydrated = syncProjects(makeUiState(), [
501 { key: "kA", logicalKey: "kA", cwd: "/projA" },
502 { key: "kB", logicalKey: "kB", cwd: "/projB" },
503 ]);
504
505 expect(rehydrated.projectExpandedById).toEqual({
506 kA: true,
507 kB: false,
508 });
509 });
510
511 it("preserves manual project order across restart", () => {
512 const projectA = { key: "kOrderA", logicalKey: "kOrderA", cwd: "/order-projA" };
513 const projectB = { key: "kOrderB", logicalKey: "kOrderB", cwd: "/order-projB" };
514 const projectC = { key: "kOrderC", logicalKey: "kOrderC", cwd: "/order-projC" };
515
516 let state = syncProjects(makeUiState(), [projectA, projectB, projectC]);
517 state = reorderProjects(state, [projectC.key], [projectA.key]);
518 expect(state.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]);
519 persistState(state);
520
521 const persisted = JSON.parse(
522 localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
523 ) as PersistedUiState;
524 expect(persisted.projectOrderCwds).toEqual([projectC.cwd, projectA.cwd, projectB.cwd]);
525
526 hydratePersistedProjectState(persisted);
527 // Fresh state (empty projectOrder) so syncProjects derives order from
528 // persistedProjectOrderCwds rather than the in-memory projectOrder branch.
529 const rehydrated = syncProjects(makeUiState(), [projectA, projectB, projectC]);
530
531 expect(rehydrated.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]);
532 });
533
534 it("preserves expand state across restart when project's logical key changes", () => {
535 // After restart, in-memory previousExpandedById is empty, so the
536 // previousLogicalKey-to-state bridge in syncProjects cannot help. The
537 // persisted-cwd fallback is the only mechanism that can carry collapse
538 // state across a restart that also flips a project into a new logical
539 // group (e.g. late-arriving repo metadata). This locks in that path.
540 const physicalKey = "env-local:/lk-restart-proj";
541 const previousLogicalKey = physicalKey;
542 const cwd = "/lk-restart-proj";
543
544 let state = syncProjects(makeUiState(), [
545 { key: physicalKey, logicalKey: previousLogicalKey, cwd },
546 ]);
547 state = setProjectExpanded(state, previousLogicalKey, false);
548 persistState(state);
549
550 const persisted = JSON.parse(
551 localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
552 ) as PersistedUiState;
553 hydratePersistedProjectState(persisted);
554
555 const nextLogicalKey = "lk-restart-canonical";
556 const rehydrated = syncProjects(makeUiState(), [
557 { key: physicalKey, logicalKey: nextLogicalKey, cwd },
558 ]);
559
560 expect(rehydrated.projectExpandedById[nextLogicalKey]).toBe(false);
561 });
562});
apps/web/src/uiStateStore.ts+96-20
......@@ -1,7 +1,7 @@
11import { Debouncer } from "@tanstack/react-pacer";
22import { create } from "zustand";
33
4const PERSISTED_STATE_KEY = "t3code:ui-state:v1";
4export const PERSISTED_STATE_KEY = "t3code:ui-state:v1";
55const LEGACY_PERSISTED_STATE_KEYS = [
66 "t3code:renderer-state:v8",
77 "t3code:renderer-state:v7",
......@@ -15,7 +15,8 @@ const LEGACY_PERSISTED_STATE_KEYS = [
1515 "codething:renderer-state:v1",
1616] as const;
1717
18interface PersistedUiState {
18export interface PersistedUiState {
19 collapsedProjectCwds?: string[];
1920 expandedProjectCwds?: string[];
2021 projectOrderCwds?: string[];
2122 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;
......@@ -34,7 +35,10 @@ export interface UiThreadState {
3435export interface UiState extends UiProjectState, UiThreadState {}
3536
3637export interface SyncProjectInput {
38 /** Physical project key (env + cwd). Used for manual sort order. */
3739 key: string;
40 /** Logical group key. Used for expand/collapse state. */
41 logicalKey: string;
3842 cwd: string;
3943}
4044
......@@ -50,9 +54,17 @@ const initialState: UiState = {
5054 threadChangedFilesExpandedById: {},
5155};
5256
57const persistedCollapsedProjectCwds = new Set<string>();
5358const persistedExpandedProjectCwds = new Set<string>();
5459const persistedProjectOrderCwds: string[] = [];
60// Pre-fix persisted shape only listed expanded cwds, so anything not listed
61// was treated as collapsed. Track whether the loaded blob carried the new
62// `collapsedProjectCwds` field so we can preserve that legacy semantic for
63// one session after upgrade, until persistState rewrites in the new shape.
64let persistedProjectStateUsesLegacyShape = false;
5565const currentProjectCwdById = new Map<string, string>();
66const currentProjectCwdsByLogicalKey = new Map<string, string[]>();
67const currentLogicalKeyByPhysicalKey = new Map<string, string>();
5668let legacyKeysCleanedUp = false;
5769
5870function readPersistedState(): UiState {
......@@ -113,9 +125,16 @@ function sanitizePersistedThreadChangedFilesExpanded(
113125 return nextState;
114126}
115127
116function hydratePersistedProjectState(parsed: PersistedUiState): void {
128export function hydratePersistedProjectState(parsed: PersistedUiState): void {
129 persistedCollapsedProjectCwds.clear();
117130 persistedExpandedProjectCwds.clear();
118131 persistedProjectOrderCwds.length = 0;
132 persistedProjectStateUsesLegacyShape = !Array.isArray(parsed.collapsedProjectCwds);
133 for (const cwd of parsed.collapsedProjectCwds ?? []) {
134 if (typeof cwd === "string" && cwd.length > 0) {
135 persistedCollapsedProjectCwds.add(cwd);
136 }
137 }
119138 for (const cwd of parsed.expandedProjectCwds ?? []) {
120139 if (typeof cwd === "string" && cwd.length > 0) {
121140 persistedExpandedProjectCwds.add(cwd);
......@@ -128,17 +147,20 @@ function hydratePersistedProjectState(parsed: PersistedUiState): void {
128147 }
129148}
130149
131function persistState(state: UiState): void {
150export function persistState(state: UiState): void {
132151 if (typeof window === "undefined") {
133152 return;
134153 }
135154 try {
155 // Persist collapsed cwds explicitly so an empty/missing field unambiguously
156 // means "first install" rather than "user collapsed everything"; without
157 // this, the syncProjects fallback would re-expand all rows on next launch.
158 const collapsedProjectCwds = Object.entries(state.projectExpandedById)
159 .filter(([, expanded]) => !expanded)
160 .flatMap(([logicalKey]) => currentProjectCwdsByLogicalKey.get(logicalKey) ?? []);
136161 const expandedProjectCwds = Object.entries(state.projectExpandedById)
137162 .filter(([, expanded]) => expanded)
138 .flatMap(([projectId]) => {
139 const cwd = currentProjectCwdById.get(projectId);
140 return cwd ? [cwd] : [];
141 });
163 .flatMap(([logicalKey]) => currentProjectCwdsByLogicalKey.get(logicalKey) ?? []);
142164 const projectOrderCwds = state.projectOrder.flatMap((projectId) => {
143165 const cwd = currentProjectCwdById.get(projectId);
144166 return cwd ? [cwd] : [];
......@@ -154,6 +176,7 @@ function persistState(state: UiState): void {
154176 window.localStorage.setItem(
155177 PERSISTED_STATE_KEY,
156178 JSON.stringify({
179 collapsedProjectCwds,
157180 expandedProjectCwds,
158181 projectOrderCwds,
159182 threadChangedFilesExpandedById,
......@@ -211,12 +234,40 @@ function nestedBooleanRecordsEqual(
211234
212235export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState {
213236 const previousProjectCwdById = new Map(currentProjectCwdById);
214 const previousProjectIdByCwd = new Map(
215 [...previousProjectCwdById.entries()].map(([projectId, cwd]) => [cwd, projectId] as const),
216 );
237 const previousLogicalKeyByPhysicalKey = new Map(currentLogicalKeyByPhysicalKey);
217238 currentProjectCwdById.clear();
239 currentLogicalKeyByPhysicalKey.clear();
218240 for (const project of projects) {
219241 currentProjectCwdById.set(project.key, project.cwd);
242 currentLogicalKeyByPhysicalKey.set(project.key, project.logicalKey);
243 }
244 currentProjectCwdsByLogicalKey.clear();
245 for (const project of projects) {
246 const cwds = currentProjectCwdsByLogicalKey.get(project.logicalKey);
247 if (cwds) {
248 if (!cwds.includes(project.cwd)) {
249 cwds.push(project.cwd);
250 }
251 } else {
252 currentProjectCwdsByLogicalKey.set(project.logicalKey, [project.cwd]);
253 }
254 }
255 // Build reverse map: for each new logical key, which previous logical keys
256 // did its member projects live under? Lets us preserve expand state when a
257 // project's logical key changes (e.g. late-arriving repo metadata flips the
258 // group identity).
259 const previousLogicalKeysByNewLogicalKey = new Map<string, Set<string>>();
260 for (const project of projects) {
261 const previousLogicalKey = previousLogicalKeyByPhysicalKey.get(project.key);
262 if (!previousLogicalKey || previousLogicalKey === project.logicalKey) {
263 continue;
264 }
265 const set = previousLogicalKeysByNewLogicalKey.get(project.logicalKey);
266 if (set) {
267 set.add(previousLogicalKey);
268 } else {
269 previousLogicalKeysByNewLogicalKey.set(project.logicalKey, new Set([previousLogicalKey]));
270 }
220271 }
221272 const cwdMappingChanged =
222273 previousProjectCwdById.size !== currentProjectCwdById.size ||
......@@ -228,14 +279,38 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput
228279 persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const),
229280 );
230281 const mappedProjects = projects.map((project, index) => {
231 const previousProjectIdForCwd = previousProjectIdByCwd.get(project.cwd);
232 const expanded =
233 previousExpandedById[project.key] ??
234 (previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ??
235 (persistedExpandedProjectCwds.size > 0
236 ? persistedExpandedProjectCwds.has(project.cwd)
237 : true);
238 nextExpandedById[project.key] = expanded;
282 if (!(project.logicalKey in nextExpandedById)) {
283 const groupCwds = currentProjectCwdsByLogicalKey.get(project.logicalKey) ?? [project.cwd];
284 const fallbackFromPreviousLogicalKey = (() => {
285 const previousKeys = previousLogicalKeysByNewLogicalKey.get(project.logicalKey);
286 if (!previousKeys) {
287 return undefined;
288 }
289 for (const previousKey of previousKeys) {
290 if (previousKey in previousExpandedById) {
291 return previousExpandedById[previousKey];
292 }
293 }
294 return undefined;
295 })();
296 const fallbackFromPersistedShape = (() => {
297 if (groupCwds.some((cwd) => persistedExpandedProjectCwds.has(cwd))) {
298 return true;
299 }
300 if (groupCwds.some((cwd) => persistedCollapsedProjectCwds.has(cwd))) {
301 return false;
302 }
303 if (persistedProjectStateUsesLegacyShape && persistedExpandedProjectCwds.size > 0) {
304 return false;
305 }
306 return true;
307 })();
308 const expanded =
309 previousExpandedById[project.logicalKey] ??
310 fallbackFromPreviousLogicalKey ??
311 fallbackFromPersistedShape;
312 nextExpandedById[project.logicalKey] = expanded;
313 }
239314 return {
240315 id: project.key,
241316 cwd: project.cwd,
......@@ -246,6 +321,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput
246321 const nextProjectOrder =
247322 state.projectOrder.length > 0
248323 ? (() => {
324 const currentProjectIds = new Set(mappedProjects.map((project) => project.id));
249325 const nextProjectIdByCwd = new Map(
250326 mappedProjects.map((project) => [project.cwd, project.id] as const),
251327 );
......@@ -254,7 +330,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput
254330
255331 for (const projectId of state.projectOrder) {
256332 const matchedProjectId =
257 (projectId in nextExpandedById ? projectId : undefined) ??
333 (currentProjectIds.has(projectId) ? projectId : undefined) ??
258334 (() => {
259335 const previousCwd = previousProjectCwdById.get(projectId);
260336 return previousCwd ? nextProjectIdByCwd.get(previousCwd) : undefined;