Unified · Split
| @@ -309,6 +309,42 @@ describe("orderItemsByPreferredIds", () => { · 308 unmodified lines | ||
| 309 | 309 | ProjectId.make("project-1"), |
| 310 | 310 | ]); |
| 311 | 311 | }); |
| 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 | + }); | |
| 312 | 348 | }); |
| 313 | 349 | |
| 314 | 350 | describe("resolveAdjacentThreadId", () => { |
| @@ -168,7 +168,11 @@ import { CommandDialogTrigger } from "./ui/command"; · 167 unmodified lines | ||
| 168 | 168 | import { readEnvironmentApi } from "../environmentApi"; |
| 169 | 169 | import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; |
| 170 | 170 | import { useServerKeybindings } from "../rpc/serverState"; |
| 171 | −import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey } from "../logicalProject"; | |
| 171 | +import { | |
| 172 | + derivePhysicalProjectKey, | |
| 173 | + deriveProjectGroupingOverrideKey, | |
| 174 | + getProjectOrderKey, | |
| 175 | +} from "../logicalProject"; | |
| 172 | 176 | import { |
| 173 | 177 | useSavedEnvironmentRegistryStore, |
| 174 | 178 | useSavedEnvironmentRuntimeStore, |
| @@ -2708,7 +2712,7 @@ export default function Sidebar() { · 2533 unmodified lines | ||
| 2708 | 2712 | return orderItemsByPreferredIds({ |
| 2709 | 2713 | items: projects, |
| 2710 | 2714 | preferredIds: projectOrder, |
| 2711 | − getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), | |
| 2715 | + getId: getProjectOrderKey, | |
| 2712 | 2716 | }); |
| 2713 | 2717 | }, [projectOrder, projects]); |
| 2714 | 2718 | |
| @@ -61,7 +61,11 @@ import { useTerminalStateStore } from "~/terminalStateStore"; · 60 unmodified lines | ||
| 61 | 61 | import { useUiStateStore } from "~/uiStateStore"; |
| 62 | 62 | import { WsTransport } from "../../rpc/wsTransport"; |
| 63 | 63 | import { createWsRpcClient, type WsRpcClient } from "../../rpc/wsRpcClient"; |
| 64 | −import { derivePhysicalProjectKey } from "../../logicalProject"; | |
| 64 | +import { | |
| 65 | + deriveLogicalProjectKeyFromSettings, | |
| 66 | + derivePhysicalProjectKey, | |
| 67 | +} from "../../logicalProject"; | |
| 68 | +import { getClientSettings } from "~/hooks/useSettings"; | |
| 65 | 69 | |
| 66 | 70 | type EnvironmentServiceState = { |
| 67 | 71 | readonly queryClient: QueryClient; |
| @@ -468,9 +472,11 @@ function coalesceOrchestrationUiEvents( · 400 unmodified lines | ||
| 468 | 472 | |
| 469 | 473 | function syncProjectUiFromStore() { |
| 470 | 474 | const projects = selectProjectsAcrossEnvironments(useStore.getState()); |
| 475 | + const clientSettings = getClientSettings(); | |
| 471 | 476 | useUiStateStore.getState().syncProjects( |
| 472 | 477 | projects.map((project) => ({ |
| 473 | 478 | key: derivePhysicalProjectKey(project), |
| 479 | + logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings), | |
| 474 | 480 | cwd: project.cwd, |
| 475 | 481 | })), |
| 476 | 482 | ); |
| @@ -541,9 +547,11 @@ function applyRecoveredEventBatch( · 64 unmodified lines | ||
| 541 | 547 | useStore.getState().applyOrchestrationEvents(uiEvents, environmentId); |
| 542 | 548 | if (needsProjectUiSync) { |
| 543 | 549 | const projects = selectProjectsAcrossEnvironments(useStore.getState()); |
| 550 | + const clientSettings = getClientSettings(); | |
| 544 | 551 | useUiStateStore.getState().syncProjects( |
| 545 | 552 | projects.map((project) => ({ |
| 546 | 553 | key: derivePhysicalProjectKey(project), |
| 554 | + logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings), | |
| 547 | 555 | cwd: project.cwd, |
| 548 | 556 | })), |
| 549 | 557 | ); |
| @@ -10,7 +10,7 @@ import { · 9 unmodified lines | ||
| 10 | 10 | } from "../composerDraftStore"; |
| 11 | 11 | import { newDraftId, newThreadId } from "../lib/utils"; |
| 12 | 12 | import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; |
| 13 | −import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; | |
| 13 | +import { deriveLogicalProjectKeyFromSettings, getProjectOrderKey } from "../logicalProject"; | |
| 14 | 14 | import { selectProjectsAcrossEnvironments, useStore } from "../store"; |
| 15 | 15 | import { createThreadSelectorByRef } from "../storeSelectors"; |
| 16 | 16 | import { resolveThreadRouteTarget } from "../threadRoutes"; |
| @@ -169,7 +169,7 @@ export function useHandleNewThread() { · 152 unmodified lines | ||
| 169 | 169 | return orderItemsByPreferredIds({ |
| 170 | 170 | items: projects, |
| 171 | 171 | preferredIds: projectOrder, |
| 172 | − getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), | |
| 172 | + getId: getProjectOrderKey, | |
| 173 | 173 | }); |
| 174 | 174 | }, [projectOrder, projects]); |
| 175 | 175 | const handleNewThread = useNewThreadState(); |
| @@ -123,6 +123,15 @@ function splitPatch(patch: Partial<UnifiedSettings>): { · 122 unmodified lines | ||
| 123 | 123 | * only re-render when the slice they care about changes. |
| 124 | 124 | */ |
| 125 | 125 | |
| 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 | + */ | |
| 131 | +export function getClientSettings(): ClientSettings { | |
| 132 | + return getClientSettingsSnapshot(); | |
| 133 | +} | |
| 134 | + | |
| 126 | 135 | export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T { |
| 127 | 136 | const serverSettings = useServerSettings(); |
| 128 | 137 | const clientSettings = useSyncExternalStore( |
| @@ -65,6 +65,13 @@ export function deriveProjectGroupingOverrideKey( · 64 unmodified lines | ||
| 65 | 65 | return derivePhysicalProjectKey(project); |
| 66 | 66 | } |
| 67 | 67 | |
| 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. | |
| 71 | +export function getProjectOrderKey(project: Pick<Project, "environmentId" | "cwd">): string { | |
| 72 | + return derivePhysicalProjectKey(project); | |
| 73 | +} | |
| 74 | + | |
| 68 | 75 | export function resolveProjectGroupingMode( |
| 69 | 76 | project: Pick<Project, "environmentId" | "cwd">, |
| 70 | 77 | settings: ProjectGroupingSettings, |
| @@ -1,9 +1,13 @@ | ||
| 1 | 1 | import { ProjectId, ThreadId } from "@t3tools/contracts"; |
| 2 | −import { describe, expect, it } from "vitest"; | |
| 2 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | clearThreadUi, |
| 6 | + hydratePersistedProjectState, | |
| 6 | 7 | markThreadUnread, |
| 8 | + PERSISTED_STATE_KEY, | |
| 9 | + type PersistedUiState, | |
| 10 | + persistState, | |
| 7 | 11 | reorderProjects, |
| 8 | 12 | setProjectExpanded, |
| 9 | 13 | setThreadChangedFilesExpanded, |
| @@ -183,40 +187,43 @@ describe("uiStateStore pure functions", () => { · 173 unmodified lines | ||
| 183 | 187 | }); |
| 184 | 188 | |
| 185 | 189 | 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" }, | |
| 189 | 193 | ]); |
| 190 | 194 | |
| 191 | 195 | expect(next.projectOrder).toEqual([project2, project1, project3]); |
| 192 | 196 | expect(next.projectExpandedById[project2]).toBe(false); |
| 193 | 197 | }); |
| 194 | 198 | |
| 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"; | |
| 199 | 206 | const initialState = syncProjects( |
| 200 | 207 | makeUiState({ |
| 201 | 208 | projectExpandedById: { |
| 202 | − [oldProject1]: true, | |
| 203 | − [oldProject2]: false, | |
| 209 | + [keyProject1]: true, | |
| 210 | + [keyProject2]: false, | |
| 204 | 211 | }, |
| 205 | − projectOrder: [oldProject2, oldProject1], | |
| 212 | + projectOrder: [keyProject2, keyProject1], | |
| 206 | 213 | }), |
| 207 | 214 | [ |
| 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" }, | |
| 210 | 217 | ], |
| 211 | 218 | ); |
| 212 | 219 | |
| 213 | 220 | 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" }, | |
| 216 | 223 | ]); |
| 217 | 224 | |
| 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); | |
| 220 | 227 | }); |
| 221 | 228 | |
| 222 | 229 | it("syncProjects returns a new state when only project cwd changes", () => { |
| @@ -228,16 +235,70 @@ describe("uiStateStore pure functions", () => { · 5 unmodified lines | ||
| 228 | 235 | }, |
| 229 | 236 | projectOrder: [project1], |
| 230 | 237 | }), |
| 231 | − [{ key: project1, cwd: "/tmp/project-1" }], | |
| 238 | + [{ key: project1, logicalKey: project1, cwd: "/tmp/project-1" }], | |
| 232 | 239 | ); |
| 233 | 240 | |
| 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 | + ]); | |
| 235 | 244 | |
| 236 | 245 | expect(next).not.toBe(initialState); |
| 237 | 246 | expect(next.projectOrder).toEqual([project1]); |
| 238 | 247 | expect(next.projectExpandedById[project1]).toBe(false); |
| 239 | 248 | }); |
| 240 | 249 | |
| 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 | + | |
| 241 | 302 | it("syncThreads prunes missing thread UI state", () => { |
| 242 | 303 | const thread1 = ThreadId.make("thread-1"); |
| 243 | 304 | const thread2 = ThreadId.make("thread-2"); |
| @@ -346,3 +407,156 @@ describe("uiStateStore pure functions", () => { · 102 unmodified lines | ||
| 346 | 407 | expect(next.threadChangedFilesExpandedById).toEqual({}); |
| 347 | 408 | }); |
| 348 | 409 | }); |
| 410 | + | |
| 411 | +describe("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 | +}); | |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { Debouncer } from "@tanstack/react-pacer"; |
| 2 | 2 | import { create } from "zustand"; |
| 3 | 3 | |
| 4 | −const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; | |
| 4 | +export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; | |
| 5 | 5 | const LEGACY_PERSISTED_STATE_KEYS = [ |
| 6 | 6 | "t3code:renderer-state:v8", |
| 7 | 7 | "t3code:renderer-state:v7", |
| @@ -15,7 +15,8 @@ const LEGACY_PERSISTED_STATE_KEYS = [ · 7 unmodified lines | ||
| 15 | 15 | "codething:renderer-state:v1", |
| 16 | 16 | ] as const; |
| 17 | 17 | |
| 18 | −interface PersistedUiState { | |
| 18 | +export interface PersistedUiState { | |
| 19 | + collapsedProjectCwds?: string[]; | |
| 19 | 20 | expandedProjectCwds?: string[]; |
| 20 | 21 | projectOrderCwds?: string[]; |
| 21 | 22 | threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>; |
| @@ -34,7 +35,10 @@ export interface UiThreadState { · 12 unmodified lines | ||
| 34 | 35 | export interface UiState extends UiProjectState, UiThreadState {} |
| 35 | 36 | |
| 36 | 37 | export interface SyncProjectInput { |
| 38 | + /** Physical project key (env + cwd). Used for manual sort order. */ | |
| 37 | 39 | key: string; |
| 40 | + /** Logical group key. Used for expand/collapse state. */ | |
| 41 | + logicalKey: string; | |
| 38 | 42 | cwd: string; |
| 39 | 43 | } |
| 40 | 44 | |
| @@ -50,9 +54,17 @@ const initialState: UiState = { · 9 unmodified lines | ||
| 50 | 54 | threadChangedFilesExpandedById: {}, |
| 51 | 55 | }; |
| 52 | 56 | |
| 57 | +const persistedCollapsedProjectCwds = new Set<string>(); | |
| 53 | 58 | const persistedExpandedProjectCwds = new Set<string>(); |
| 54 | 59 | const 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. | |
| 64 | +let persistedProjectStateUsesLegacyShape = false; | |
| 55 | 65 | const currentProjectCwdById = new Map<string, string>(); |
| 66 | +const currentProjectCwdsByLogicalKey = new Map<string, string[]>(); | |
| 67 | +const currentLogicalKeyByPhysicalKey = new Map<string, string>(); | |
| 56 | 68 | let legacyKeysCleanedUp = false; |
| 57 | 69 | |
| 58 | 70 | function readPersistedState(): UiState { |
| @@ -113,9 +125,16 @@ function sanitizePersistedThreadChangedFilesExpanded( · 54 unmodified lines | ||
| 113 | 125 | return nextState; |
| 114 | 126 | } |
| 115 | 127 | |
| 116 | −function hydratePersistedProjectState(parsed: PersistedUiState): void { | |
| 128 | +export function hydratePersistedProjectState(parsed: PersistedUiState): void { | |
| 129 | + persistedCollapsedProjectCwds.clear(); | |
| 117 | 130 | persistedExpandedProjectCwds.clear(); |
| 118 | 131 | 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 | + } | |
| 119 | 138 | for (const cwd of parsed.expandedProjectCwds ?? []) { |
| 120 | 139 | if (typeof cwd === "string" && cwd.length > 0) { |
| 121 | 140 | persistedExpandedProjectCwds.add(cwd); |
| @@ -128,17 +147,20 @@ function hydratePersistedProjectState(parsed: PersistedUiState): void { · 6 unmodified lines | ||
| 128 | 147 | } |
| 129 | 148 | } |
| 130 | 149 | |
| 131 | −function persistState(state: UiState): void { | |
| 150 | +export function persistState(state: UiState): void { | |
| 132 | 151 | if (typeof window === "undefined") { |
| 133 | 152 | return; |
| 134 | 153 | } |
| 135 | 154 | 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) ?? []); | |
| 136 | 161 | const expandedProjectCwds = Object.entries(state.projectExpandedById) |
| 137 | 162 | .filter(([, expanded]) => expanded) |
| 138 | − .flatMap(([projectId]) => { | |
| 139 | − const cwd = currentProjectCwdById.get(projectId); | |
| 140 | − return cwd ? [cwd] : []; | |
| 141 | − }); | |
| 163 | + .flatMap(([logicalKey]) => currentProjectCwdsByLogicalKey.get(logicalKey) ?? []); | |
| 142 | 164 | const projectOrderCwds = state.projectOrder.flatMap((projectId) => { |
| 143 | 165 | const cwd = currentProjectCwdById.get(projectId); |
| 144 | 166 | return cwd ? [cwd] : []; |
| @@ -154,6 +176,7 @@ function persistState(state: UiState): void { · 9 unmodified lines | ||
| 154 | 176 | window.localStorage.setItem( |
| 155 | 177 | PERSISTED_STATE_KEY, |
| 156 | 178 | JSON.stringify({ |
| 179 | + collapsedProjectCwds, | |
| 157 | 180 | expandedProjectCwds, |
| 158 | 181 | projectOrderCwds, |
| 159 | 182 | threadChangedFilesExpandedById, |
| @@ -211,12 +234,40 @@ function nestedBooleanRecordsEqual( · 51 unmodified lines | ||
| 211 | 234 | |
| 212 | 235 | export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState { |
| 213 | 236 | 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); | |
| 217 | 238 | currentProjectCwdById.clear(); |
| 239 | + currentLogicalKeyByPhysicalKey.clear(); | |
| 218 | 240 | for (const project of projects) { |
| 219 | 241 | 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 | + } | |
| 220 | 271 | } |
| 221 | 272 | const cwdMappingChanged = |
| 222 | 273 | previousProjectCwdById.size !== currentProjectCwdById.size || |
| @@ -228,14 +279,38 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput · 5 unmodified lines | ||
| 228 | 279 | persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const), |
| 229 | 280 | ); |
| 230 | 281 | 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 | + } | |
| 239 | 314 | return { |
| 240 | 315 | id: project.key, |
| 241 | 316 | cwd: project.cwd, |
| @@ -246,6 +321,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput · 4 unmodified lines | ||
| 246 | 321 | const nextProjectOrder = |
| 247 | 322 | state.projectOrder.length > 0 |
| 248 | 323 | ? (() => { |
| 324 | + const currentProjectIds = new Set(mappedProjects.map((project) => project.id)); | |
| 249 | 325 | const nextProjectIdByCwd = new Map( |
| 250 | 326 | mappedProjects.map((project) => [project.cwd, project.id] as const), |
| 251 | 327 | ); |
| @@ -254,7 +330,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput · 2 unmodified lines | ||
| 254 | 330 | |
| 255 | 331 | for (const projectId of state.projectOrder) { |
| 256 | 332 | const matchedProjectId = |
| 257 | − (projectId in nextExpandedById ? projectId : undefined) ?? | |
| 333 | + (currentProjectIds.has(projectId) ? projectId : undefined) ?? | |
| 258 | 334 | (() => { |
| 259 | 335 | const previousCwd = previousProjectCwdById.get(projectId); |
| 260 | 336 | return previousCwd ? nextProjectIdByCwd.get(previousCwd) : undefined; |