| author | |
| committer | |
| log | de05b0c94e257e69afc346ae6daaea85fcfb4e65 |
| tree | b531b5a6323f84e54e4764f4dac9f9beb7de91f5 |
| parent | 6d1505c9fb6ef9a7e147133bb9827f2f36e6945c |
| signature | Signed by PGP key B5690EEEBB952194 |
8 files changed, 398 insertions(+), 44 deletions(-)
apps/web/src/components/Sidebar.logic.test.ts+36| ... | ... | @@ -309,6 +309,42 @@ describe("orderItemsByPreferredIds", () => { |
| 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", () => { |
apps/web/src/components/Sidebar.tsx+6-2| ... | ... | @@ -168,7 +168,11 @@ import { CommandDialogTrigger } from "./ui/command"; |
| 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() { |
| 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 |
apps/web/src/environments/runtime/service.ts+9-1| ... | ... | @@ -61,7 +61,11 @@ import { useTerminalStateStore } from "~/terminalStateStore"; |
| 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( |
| 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( |
| 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 | ); |
apps/web/src/hooks/useHandleNewThread.ts+2-2| ... | ... | @@ -10,7 +10,7 @@ import { |
| 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() { |
| 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(); |
apps/web/src/hooks/useSettings.ts+9| ... | ... | @@ -123,6 +123,15 @@ function splitPatch(patch: Partial<UnifiedSettings>): { |
| 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( |
apps/web/src/logicalProject.ts+7| ... | ... | @@ -65,6 +65,13 @@ export function deriveProjectGroupingOverrideKey( |
| 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, |
apps/web/src/uiStateStore.test.ts+233-19| ... | ... | @@ -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", () => { |
| 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", () => { |
| 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", () => { |
| 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 | }); |
apps/web/src/uiStateStore.ts+96-20| ... | ... | @@ -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 = [ |
| 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 { |
| 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 = { |
| 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( |
| 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 { |
| 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 { |
| 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( |
| 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 |
| 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 |
| 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 |
| 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; |