Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,16 @@ import {
type DashboardSidebarSectionRow,
dashboardSidebarProjectSchema,
dashboardSidebarSectionSchema,
healV2UserPreferences,
healWorkspaceLocalState,
type V2TerminalPresetRow,
type V2UserPreferencesRow,
v2TerminalPresetSchema,
v2UserPreferencesSchema,
type WorkspaceLocalStateRow,
workspaceLocalStateSchema,
} from "./dashboardSidebarLocal";
import { withReadHeal } from "./withReadHeal";

const columnMapper = snakeCamelMapper();

Expand Down Expand Up @@ -641,12 +644,19 @@ function createOrgCollections(organizationId: string): OrgCollections {
);

const v2WorkspaceLocalState = createIndexedCollection(
localStorageCollectionOptions({
id: `v2_workspace_local_state-${organizationId}`,
storageKey: `v2-workspace-local-state-${organizationId}`,
schema: workspaceLocalStateSchema,
getKey: (item) => item.workspaceId,
}),
localStorageCollectionOptions(
withReadHeal(
{
id: `v2_workspace_local_state-${organizationId}`,
storageKey: `v2-workspace-local-state-${organizationId}`,
schema: workspaceLocalStateSchema,
// Explicit type so `withReadHeal`'s passthrough generic keeps the
// linkage between schema and getKey for downstream inference.
getKey: (item: WorkspaceLocalStateRow) => item.workspaceId,
},
healWorkspaceLocalState,
),
),
);

