feat(LayoutBrowser): refetch organization layouts from the server - #8
Conversation
Organization layouts had no way to pull the server's current version. The periodic sync refreshes baselines, but it preserves the local working copy and only a revert event re-applies a layout to the viewport, so once a user touches an org layout their stale copy shadows the server's indefinitely. Add LayoutManager.refetchLayout, which replaces a shared layout's cached copy with the server's and discards local changes. It reports the change as a revert so CurrentLayoutProvider re-renders the open layout. Surface it two ways: - "Refetch from server" in a layout row's context menu, confirmed when the layout has unsaved changes. Offered on read-only catalog layouts too, since refetching is a read. - A refresh button in the Layouts sidebar that force-runs syncWithRemote for everything, non-destructive and never discarding a working copy. refetchLayout and syncWithRemote are now declared on ILayoutManager; the latter already existed on the concrete class.
…etch refetchLayout wrote syncInfo.status = "tracked" regardless of the permission the server returned. A row that comes back as CREATOR_WRITE would then be stored as a personal layout carrying syncInfo, and the sync reducer treats a tracked layout missing from the remote list as delete-local — silently dropping it from the cache. Key the syncInfo off the returned permission, matching add-to-cache.
| // Keyed off the permission the server just returned, not the one the | ||
| // layout had locally: a personal layout must never carry syncInfo, or | ||
| // the sync reducer becomes free to delete it. Same rule as add-to-cache. | ||
| syncInfo: layoutPermissionIsShared(remoteLayout.permission) |
There was a problem hiding this comment.
Review finding (fixed in e2538da): refetchLayout originally wrote syncInfo: { status: "tracked", ... } unconditionally.
Failure scenario: the remote row comes back with permission: "CREATOR_WRITE" (someone demoted it, or the workspace list includes personal rows as it can in the web LayoutsAPI build). The layout was then persisted as a personal layout carrying syncInfo. LayoutBrowser files it under Personal, and on the next sync where that row is absent from the remote list, computeLayoutSyncOperations.syncLocalLayout hits case "tracked" with !layoutIsShared(localLayout) and emits delete-local — silently deleting the user's personal layout from the cache. This is the invariant updateLayout documents as "Personal layouts should NEVER have syncInfo".
Now keyed off the returned permission, matching the add-to-cache sync path.
| name: remoteLayout.name, | ||
| permission: remoteLayout.permission, | ||
| baseline: { data: remoteLayout.data, savedAt: remoteLayout.savedAt }, | ||
| working: undefined, |
There was a problem hiding this comment.
Review finding (not fixed — known limitation, tracked here): clearing working races with CurrentLayoutSyncAdapter's 1s debounced write.
Failure scenario: user drags a panel in an org layout. performAction sets edited: true and the adapter starts its SAVE_INTERVAL_MS (1000ms) debounce. Because working has not been persisted yet, layout.working is still undefined, so LayoutRow's hasModifications is false and Refetch from server runs with no confirm dialog. The refetch clears working and the viewport redraws with the server copy. ~1s later the debounced useAsync flushes its pre-refetch snapshot through layoutManager.updateLayout({ id, data: staleEditedData }); that compares against the new server baseline, finds a difference, and writes the discarded edits back as working. The layout re-acquires the unsaved-changes dot and the discarded edits come back the next time it is loaded.
Not fixed in this PR deliberately: revertLayout has the identical exposure today, and the correct fix is in CurrentLayoutSyncAdapter (drop pending unsaved entries for a layout when a revert event arrives for it), which is outside this diff. Window is ~1s from the last panel edit.
Problem
Once an organization layout is loaded, there is no way to pull the server's current version. The periodic sync (30s–3min) does refresh baselines, but:
update-baselinepreserves the localworkingcopy, so as soon as a user nudges a panel their stale edit shadows the server version indefinitely.CurrentLayoutProviderre-applies a layout to the viewport only on arevertevent, so even a refreshed baseline stays invisible until the user switches layouts and back.Change
LayoutManager.refetchLayout({ id })replaces a shared layout's cached copy with the server's current version, discarding the working copy. It reports the result as arevertevent, which is what makes the open layout actually re-render.Two entry points:
ORG_WRITEand read-onlyORG_READcatalog layouts — refetching is a read, so unlike rename/delete it is not gated on write permission, and a stale catalog layout is exactly where a user gets stranded. Hidden on personal layouts, disabled offline and under multi-selection.syncWithRemoteinstead of waiting out the backoff. Deliberately non-destructive: it never discards anyone's working copy. Only rendered when organization layout storage is present.The remote copy is located via
getLayouts()rather thangetLayout(id), since only someIRemoteLayoutStorageimplementations support fetching a single layout by id.refetchLayoutandsyncWithRemoteare now declared onILayoutManager(the latter already existed on the concrete class and the mock).Testing
LayoutManager.refetchLayout: baseline replaced and working dropped,revertemitted, personal layout rejected, offline rejected, missing locally, deleted on the server.LayoutRow: offered on catalog layouts, hidden for personal and when no handler is supplied, disabled offline / multi-select, confirm honored and dismissed.LayoutBrowseranduseLayoutActions: refresh button hidden without sharing, callssyncWithRemote, disabled offline.270 tests pass across the affected suites;
yarn build:packagesandyarn extension:typecheckexit 0; eslint and biome are clean.