fix(app): validate persisted client state (#3289)
* fix(app): validate persisted client state Persisted replica data could satisfy TypeScript through an assertion while missing fields required by renderers. Treat every AsyncStorage value as untrusted input, clear values that fail strict schemas, and version the replica DTO so incompatible caches cannot hydrate. * fix(app): preserve validated legacy state * fix(app): preserve validated preferences Preserve supported legacy location fields and every shipped theme while keeping unknown persisted fields rejected. Align browser test seeds with the current preference shape so strict validation exercises the intended provider. * fix(app): migrate legacy agent preferences Released clients stored host/location fields and per-provider thinkingOptionId together. Migrate those exact strict bytes into current preferences without admitting unknown fields. * fix(app): preserve drafts with legacy reviews Accept the exact strict shape released for workspace review attachments so migration can discard that obsolete attachment without clearing every persisted draft.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { persist } from "zustand/middleware";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import type { AttachmentMetadata, WorkspaceFileComposerAttachment } from "@/attachments/types";
|
||||
import { appendWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||
@@ -27,8 +27,14 @@ import {
|
||||
type DraftRecord,
|
||||
type DraftStoreState,
|
||||
} from "./state";
|
||||
import { migrateDraftInput, migratePersistedState, type MigrateLegacyImages } from "./migration";
|
||||
import {
|
||||
migrateDraftInput,
|
||||
migratePersistedState,
|
||||
type MigrateLegacyImages,
|
||||
PersistedDraftStoreSchema,
|
||||
} from "./migration";
|
||||
import { createDraftPersistStorage } from "./persistence";
|
||||
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
||||
|
||||
export type { DraftInput, DraftLifecycleState } from "./state";
|
||||
|
||||
@@ -58,7 +64,7 @@ type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions;
|
||||
|
||||
let gcScheduled = false;
|
||||
const draftPersistStorage = createDraftPersistStorage(
|
||||
createJSONStorage<DraftStoreState>(() => AsyncStorage),
|
||||
createValidatedPersistStorage(AsyncStorage, PersistedDraftStoreSchema),
|
||||
);
|
||||
|
||||
export function flushDraftPersistStorage(): Promise<void> {
|
||||
@@ -414,12 +420,11 @@ export const useDraftStore = create<DraftStore>()(
|
||||
version: DRAFT_STORE_VERSION,
|
||||
storage: draftPersistStorage,
|
||||
partialize: ({ drafts, createModalDraft }) => ({ drafts, createModalDraft }),
|
||||
migrate: (persistedState) => {
|
||||
return migratePersistedState(persistedState, {
|
||||
migrate: (state) =>
|
||||
migratePersistedState(state, {
|
||||
migrateLegacyImages,
|
||||
nowMs: Date.now(),
|
||||
});
|
||||
},
|
||||
}),
|
||||
onRehydrateStorage: () => {
|
||||
return () => {
|
||||
void migrateAllLegacyDrafts();
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StateStorage } from "zustand/middleware";
|
||||
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
|
||||
import { migratePersistedState, type MigrateLegacyImages } from "./migration";
|
||||
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
||||
import {
|
||||
migratePersistedState,
|
||||
type MigrateLegacyImages,
|
||||
PersistedDraftStoreSchema,
|
||||
} from "./migration";
|
||||
import { isAttachmentMetadata, type DraftRecord } from "./state";
|
||||
|
||||
const passThroughMigrateLegacyImages: MigrateLegacyImages = async (images) =>
|
||||
images.filter(isAttachmentMetadata);
|
||||
|
||||
function createMemoryStorage(): StateStorage & { values: Map<string, string> } {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
values,
|
||||
getItem: async (key) => values.get(key) ?? null,
|
||||
setItem: async (key, value) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: async (key) => {
|
||||
values.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function activeDraft(
|
||||
text: string,
|
||||
updatedAt: number,
|
||||
@@ -96,6 +116,30 @@ function workspaceReviewAttachment(): Extract<ComposerAttachment, { kind: "revie
|
||||
}
|
||||
|
||||
describe("draft-store migration", () => {
|
||||
it("keeps a supported legacy draft for migration", async () => {
|
||||
const backing = createMemoryStorage();
|
||||
const legacyState = {
|
||||
drafts: {
|
||||
"draft:unsent": {
|
||||
text: "do not lose this",
|
||||
attachments: [],
|
||||
},
|
||||
},
|
||||
createModalDraft: null,
|
||||
};
|
||||
backing.values.set("paseo-drafts", JSON.stringify({ state: legacyState, version: 4 }));
|
||||
const storage = createValidatedPersistStorage(backing, PersistedDraftStoreSchema);
|
||||
|
||||
const stored = await storage.getItem("paseo-drafts");
|
||||
const migrated = await migratePersistedState(stored?.state, {
|
||||
migrateLegacyImages: passThroughMigrateLegacyImages,
|
||||
nowMs: 1,
|
||||
});
|
||||
|
||||
expect(migrated.drafts["draft:unsent"]?.input.text).toBe("do not lose this");
|
||||
expect(backing.values.has("paseo-drafts")).toBe(true);
|
||||
});
|
||||
|
||||
it("promotes the newest legacy New Workspace draft into the singleton surface", async () => {
|
||||
const forkDraft = activeDraft("fork context", 1700000000003);
|
||||
const agentDraft = activeDraft("agent prompt", 1700000000004);
|
||||
@@ -242,25 +286,35 @@ describe("draft-store migration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects workspace review attachments from migrated draft attachments", async () => {
|
||||
const migrated = await migratePersistedState(
|
||||
{
|
||||
drafts: {
|
||||
"agent:server:agent": {
|
||||
input: {
|
||||
text: "hello",
|
||||
attachments: [workspaceReviewAttachment()],
|
||||
},
|
||||
lifecycle: "active",
|
||||
updatedAt: 1700000000001,
|
||||
version: 2,
|
||||
it("drops a legacy workspace review attachment without deleting its draft", async () => {
|
||||
const backing = createMemoryStorage();
|
||||
const persistedState = {
|
||||
drafts: {
|
||||
"agent:server:agent": {
|
||||
input: {
|
||||
text: "hello",
|
||||
attachments: [workspaceReviewAttachment()],
|
||||
},
|
||||
lifecycle: "active",
|
||||
updatedAt: 1700000000001,
|
||||
version: 2,
|
||||
},
|
||||
createModalDraft: null,
|
||||
},
|
||||
{ migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000002 },
|
||||
);
|
||||
createModalDraft: null,
|
||||
};
|
||||
backing.values.set("paseo-drafts", JSON.stringify({ state: persistedState, version: 4 }));
|
||||
const storage = createValidatedPersistStorage(backing, PersistedDraftStoreSchema);
|
||||
|
||||
expect(migrated.drafts["agent:server:agent"]?.input.attachments).toEqual([]);
|
||||
const stored = await storage.getItem("paseo-drafts");
|
||||
const migrated = await migratePersistedState(stored?.state, {
|
||||
migrateLegacyImages: passThroughMigrateLegacyImages,
|
||||
nowMs: 1700000000002,
|
||||
});
|
||||
|
||||
expect(migrated.drafts["agent:server:agent"]?.input).toEqual({
|
||||
text: "hello",
|
||||
attachments: [],
|
||||
});
|
||||
expect(backing.values.has("paseo-drafts")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types";
|
||||
import { isLegacyNewWorkspaceDraftKey, NEW_WORKSPACE_DRAFT_KEY } from "@/stores/draft-keys";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
isAttachmentMetadata,
|
||||
isLegacyDraftImage,
|
||||
isUserComposerAttachment,
|
||||
AttachmentMetadataSchema,
|
||||
LegacyDraftImageSchema,
|
||||
normalizeAttachmentMetadata,
|
||||
normalizeComposerAttachment,
|
||||
UserComposerAttachmentSchema,
|
||||
type CanonicalDraftInput,
|
||||
type DraftLifecycleState,
|
||||
type DraftRecord,
|
||||
@@ -13,28 +14,108 @@ import {
|
||||
type PersistedDraftImage,
|
||||
} from "./state";
|
||||
|
||||
const LegacyPullRequestItemSchema = z.strictObject({
|
||||
kind: z.literal("pr"),
|
||||
forge: z.string().optional(),
|
||||
number: z.number(),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
state: z.string(),
|
||||
body: z.string().nullable(),
|
||||
labels: z.array(z.string()),
|
||||
projectPath: z.string().optional(),
|
||||
baseRefName: z.string().nullable().optional(),
|
||||
headRefName: z.string().nullable().optional(),
|
||||
updatedAt: z.string().optional(),
|
||||
});
|
||||
const LegacyGithubPrAttachmentSchema = z.strictObject({
|
||||
kind: z.literal("github_pr"),
|
||||
item: LegacyPullRequestItemSchema,
|
||||
owner: z.literal("new-workspace-picker").optional(),
|
||||
});
|
||||
const LegacyReviewAttachmentContextLineSchema = z.strictObject({
|
||||
oldLineNumber: z.number().int().positive().nullable(),
|
||||
newLineNumber: z.number().int().positive().nullable(),
|
||||
type: z.enum(["add", "remove", "context"]),
|
||||
content: z.string(),
|
||||
});
|
||||
const LegacyReviewAttachmentSchema = z.strictObject({
|
||||
kind: z.literal("review"),
|
||||
reviewDraftKey: z.string(),
|
||||
commentCount: z.number().int().nonnegative(),
|
||||
attachment: z.strictObject({
|
||||
type: z.literal("review"),
|
||||
mimeType: z.literal("application/paseo-review"),
|
||||
cwd: z.string(),
|
||||
mode: z.enum(["uncommitted", "base"]),
|
||||
baseRef: z.string().nullable().optional(),
|
||||
comments: z.array(
|
||||
z.strictObject({
|
||||
filePath: z.string(),
|
||||
side: z.enum(["old", "new"]),
|
||||
lineNumber: z.number().int().positive(),
|
||||
body: z.string(),
|
||||
context: z.strictObject({
|
||||
hunkHeader: z.string(),
|
||||
targetLine: LegacyReviewAttachmentContextLineSchema,
|
||||
lines: z.array(LegacyReviewAttachmentContextLineSchema),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
const PersistedComposerAttachmentSchema = z.union([
|
||||
UserComposerAttachmentSchema,
|
||||
LegacyGithubPrAttachmentSchema,
|
||||
LegacyReviewAttachmentSchema,
|
||||
]);
|
||||
type PersistedComposerAttachment = z.infer<typeof PersistedComposerAttachmentSchema>;
|
||||
const LegacyAttachmentMetadataSchema = AttachmentMetadataSchema.extend({
|
||||
previewUri: z.string(),
|
||||
});
|
||||
type PersistedImage = PersistedDraftImage | z.infer<typeof LegacyAttachmentMetadataSchema>;
|
||||
|
||||
const RawDraftInputSchema = z.strictObject({
|
||||
text: z.string().optional(),
|
||||
attachments: z.array(PersistedComposerAttachmentSchema).optional(),
|
||||
images: z
|
||||
.array(
|
||||
z.union([AttachmentMetadataSchema, LegacyAttachmentMetadataSchema, LegacyDraftImageSchema]),
|
||||
)
|
||||
.optional(),
|
||||
cwd: z.string().optional(),
|
||||
});
|
||||
const DraftLifecycleSchema = z.enum(["active", "abandoned", "sent"]);
|
||||
const NestedDraftRecordSchema = z.strictObject({
|
||||
input: RawDraftInputSchema,
|
||||
lifecycle: DraftLifecycleSchema.optional(),
|
||||
updatedAt: z.number().optional(),
|
||||
version: z.number().int().positive().optional(),
|
||||
});
|
||||
const FlatDraftRecordSchema = RawDraftInputSchema.extend({
|
||||
lifecycle: DraftLifecycleSchema.optional(),
|
||||
updatedAt: z.number().optional(),
|
||||
version: z.number().int().positive().optional(),
|
||||
});
|
||||
const PersistedDraftRecordSchema = z.union([NestedDraftRecordSchema, FlatDraftRecordSchema]);
|
||||
export const PersistedDraftStoreSchema = z.strictObject({
|
||||
drafts: z.record(z.string(), PersistedDraftRecordSchema).optional(),
|
||||
createModalDraft: PersistedDraftRecordSchema.nullable().optional(),
|
||||
});
|
||||
type PersistedDraftRecord = z.infer<typeof PersistedDraftRecordSchema>;
|
||||
|
||||
export type MigrateLegacyImages = (
|
||||
images: readonly PersistedDraftImage[],
|
||||
) => Promise<AttachmentMetadata[]>;
|
||||
|
||||
function normalizePersistedImage(value: unknown): PersistedDraftImage | null {
|
||||
if (isAttachmentMetadata(value)) {
|
||||
function normalizePersistedImage(value: PersistedImage): PersistedDraftImage {
|
||||
if ("id" in value) {
|
||||
return normalizeAttachmentMetadata(value);
|
||||
}
|
||||
if (isLegacyDraftImage(value)) {
|
||||
return {
|
||||
uri: value.uri,
|
||||
...(value.mimeType ? { mimeType: value.mimeType } : {}),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePersistedComposerAttachment(value: unknown): UserComposerAttachment | null {
|
||||
if (!isUserComposerAttachment(value)) {
|
||||
return null;
|
||||
}
|
||||
return normalizeComposerAttachment(value);
|
||||
return {
|
||||
uri: value.uri,
|
||||
...(value.mimeType ? { mimeType: value.mimeType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function legacyImagesToAttachments(
|
||||
@@ -46,24 +127,36 @@ function legacyImagesToAttachments(
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizePersistedComposerAttachment(
|
||||
attachment: PersistedComposerAttachment,
|
||||
): UserComposerAttachment | null {
|
||||
if (attachment.kind === "review") {
|
||||
return null;
|
||||
}
|
||||
if (attachment.kind === "github_pr" && attachment.item.kind === "pr") {
|
||||
return {
|
||||
kind: "github_pr",
|
||||
item: { ...attachment.item, kind: "change_request" },
|
||||
...(attachment.owner ? { owner: attachment.owner } : {}),
|
||||
};
|
||||
}
|
||||
const result = UserComposerAttachmentSchema.safeParse(attachment);
|
||||
if (!result.success) {
|
||||
throw new Error("Persisted composer attachment failed validation after migration");
|
||||
}
|
||||
return normalizeComposerAttachment(result.data);
|
||||
}
|
||||
|
||||
export async function migrateDraftInput(
|
||||
input: { rawInput: unknown },
|
||||
ports: { migrateLegacyImages: MigrateLegacyImages },
|
||||
): Promise<CanonicalDraftInput> {
|
||||
const rawInput =
|
||||
input.rawInput && typeof input.rawInput === "object"
|
||||
? (input.rawInput as Record<string, unknown>)
|
||||
: {};
|
||||
const attachments = Array.isArray(rawInput.attachments)
|
||||
? rawInput.attachments
|
||||
.map((entry) => normalizePersistedComposerAttachment(entry))
|
||||
.filter((entry): entry is UserComposerAttachment => entry !== null)
|
||||
: [];
|
||||
const legacyImages = Array.isArray(rawInput.images)
|
||||
? rawInput.images
|
||||
.map((entry) => normalizePersistedImage(entry))
|
||||
.filter((entry): entry is PersistedDraftImage => entry !== null)
|
||||
: [];
|
||||
const result = RawDraftInputSchema.safeParse(input.rawInput);
|
||||
const rawInput = result.success ? result.data : {};
|
||||
const attachments = (rawInput.attachments ?? [])
|
||||
.map(normalizePersistedComposerAttachment)
|
||||
.filter((attachment): attachment is UserComposerAttachment => attachment !== null);
|
||||
const legacyImages = (rawInput.images ?? []).map(normalizePersistedImage);
|
||||
const migratedImages = await ports.migrateLegacyImages(legacyImages);
|
||||
|
||||
return {
|
||||
@@ -72,30 +165,29 @@ export async function migrateDraftInput(
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePersistedLifecycle(lifecycle: unknown): DraftLifecycleState {
|
||||
if (lifecycle === "sent" || lifecycle === "abandoned") {
|
||||
return lifecycle as DraftLifecycleState;
|
||||
}
|
||||
return "active";
|
||||
function resolvePersistedLifecycle(
|
||||
lifecycle: DraftLifecycleState | undefined,
|
||||
): DraftLifecycleState {
|
||||
return lifecycle ?? "active";
|
||||
}
|
||||
|
||||
function extractRawInput(record: Record<string, unknown>): unknown {
|
||||
if ("input" in record && record.input && typeof record.input === "object") {
|
||||
function extractRawInput(record: PersistedDraftRecord): z.infer<typeof RawDraftInputSchema> {
|
||||
if ("input" in record) {
|
||||
return record.input;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async function buildMigratedDraftRecord(
|
||||
parsed: Record<string, unknown>,
|
||||
parsed: PersistedDraftRecord,
|
||||
ports: { migrateLegacyImages: MigrateLegacyImages },
|
||||
nowMs: number,
|
||||
): Promise<DraftRecord> {
|
||||
return {
|
||||
input: await migrateDraftInput({ rawInput: extractRawInput(parsed) }, ports),
|
||||
lifecycle: resolvePersistedLifecycle(parsed.lifecycle),
|
||||
updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : nowMs,
|
||||
version: typeof parsed.version === "number" ? parsed.version : 1,
|
||||
updatedAt: parsed.updatedAt ?? nowMs,
|
||||
version: parsed.version ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,30 +235,17 @@ export async function migratePersistedState(
|
||||
state: unknown,
|
||||
ports: { migrateLegacyImages: MigrateLegacyImages; nowMs: number },
|
||||
): Promise<DraftStoreState> {
|
||||
const input = (state ?? {}) as {
|
||||
drafts?: Record<string, unknown>;
|
||||
createModalDraft?: unknown;
|
||||
};
|
||||
const result = PersistedDraftStoreSchema.safeParse(state);
|
||||
const input = result.success ? result.data : {};
|
||||
|
||||
const nextDrafts: Record<string, DraftRecord> = {};
|
||||
for (const [draftKey, rawRecord] of Object.entries(input.drafts ?? {})) {
|
||||
if (!rawRecord || typeof rawRecord !== "object") {
|
||||
continue;
|
||||
}
|
||||
nextDrafts[draftKey] = await buildMigratedDraftRecord(
|
||||
rawRecord as Record<string, unknown>,
|
||||
ports,
|
||||
ports.nowMs,
|
||||
);
|
||||
nextDrafts[draftKey] = await buildMigratedDraftRecord(rawRecord, ports, ports.nowMs);
|
||||
}
|
||||
|
||||
let createModalDraft: DraftRecord | null = null;
|
||||
if (input.createModalDraft && typeof input.createModalDraft === "object") {
|
||||
createModalDraft = await buildMigratedDraftRecord(
|
||||
input.createModalDraft as Record<string, unknown>,
|
||||
ports,
|
||||
ports.nowMs,
|
||||
);
|
||||
if (input.createModalDraft) {
|
||||
createModalDraft = await buildMigratedDraftRecord(input.createModalDraft, ports, ports.nowMs);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { PersistStorage } from "zustand/middleware";
|
||||
|
||||
export const DRAFT_PERSIST_INTERVAL_MS = 200;
|
||||
|
||||
export interface PersistenceScheduler {
|
||||
now: () => number;
|
||||
schedule: (callback: () => void, delayMs: number) => unknown;
|
||||
@@ -12,10 +11,28 @@ export interface DraftPersistStorage<T> extends PersistStorage<T> {
|
||||
flush: () => Promise<void>;
|
||||
}
|
||||
|
||||
let nextSystemTimerId = 0;
|
||||
const systemTimers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
const systemScheduler: PersistenceScheduler = {
|
||||
now: Date.now,
|
||||
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
cancel: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
||||
schedule: (callback, delayMs) => {
|
||||
const id = nextSystemTimerId++;
|
||||
systemTimers.set(
|
||||
id,
|
||||
setTimeout(() => {
|
||||
systemTimers.delete(id);
|
||||
callback();
|
||||
}, delayMs),
|
||||
);
|
||||
return id;
|
||||
},
|
||||
cancel: (handle) => {
|
||||
if (typeof handle !== "number") return;
|
||||
const timer = systemTimers.get(handle);
|
||||
if (timer === undefined) return;
|
||||
clearTimeout(timer);
|
||||
systemTimers.delete(handle);
|
||||
},
|
||||
};
|
||||
|
||||
export function createDraftPersistStorage<T>(
|
||||
|
||||
@@ -3,12 +3,7 @@ import {
|
||||
type AttachmentMetadata,
|
||||
type UserComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import { isWorkspaceFileComposerAttachment } from "@/attachments/workspace-file";
|
||||
import {
|
||||
ForgeSearchItemSchema,
|
||||
GitHubSearchItemSchema,
|
||||
UploadedFileAttachmentSchema,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { z } from "zod";
|
||||
|
||||
export const DRAFT_STORE_VERSION = 5;
|
||||
export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
|
||||
@@ -41,26 +36,95 @@ export interface DraftStoreState {
|
||||
createModalDraft: DraftRecord | null;
|
||||
}
|
||||
|
||||
export const AttachmentMetadataSchema = z.strictObject({
|
||||
id: z.string(),
|
||||
mimeType: z.string(),
|
||||
storageType: z.enum(["web-indexeddb", "desktop-file", "native-file"]),
|
||||
storageKey: z.string(),
|
||||
fileName: z.string().nullable().optional(),
|
||||
byteSize: z.number().nullable().optional(),
|
||||
createdAt: z.number(),
|
||||
}) satisfies z.ZodType<AttachmentMetadata>;
|
||||
export const LegacyDraftImageSchema: z.ZodType<LegacyDraftImage> = z.strictObject({
|
||||
uri: z.string(),
|
||||
mimeType: z.string().optional(),
|
||||
});
|
||||
const UploadedFileSchema = z.strictObject({
|
||||
type: z.literal("uploaded_file"),
|
||||
id: z.string(),
|
||||
fileName: z.string(),
|
||||
mimeType: z.string(),
|
||||
size: z.number().int().nonnegative(),
|
||||
path: z.string(),
|
||||
});
|
||||
const ForgeItemFields = {
|
||||
forge: z.string().optional(),
|
||||
number: z.number(),
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
state: z.string(),
|
||||
body: z.string().nullable(),
|
||||
labels: z.array(z.string()),
|
||||
projectPath: z.string().optional(),
|
||||
baseRefName: z.string().nullable().optional(),
|
||||
headRefName: z.string().nullable().optional(),
|
||||
updatedAt: z.string().optional(),
|
||||
};
|
||||
const IssueItemSchema = z.strictObject({ kind: z.literal("issue"), ...ForgeItemFields });
|
||||
const ChangeRequestItemSchema = z.strictObject({
|
||||
kind: z.literal("change_request"),
|
||||
...ForgeItemFields,
|
||||
});
|
||||
export const UserComposerAttachmentSchema: z.ZodType<UserComposerAttachment> = z.discriminatedUnion(
|
||||
"kind",
|
||||
[
|
||||
z.strictObject({ kind: z.literal("image"), metadata: AttachmentMetadataSchema }),
|
||||
z.strictObject({ kind: z.literal("file"), attachment: UploadedFileSchema }),
|
||||
z.strictObject({
|
||||
kind: z.literal("workspace_file"),
|
||||
path: z.string(),
|
||||
selection: z.discriminatedUnion("kind", [
|
||||
z.strictObject({ kind: z.literal("whole_file") }),
|
||||
z.strictObject({
|
||||
kind: z.literal("line_range"),
|
||||
startLine: z.number().int().positive(),
|
||||
endLine: z.number().int().positive(),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
z.strictObject({ kind: z.literal("forge_issue"), item: IssueItemSchema }),
|
||||
z.strictObject({ kind: z.literal("forge_change_request"), item: ChangeRequestItemSchema }),
|
||||
z.strictObject({ kind: z.literal("github_issue"), item: IssueItemSchema }),
|
||||
z.strictObject({
|
||||
kind: z.literal("github_pr"),
|
||||
item: ChangeRequestItemSchema,
|
||||
owner: z.literal(NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER).optional(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
export const CanonicalDraftInputSchema = z.strictObject({
|
||||
text: z.string(),
|
||||
attachments: z.array(UserComposerAttachmentSchema),
|
||||
// COMPAT(draft-cwd): accept legacy persisted drafts that include cwd. Stop accepting after 2026-11-09.
|
||||
cwd: z.string().optional(),
|
||||
});
|
||||
const DraftRecordSchema: z.ZodType<DraftRecord> = z.strictObject({
|
||||
input: CanonicalDraftInputSchema,
|
||||
lifecycle: z.enum(["active", "abandoned", "sent"]),
|
||||
updatedAt: z.number(),
|
||||
version: z.number().int().positive(),
|
||||
});
|
||||
export const DraftStoreStateSchema: z.ZodType<DraftStoreState> = z.strictObject({
|
||||
drafts: z.record(z.string(), DraftRecordSchema),
|
||||
createModalDraft: DraftRecordSchema.nullable(),
|
||||
});
|
||||
|
||||
export function isAttachmentMetadata(value: unknown): value is AttachmentMetadata {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof record.id === "string" &&
|
||||
typeof record.mimeType === "string" &&
|
||||
typeof record.storageType === "string" &&
|
||||
typeof record.storageKey === "string" &&
|
||||
typeof record.createdAt === "number"
|
||||
);
|
||||
return AttachmentMetadataSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
export function isLegacyDraftImage(value: unknown): value is LegacyDraftImage {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return typeof record.uri === "string";
|
||||
return LegacyDraftImageSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
export function normalizeAttachmentMetadata(image: AttachmentMetadata): AttachmentMetadata {
|
||||
@@ -80,39 +144,7 @@ export function normalizeAttachmentMetadata(image: AttachmentMetadata): Attachme
|
||||
}
|
||||
|
||||
export function isUserComposerAttachment(value: unknown): value is UserComposerAttachment {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.kind === "image") {
|
||||
const metadata = record.metadata;
|
||||
return isAttachmentMetadata(metadata);
|
||||
}
|
||||
if (record.kind === "workspace_file") {
|
||||
return isWorkspaceFileComposerAttachment(value);
|
||||
}
|
||||
if (record.kind === "file") {
|
||||
return UploadedFileAttachmentSchema.safeParse(record.attachment).success;
|
||||
}
|
||||
if (
|
||||
record.kind !== "forge_issue" &&
|
||||
record.kind !== "forge_change_request" &&
|
||||
record.kind !== "github_issue" &&
|
||||
record.kind !== "github_pr"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
record.kind === "github_pr" &&
|
||||
record.owner !== undefined &&
|
||||
record.owner !== NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
ForgeSearchItemSchema.safeParse(record.item).success ||
|
||||
GitHubSearchItemSchema.safeParse(record.item).success
|
||||
);
|
||||
return UserComposerAttachmentSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
export function normalizeComposerAttachment(
|
||||
@@ -131,33 +163,11 @@ export function normalizeComposerAttachment(
|
||||
selection: attachment.selection,
|
||||
};
|
||||
}
|
||||
if (attachment.kind === "github_pr") {
|
||||
const item =
|
||||
(attachment.item as { kind: string }).kind === "pr"
|
||||
? { ...attachment.item, kind: "change_request" as const }
|
||||
: attachment.item;
|
||||
return {
|
||||
kind: "github_pr",
|
||||
item,
|
||||
...(attachment.owner === NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER
|
||||
? { owner: attachment.owner }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
return attachment;
|
||||
}
|
||||
|
||||
export function isCanonicalDraftInput(value: unknown): value is CanonicalDraftInput {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const input = value as Record<string, unknown>;
|
||||
// COMPAT(draft-cwd): accept legacy persisted drafts that include cwd. Stop accepting after 2026-11-09.
|
||||
return (
|
||||
typeof input.text === "string" &&
|
||||
Array.isArray(input.attachments) &&
|
||||
input.attachments.every(isUserComposerAttachment)
|
||||
);
|
||||
return CanonicalDraftInputSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
export function toDraftInputIfReady(
|
||||
|
||||
@@ -20,12 +20,19 @@ class DelayedWorkspaceSelectionStorage implements LastWorkspaceSelectionStorage
|
||||
this.saved = value;
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.saved = null;
|
||||
}
|
||||
|
||||
finishHydrationWith(selection: ActiveWorkspaceSelection | null) {
|
||||
this.finishRead(selection ? JSON.stringify(selection) : null);
|
||||
}
|
||||
|
||||
getSavedSelection(): ActiveWorkspaceSelection | null {
|
||||
return this.saved ? (JSON.parse(this.saved) as ActiveWorkspaceSelection) : null;
|
||||
if (!this.saved) return null;
|
||||
const parsed: unknown = JSON.parse(this.saved);
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
return this.saved ? JSON.parse(this.saved) : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export interface ActiveWorkspaceSelection {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
@@ -8,19 +10,17 @@ export const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-
|
||||
export interface LastWorkspaceSelectionStorage {
|
||||
read(): Promise<string | null>;
|
||||
write(value: string): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
const ActiveWorkspaceSelectionSchema: z.ZodType<ActiveWorkspaceSelection> = z.strictObject({
|
||||
serverId: z.string().trim().min(1),
|
||||
workspaceId: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
function normalizeWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
return null;
|
||||
}
|
||||
const record = input as Record<string, unknown>;
|
||||
const serverId = typeof record.serverId === "string" ? record.serverId.trim() : "";
|
||||
const workspaceId = typeof record.workspaceId === "string" ? record.workspaceId.trim() : "";
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
return { serverId, workspaceId };
|
||||
const result = ActiveWorkspaceSelectionSchema.safeParse(input);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
|
||||
function parseStoredWorkspaceSelection(stored: string | null): ActiveWorkspaceSelection | null {
|
||||
@@ -75,6 +75,9 @@ export function createLastWorkspaceSelectionStore(storage: LastWorkspaceSelectio
|
||||
.then((stored) => {
|
||||
if (revision === hydrationRevision) {
|
||||
selection = parseStoredWorkspaceSelection(stored);
|
||||
if (stored !== null && selection === null) {
|
||||
void storage.clear().catch(() => {});
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ export type { NavigateToWorkspaceInput } from "./navigation";
|
||||
const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = {
|
||||
read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY),
|
||||
write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value),
|
||||
clear: () => AsyncStorage.removeItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY),
|
||||
};
|
||||
|
||||
const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import { persist } from "zustand/middleware";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import {
|
||||
buildExplorerCheckoutKey,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
migratePanelState,
|
||||
PanelPersistedStateSchema,
|
||||
selectIsAgentListOpen,
|
||||
selectIsFileExplorerOpen,
|
||||
setMobilePanelTarget,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
type SortOption,
|
||||
} from "./state";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
||||
export type { ExplorerTab } from "../explorer-tab-memory";
|
||||
export type { ExplorerCheckoutContext } from "../explorer-checkout-context";
|
||||
export type {
|
||||
@@ -311,9 +313,8 @@ export const usePanelStore = create<PanelState>()(
|
||||
{
|
||||
name: "panel-state",
|
||||
version: 12,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
migrate: (persistedState, version) =>
|
||||
migratePanelState(persistedState, version, { isWeb }) as unknown as PanelState,
|
||||
storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema),
|
||||
migrate: (persistedState, version) => migratePanelState(persistedState, version, { isWeb }),
|
||||
partialize: (state) => ({
|
||||
desktop: state.desktop,
|
||||
explorerTab: state.explorerTab,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type ExplorerTab,
|
||||
} from "../explorer-tab-memory";
|
||||
import { type ExplorerCheckoutContext } from "../explorer-checkout-context";
|
||||
import { z } from "zod";
|
||||
|
||||
export type MobilePanelView = "agent" | "agent-list" | "file-explorer";
|
||||
|
||||
@@ -161,7 +162,37 @@ export function buildToggleFileExplorerPatch(
|
||||
return { desktop: { ...state.desktop, fileExplorerOpen: false } };
|
||||
}
|
||||
|
||||
type MigratablePanelState = Record<string, unknown>;
|
||||
const ExplorerTabSchema = z.enum(["changes", "files", "pr"]);
|
||||
const DesktopSidebarStorageSchema = z.strictObject({
|
||||
agentListOpen: z.boolean().optional(),
|
||||
fileExplorerOpen: z.boolean().optional(),
|
||||
focusModeEnabled: z.boolean().optional(),
|
||||
zoomed: z.boolean().optional(),
|
||||
focused: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const PanelPersistedStateSchema = z.strictObject({
|
||||
mobileView: z.enum(["agent", "agent-list", "file-explorer"]).optional(),
|
||||
mobilePanel: z
|
||||
.strictObject({
|
||||
target: z.enum(["agent", "agent-list", "file-explorer"]),
|
||||
revision: z.number().int().nonnegative(),
|
||||
})
|
||||
.optional(),
|
||||
desktop: DesktopSidebarStorageSchema.optional(),
|
||||
explorerTab: ExplorerTabSchema.optional(),
|
||||
explorerTabByCheckout: z.record(z.string(), ExplorerTabSchema).optional(),
|
||||
expandedPathsByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
|
||||
diffExpandedPathsByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
|
||||
diffCollapsedFoldersByWorkspace: z.record(z.string(), z.array(z.string())).optional(),
|
||||
sidebarWidth: z.number().optional(),
|
||||
explorerWidth: z.number().optional(),
|
||||
explorerSortOption: z.enum(["name", "modified", "size"]).optional(),
|
||||
explorerShowHiddenFiles: z.boolean().optional(),
|
||||
explorerFilesSplitRatio: z.number().optional(),
|
||||
});
|
||||
|
||||
type MigratablePanelState = z.infer<typeof PanelPersistedStateSchema>;
|
||||
|
||||
function migratePanelV2Explorer(state: MigratablePanelState, isWeb: boolean): void {
|
||||
if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) {
|
||||
@@ -193,7 +224,7 @@ function migratePanelExplorerTabByCheckout(state: MigratablePanelState, version:
|
||||
state.explorerTabByCheckout = {};
|
||||
return;
|
||||
}
|
||||
const entries = Object.entries(state.explorerTabByCheckout as Record<string, unknown>);
|
||||
const entries = Object.entries(state.explorerTabByCheckout);
|
||||
const next: Record<string, ExplorerTab> = {};
|
||||
for (const [key, value] of entries) {
|
||||
if (!isExplorerTab(value)) {
|
||||
@@ -205,7 +236,7 @@ function migratePanelExplorerTabByCheckout(state: MigratablePanelState, version:
|
||||
}
|
||||
|
||||
function migratePanelDesktopFocusMode(state: MigratablePanelState): void {
|
||||
const desktop = state.desktop as Record<string, unknown> | undefined;
|
||||
const desktop = state.desktop;
|
||||
if (!desktop) {
|
||||
return;
|
||||
}
|
||||
@@ -227,7 +258,8 @@ export function migratePanelState(
|
||||
version: number,
|
||||
options: { isWeb: boolean },
|
||||
): MigratablePanelState {
|
||||
const state = (persistedState ?? {}) as MigratablePanelState;
|
||||
const result = PanelPersistedStateSchema.safeParse(persistedState);
|
||||
const state: MigratablePanelState = result.success ? result.data : {};
|
||||
const { isWeb } = options;
|
||||
|
||||
if (version < 2) {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
||||
import {
|
||||
type CollapsedProjectsState,
|
||||
type PersistedCollapsedProjects,
|
||||
mergePersistedCollapsedProjects,
|
||||
PersistedCollapsedProjectsSchema,
|
||||
serializeCollapsedProjects,
|
||||
setProjectCollapsed,
|
||||
togglePinnedCollapsed,
|
||||
@@ -19,7 +22,7 @@ interface SidebarCollapsedSectionsState extends CollapsedProjectsState {
|
||||
}
|
||||
|
||||
export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsState>()(
|
||||
persist(
|
||||
persist<SidebarCollapsedSectionsState, [], [], PersistedCollapsedProjects>(
|
||||
(set) => ({
|
||||
collapsedProjectKeys: new Set(),
|
||||
collapsedStatusGroupKeys: new Set(),
|
||||
@@ -34,13 +37,10 @@ export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsS
|
||||
}),
|
||||
{
|
||||
name: "sidebar-collapsed-sections",
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
storage: createValidatedPersistStorage(AsyncStorage, PersistedCollapsedProjectsSchema),
|
||||
partialize: (state) => serializeCollapsedProjects(state),
|
||||
merge: (persistedState, currentState) =>
|
||||
mergePersistedCollapsedProjects(
|
||||
persistedState as { collapsedProjectKeys?: unknown } | undefined,
|
||||
currentState,
|
||||
),
|
||||
mergePersistedCollapsedProjects(persistedState, currentState),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -52,13 +52,13 @@ describe("sidebar collapsed projects transitions", () => {
|
||||
expect(restored.collapsedPinned).toBe(true);
|
||||
});
|
||||
|
||||
it("restores collapsed project keys from persisted preferences", () => {
|
||||
it("rejects the complete value when a persisted project key is invalid", () => {
|
||||
const restored = mergePersistedCollapsedProjects(
|
||||
{ collapsedProjectKeys: ["project-a", "project-b", 42] },
|
||||
emptyState(),
|
||||
);
|
||||
|
||||
expect(Array.from(restored.collapsedProjectKeys)).toEqual(["project-a", "project-b"]);
|
||||
expect(Array.from(restored.collapsedProjectKeys)).toEqual([]);
|
||||
expect(Array.from(restored.collapsedStatusGroupKeys)).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export interface CollapsedProjectsState {
|
||||
collapsedProjectKeys: Set<string>;
|
||||
collapsedStatusGroupKeys: Set<string>;
|
||||
@@ -5,11 +7,18 @@ export interface CollapsedProjectsState {
|
||||
}
|
||||
|
||||
export interface PersistedCollapsedProjects {
|
||||
collapsedProjectKeys?: unknown;
|
||||
collapsedStatusGroupKeys?: unknown;
|
||||
collapsedPinned?: unknown;
|
||||
collapsedProjectKeys?: string[];
|
||||
collapsedStatusGroupKeys?: string[];
|
||||
collapsedPinned?: boolean;
|
||||
}
|
||||
|
||||
export const PersistedCollapsedProjectsSchema: z.ZodType<PersistedCollapsedProjects> =
|
||||
z.strictObject({
|
||||
collapsedProjectKeys: z.array(z.string()).optional(),
|
||||
collapsedStatusGroupKeys: z.array(z.string()).optional(),
|
||||
collapsedPinned: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export function togglePinnedCollapsed(state: CollapsedProjectsState): CollapsedProjectsState {
|
||||
return { ...state, collapsedPinned: !state.collapsedPinned };
|
||||
}
|
||||
@@ -67,22 +76,21 @@ export function serializeCollapsedProjects(state: CollapsedProjectsState): {
|
||||
}
|
||||
|
||||
export function mergePersistedCollapsedProjects<S extends CollapsedProjectsState>(
|
||||
persisted: PersistedCollapsedProjects | undefined,
|
||||
persistedValue: unknown,
|
||||
current: S,
|
||||
): S {
|
||||
if (
|
||||
!persisted?.collapsedProjectKeys &&
|
||||
!persisted?.collapsedStatusGroupKeys &&
|
||||
persisted?.collapsedPinned === undefined
|
||||
) {
|
||||
const result = PersistedCollapsedProjectsSchema.safeParse(persistedValue);
|
||||
if (!result.success) {
|
||||
return current;
|
||||
}
|
||||
const restoredProjects = deserializeCollapsedKeys(persisted.collapsedProjectKeys);
|
||||
const restoredStatusGroups = deserializeCollapsedKeys(persisted.collapsedStatusGroupKeys);
|
||||
const restoredPinned =
|
||||
typeof persisted.collapsedPinned === "boolean"
|
||||
? persisted.collapsedPinned
|
||||
: current.collapsedPinned;
|
||||
const persisted = result.data;
|
||||
const restoredProjects = deserializeCollapsedKeys(
|
||||
persisted.collapsedProjectKeys ?? Array.from(current.collapsedProjectKeys),
|
||||
);
|
||||
const restoredStatusGroups = deserializeCollapsedKeys(
|
||||
persisted.collapsedStatusGroupKeys ?? Array.from(current.collapsedStatusGroupKeys),
|
||||
);
|
||||
const restoredPinned = persisted.collapsedPinned ?? current.collapsedPinned;
|
||||
if (
|
||||
areSetsEqual(current.collapsedProjectKeys, restoredProjects) &&
|
||||
areSetsEqual(current.collapsedStatusGroupKeys, restoredStatusGroups) &&
|
||||
@@ -98,11 +106,8 @@ export function mergePersistedCollapsedProjects<S extends CollapsedProjectsState
|
||||
};
|
||||
}
|
||||
|
||||
function deserializeCollapsedKeys(value: unknown): Set<string> {
|
||||
if (!Array.isArray(value)) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(value.filter((key): key is string => typeof key === "string"));
|
||||
function deserializeCollapsedKeys(value: string[]): Set<string> {
|
||||
return new Set(value);
|
||||
}
|
||||
|
||||
function areSetsEqual(left: Set<string>, right: Set<string>): boolean {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { z } from "zod";
|
||||
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
||||
|
||||
interface SidebarOrderStoreState {
|
||||
projectOrder: string[];
|
||||
@@ -18,6 +20,14 @@ interface SidebarOrderPersistedState {
|
||||
workspaceOrderByServerAndProject?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
const StringArrayRecordSchema = z.record(z.string(), z.array(z.string()));
|
||||
const SidebarOrderPersistedStateSchema = z.strictObject({
|
||||
projectOrder: z.array(z.string()).optional(),
|
||||
workspaceOrderByProject: StringArrayRecordSchema.optional(),
|
||||
projectOrderByServerId: StringArrayRecordSchema.optional(),
|
||||
workspaceOrderByServerAndProject: StringArrayRecordSchema.optional(),
|
||||
});
|
||||
|
||||
interface SidebarWorkspaceOrderScope {
|
||||
serverId: string;
|
||||
projectViewKey: string;
|
||||
@@ -71,11 +81,11 @@ export function migrateSidebarOrderState(persistedState: unknown): {
|
||||
projectOrder: string[];
|
||||
workspaceOrderByProject: Record<string, string[]>;
|
||||
} {
|
||||
const state = persistedState as SidebarOrderPersistedState | undefined;
|
||||
|
||||
if (!state) {
|
||||
const result = SidebarOrderPersistedStateSchema.safeParse(persistedState);
|
||||
if (!result.success) {
|
||||
return { projectOrder: [], workspaceOrderByProject: {} };
|
||||
}
|
||||
const state: SidebarOrderPersistedState = result.data;
|
||||
|
||||
const projectOrder = normalizeKeys(state.projectOrder ?? []);
|
||||
const seenProjects = new Set(projectOrder);
|
||||
@@ -135,7 +145,7 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
|
||||
}),
|
||||
{
|
||||
name: "sidebar-project-workspace-order",
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
storage: createValidatedPersistStorage(AsyncStorage, SidebarOrderPersistedStateSchema),
|
||||
partialize: (state) => ({
|
||||
projectOrder: state.projectOrder,
|
||||
workspaceOrderByProject: state.workspaceOrderByProject,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
|
||||
import { persist, type StateStorage } from "zustand/middleware";
|
||||
import { z } from "zod";
|
||||
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
||||
|
||||
export type SidebarGroupMode = "project" | "status";
|
||||
|
||||
@@ -23,51 +25,55 @@ interface SidebarViewPersistedState {
|
||||
hostFilters: string[];
|
||||
}
|
||||
|
||||
function isSidebarGroupMode(value: unknown): value is SidebarGroupMode {
|
||||
return value === "project" || value === "status";
|
||||
}
|
||||
const SidebarGroupModeSchema = z.enum(["project", "status"]);
|
||||
const SidebarViewPersistedStateSchema = z.strictObject({
|
||||
groupMode: SidebarGroupModeSchema.optional(),
|
||||
hostFilters: z.array(z.string()).optional(),
|
||||
hostFilter: z.string().nullable().optional(),
|
||||
groupModeByServerId: z.record(z.string(), SidebarGroupModeSchema).optional(),
|
||||
});
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
type SidebarViewStorageState = z.infer<typeof SidebarViewPersistedStateSchema>;
|
||||
|
||||
function readLegacyGroupMode(persistedState: Record<string, unknown>): SidebarGroupMode | null {
|
||||
function readLegacyGroupMode(persistedState: SidebarViewStorageState): SidebarGroupMode | null {
|
||||
const groupModeByServerId = persistedState.groupModeByServerId;
|
||||
if (!isRecord(groupModeByServerId)) {
|
||||
if (!groupModeByServerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modes = Object.values(groupModeByServerId).filter(isSidebarGroupMode);
|
||||
const modes = Object.values(groupModeByServerId);
|
||||
if (modes.length === 0) return null;
|
||||
return modes.includes("status") ? "status" : "project";
|
||||
}
|
||||
|
||||
// Reads the host filter from any persisted shape: the current `hostFilters` array, or the
|
||||
// pre-v2 single `hostFilter` string (null/absent meant "all hosts").
|
||||
function readHostFilters(persistedState: Record<string, unknown>): string[] {
|
||||
function readHostFilters(persistedState: SidebarViewStorageState): string[] {
|
||||
const hostFilters = persistedState.hostFilters;
|
||||
if (Array.isArray(hostFilters)) {
|
||||
return hostFilters.filter((value): value is string => typeof value === "string");
|
||||
if (hostFilters) {
|
||||
return hostFilters;
|
||||
}
|
||||
// COMPAT(sidebarHostFilters): added in v0.1.102, remove after 2026-12-30 once pre-v2 persisted
|
||||
// sidebar state (a single `hostFilter` string) has aged out.
|
||||
const legacyHostFilter = persistedState.hostFilter;
|
||||
return typeof legacyHostFilter === "string" ? [legacyHostFilter] : [];
|
||||
return legacyHostFilter ? [legacyHostFilter] : [];
|
||||
}
|
||||
|
||||
export function migrateSidebarViewState(persistedState: unknown): SidebarViewPersistedState {
|
||||
if (!isRecord(persistedState)) {
|
||||
const result = SidebarViewPersistedStateSchema.safeParse(persistedState);
|
||||
if (!result.success) {
|
||||
return { groupMode: "project", hostFilters: [] };
|
||||
}
|
||||
const state = result.data;
|
||||
|
||||
const legacyGroupMode = readLegacyGroupMode(persistedState);
|
||||
const legacyGroupMode = readLegacyGroupMode(state);
|
||||
if (legacyGroupMode) {
|
||||
return { groupMode: legacyGroupMode, hostFilters: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
groupMode: isSidebarGroupMode(persistedState.groupMode) ? persistedState.groupMode : "project",
|
||||
hostFilters: readHostFilters(persistedState),
|
||||
groupMode: state.groupMode ?? "project",
|
||||
hostFilters: readHostFilters(state),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,7 +122,10 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()(
|
||||
{
|
||||
name: SIDEBAR_VIEW_STORAGE_KEY,
|
||||
version: SIDEBAR_VIEW_STORE_VERSION,
|
||||
storage: createJSONStorage(createSidebarViewStorage),
|
||||
storage: createValidatedPersistStorage(
|
||||
createSidebarViewStorage(),
|
||||
SidebarViewPersistedStateSchema,
|
||||
),
|
||||
partialize: (state) => ({
|
||||
groupMode: state.groupMode,
|
||||
hostFilters: state.hostFilters,
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
};
|
||||
});
|
||||
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import {
|
||||
collectAllPanes,
|
||||
@@ -316,6 +317,24 @@ describe("workspace-layout-store actions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps layouts written before split sizes were persisted", async () => {
|
||||
const legacyLayout = createDefaultLayout();
|
||||
await AsyncStorage.setItem(
|
||||
"workspace-layout-state",
|
||||
JSON.stringify({
|
||||
state: { layoutByWorkspace: { legacy: legacyLayout } },
|
||||
version: 1,
|
||||
}),
|
||||
);
|
||||
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
|
||||
|
||||
await restored.persist.rehydrate();
|
||||
|
||||
expect(restored.getState().layoutByWorkspace.legacy).toEqual(legacyLayout);
|
||||
expect(restored.getState().splitSizesByWorkspace).toEqual({});
|
||||
await expect(AsyncStorage.getItem("workspace-layout-state")).resolves.not.toBeNull();
|
||||
});
|
||||
|
||||
it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => {
|
||||
useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useEffect, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { z } from "zod";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import {
|
||||
defaultWorkspaceLayoutIds,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
type WorkspaceLayout,
|
||||
} from "@/stores/workspace-layout-actions";
|
||||
import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity";
|
||||
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
||||
|
||||
export {
|
||||
collectAllPanes,
|
||||
@@ -123,6 +125,83 @@ interface WorkspaceFocusRestorationState {
|
||||
|
||||
const MAX_TREE_DEPTH = 4;
|
||||
|
||||
const WorkspaceDraftTabSetupStorageSchema = z.strictObject({
|
||||
provider: z.string(),
|
||||
cwd: z.string(),
|
||||
modeId: z.string().nullable(),
|
||||
model: z.string().nullable(),
|
||||
thinkingOptionId: z.string().nullable(),
|
||||
featureValues: z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])),
|
||||
});
|
||||
const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
|
||||
z.strictObject({
|
||||
kind: z.literal("draft"),
|
||||
draftId: z.string(),
|
||||
setup: WorkspaceDraftTabSetupStorageSchema.optional(),
|
||||
}),
|
||||
z.strictObject({ kind: z.literal("agent"), agentId: z.string() }),
|
||||
z.strictObject({
|
||||
kind: z.literal("provider_subagent"),
|
||||
parentAgentId: z.string(),
|
||||
subagentId: z.string(),
|
||||
}),
|
||||
z.strictObject({ kind: z.literal("terminal"), terminalId: z.string() }),
|
||||
z.strictObject({ kind: z.literal("browser"), browserId: z.string() }),
|
||||
z.strictObject({
|
||||
kind: z.literal("file"),
|
||||
path: z.string(),
|
||||
lineStart: z.number().int().positive().optional(),
|
||||
lineEnd: z.number().int().positive().optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("working_diff"),
|
||||
focusPath: z.string().optional(),
|
||||
focusRequestId: z.number().optional(),
|
||||
// COMPAT(workingDiffTarget): accepted from pre-canonical tab ids; normalization removes them.
|
||||
mode: z.enum(["uncommitted", "base"]).optional(),
|
||||
baseRef: z.string().nullable().optional(),
|
||||
ignoreWhitespace: z.boolean().optional(),
|
||||
}),
|
||||
z.strictObject({ kind: z.literal("setup"), workspaceId: z.string() }),
|
||||
z.strictObject({ kind: z.literal("commit_diff"), sha: z.string() }),
|
||||
]);
|
||||
const WorkspaceTabStorageSchema = z.strictObject({
|
||||
tabId: z.string(),
|
||||
target: WorkspaceTabTargetStorageSchema,
|
||||
createdAt: z.number(),
|
||||
});
|
||||
const SplitNodeStorageSchema: z.ZodType<SplitNode> = z.lazy(() =>
|
||||
z.discriminatedUnion("kind", [
|
||||
z.strictObject({
|
||||
kind: z.literal("pane"),
|
||||
pane: z.strictObject({
|
||||
id: z.string(),
|
||||
tabIds: z.array(z.string()),
|
||||
focusedTabId: z.string().nullable(),
|
||||
tabs: z.array(WorkspaceTabStorageSchema).optional(),
|
||||
}),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("group"),
|
||||
group: z.strictObject({
|
||||
id: z.string(),
|
||||
direction: z.enum(["horizontal", "vertical"]),
|
||||
children: z.array(SplitNodeStorageSchema),
|
||||
sizes: z.array(z.number()),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const WorkspaceLayoutStorageSchema: z.ZodType<WorkspaceLayout> = z.strictObject({
|
||||
root: SplitNodeStorageSchema,
|
||||
focusedPaneId: z.string().nullable(),
|
||||
parentTabIdByTabId: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
const WorkspaceLayoutPersistedStateSchema = z.strictObject({
|
||||
layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema),
|
||||
splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(),
|
||||
});
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
@@ -942,7 +1021,7 @@ export function createWorkspaceLayoutStore(
|
||||
{
|
||||
name: "workspace-layout-state",
|
||||
version: 1,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
storage: createValidatedPersistStorage(AsyncStorage, WorkspaceLayoutPersistedStateSchema),
|
||||
partialize: (state) => {
|
||||
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
||||
for (const key in state.layoutByWorkspace) {
|
||||
@@ -957,6 +1036,17 @@ export function createWorkspaceLayoutStore(
|
||||
splitSizesByWorkspace: state.splitSizesByWorkspace,
|
||||
};
|
||||
},
|
||||
merge: (persistedState, currentState) => {
|
||||
const result = WorkspaceLayoutPersistedStateSchema.safeParse(persistedState);
|
||||
if (!result.success) {
|
||||
return currentState;
|
||||
}
|
||||
return {
|
||||
...currentState,
|
||||
layoutByWorkspace: result.data.layoutByWorkspace,
|
||||
splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {},
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user