const v2SidebarSections = createIndexedCollection(
Expand All @@ -668,15 +678,21 @@ function createOrgCollections(organizationId: string): OrgCollections {
);

const v2UserPreferences = createCollection(
localStorageCollectionOptions({
id: `v2_user_preferences-${organizationId}`,
storageKey: `v2-user-preferences-${organizationId}`,
schema: v2UserPreferencesSchema,
// Cast widens the inferred literal "preferences" key to string so
// the collection slots into the shared OrgCollections.{...<TKey=string>}
// shape alongside the other v2 collections.
getKey: (item) => item.id as string,
}),
localStorageCollectionOptions(
withReadHeal(
{
id: `v2_user_preferences-${organizationId}`,
storageKey: `v2-user-preferences-${organizationId}`,
schema: v2UserPreferencesSchema,
// Cast widens the inferred literal "preferences" key to string so
// the collection slots into the shared OrgCollections.{...<TKey=string>}
// shape alongside the other v2 collections. Explicit `item` type so
// `withReadHeal`'s passthrough generic keeps schema/getKey linkage.
getKey: (item: V2UserPreferencesRow) => item.id as string,
},
healV2UserPreferences,
),
),
);

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { describe, expect, it } from "bun:test";
import {
DEFAULT_V2_USER_PREFERENCES,
healV2UserPreferences,
healWorkspaceLocalState,
} from "./schema";

describe("healV2UserPreferences", () => {
it("returns full defaults for empty/non-object input", () => {
expect(healV2UserPreferences({})).toEqual(DEFAULT_V2_USER_PREFERENCES);
expect(healV2UserPreferences(null)).toEqual(DEFAULT_V2_USER_PREFERENCES);
expect(healV2UserPreferences(undefined)).toEqual(
DEFAULT_V2_USER_PREFERENCES,
);
});

it("preserves stored top-level fields and fills missing ones", () => {
const stored = { rightSidebarOpen: false, rightSidebarWidth: 500 };
const healed = healV2UserPreferences(stored);
expect(healed.rightSidebarOpen).toBe(false);
expect(healed.rightSidebarWidth).toBe(500);
expect(healed.sidebarFileLinks).toEqual(
DEFAULT_V2_USER_PREFERENCES.sidebarFileLinks,
);
expect(healed.fileLinks).toEqual(DEFAULT_V2_USER_PREFERENCES.fileLinks);
});

it("reproduces the original crash shape: missing sidebarFileLinks entirely", () => {
// Shape of rows persisted before sidebarFileLinks was added in e8067e196.
const stored = {
id: "preferences",
fileLinks: { plain: null, shift: null, meta: "pane", metaShift: null },
urlLinks: { plain: null, shift: null, meta: "pane", metaShift: null },
rightSidebarOpen: true,
rightSidebarTab: "changes",
rightSidebarWidth: 340,
deleteLocalBranch: false,
};
const healed = healV2UserPreferences(stored);
expect(healed.sidebarFileLinks).toEqual(
DEFAULT_V2_USER_PREFERENCES.sidebarFileLinks,
);
// Every tier defined — the property buildHint reads.
expect(healed.sidebarFileLinks.shift).toBeDefined();
});

it("fills missing tiers inside an otherwise-present tier map", () => {
// Hypothetical future shape: sidebarFileLinks exists but a tier was added
// to the schema after this row was written.
const stored = {
sidebarFileLinks: { plain: "pane", meta: "external" },
};
const healed = healV2UserPreferences(stored);
expect(healed.sidebarFileLinks.plain).toBe("pane");
expect(healed.sidebarFileLinks.meta).toBe("external");
// Tiers absent from the stored row fall back to defaults.
expect(healed.sidebarFileLinks.shift).toBe(
DEFAULT_V2_USER_PREFERENCES.sidebarFileLinks.shift,
);
expect(healed.sidebarFileLinks.metaShift).toBe(
DEFAULT_V2_USER_PREFERENCES.sidebarFileLinks.metaShift,
);
});
});

describe("healWorkspaceLocalState", () => {
const baseStored = {
workspaceId: "11111111-1111-1111-1111-111111111111",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
paneLayout: { panes: [], focusedPaneId: null },
sidebarState: {
projectId: "22222222-2222-2222-2222-222222222222",
tabOrder: 3,
sectionId: null,
changesFilter: { kind: "all" },
activeTab: "changes",
isHidden: false,
},
viewedFiles: ["a.ts"],
recentlyViewedFiles: [],
};

it("preserves identity fields and stored values verbatim", () => {
const healed = healWorkspaceLocalState(baseStored);
expect(healed.workspaceId).toBe(baseStored.workspaceId);
expect(healed.createdAt).toBe(baseStored.createdAt);
// Reference equality — bun's strict toBe types reject the narrow stub,
// so compare via Object.is on a widened lhs and assert the boolean.
expect(Object.is(healed.paneLayout as unknown, baseStored.paneLayout)).toBe(
true,
);
expect(healed.sidebarState.projectId).toBe(
baseStored.sidebarState.projectId,
);
expect(healed.sidebarState.tabOrder).toBe(3);
expect(healed.viewedFiles).toEqual(["a.ts"]);
});

it("fills missing top-level optional fields", () => {
const stored = {
...baseStored,
viewedFiles: undefined,
recentlyViewedFiles: undefined,
};
const healed = healWorkspaceLocalState(stored);
expect(healed.viewedFiles).toEqual([]);
expect(healed.recentlyViewedFiles).toEqual([]);
});

it("fills missing nested sidebarState fields while preserving projectId", () => {
// Hypothetical future shape: a sidebarState field was added after this
// row was written. Identity (projectId) survives; defaults fill in.
const stored = {
...baseStored,
sidebarState: { projectId: baseStored.sidebarState.projectId },
};
const healed = healWorkspaceLocalState(stored);
expect(healed.sidebarState.projectId).toBe(
baseStored.sidebarState.projectId,
);
expect(healed.sidebarState.tabOrder).toBe(0);
expect(healed.sidebarState.sectionId).toBeNull();
expect(healed.sidebarState.changesFilter).toEqual({ kind: "all" });
expect(healed.sidebarState.activeTab).toBe("changes");
expect(healed.sidebarState.isHidden).toBe(false);
});

it("does not throw on null/non-object input (parser must never throw)", () => {
// Heal must never throw — a throw would take down the entire collection
// load (loadFromStorage swallows the error and returns an empty Map).
expect(() => healWorkspaceLocalState(null)).not.toThrow();
expect(() => healWorkspaceLocalState(undefined)).not.toThrow();
expect(() => healWorkspaceLocalState("garbage")).not.toThrow();
expect(() => healWorkspaceLocalState(42)).not.toThrow();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,26 @@ export const workspaceLocalStateSchema = z.object({
.default([]),
});

// Defaults for fields heal can synthesize. Identity fields (workspaceId,
// createdAt, paneLayout, sidebarState.projectId) intentionally absent — they
// must come from the stored row.
const SIDEBAR_STATE_DEFAULTS = {
tabOrder: 0,
sectionId: null,
changesFilter: { kind: "all" },
activeTab: "changes",
isHidden: false,
} as const;

const WORKSPACE_LOCAL_STATE_OPTIONAL_DEFAULTS = {
viewedFiles: [] as string[],
recentlyViewedFiles: [] as Array<{
relativePath: string;
absolutePath: string;
lastAccessedAt: number;
}>,
};

export const dashboardSidebarSectionSchema = z.object({
sectionId: z.string().uuid(),
projectId: z.string().uuid(),
Expand Down Expand Up @@ -166,3 +186,53 @@ export const DEFAULT_V2_USER_PREFERENCES: V2UserPreferencesRow = {
rightSidebarWidth: 340,
deleteLocalBranch: false,
};

/**
* Heal a stored workspaceLocalState row against current defaults. Identity
* fields (workspaceId, projectId, paneLayout, createdAt) pass through from
* the stored row — they have no synthesizable default. Optional fields with
* intrinsic defaults get filled at both the top level and inside sidebarState.
*/
export function healWorkspaceLocalState(raw: unknown): WorkspaceLocalStateRow {
const r = (
raw && typeof raw === "object" ? raw : {}
) as Partial<WorkspaceLocalStateRow>;
const sidebar = (
r.sidebarState && typeof r.sidebarState === "object" ? r.sidebarState : {}
) as Partial<WorkspaceLocalStateRow["sidebarState"]>;
return {
...r,
viewedFiles:
r.viewedFiles ?? WORKSPACE_LOCAL_STATE_OPTIONAL_DEFAULTS.viewedFiles,
recentlyViewedFiles:
r.recentlyViewedFiles ??
WORKSPACE_LOCAL_STATE_OPTIONAL_DEFAULTS.recentlyViewedFiles,
sidebarState: {
...SIDEBAR_STATE_DEFAULTS,
...sidebar,
} as WorkspaceLocalStateRow["sidebarState"],
} as WorkspaceLocalStateRow;
}

/**
* Heal a stored v2 user-preferences row against current defaults. Used by the
* localStorage collection's read-time parser so rows persisted before a field
* was added (top-level or nested in a LinkTierMap) don't surface as undefined
* to consumers. Per-tier defaults vary by map, so we deep-merge each tier map
* against its own default rather than relying on a single Zod default.
*/
export function healV2UserPreferences(raw: unknown): V2UserPreferencesRow {
const r = (
raw && typeof raw === "object" ? raw : {}
) as Partial<V2UserPreferencesRow>;
return {
...DEFAULT_V2_USER_PREFERENCES,
...r,
fileLinks: { ...DEFAULT_V2_USER_PREFERENCES.fileLinks, ...r.fileLinks },
urlLinks: { ...DEFAULT_V2_USER_PREFERENCES.urlLinks, ...r.urlLinks },
sidebarFileLinks: {
...DEFAULT_V2_USER_PREFERENCES.sidebarFileLinks,
...r.sidebarFileLinks,
},
};
}
Loading
Loading