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