mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58d06f511f | ||
|
|
c575464e8f | ||
|
|
9a0b95aa10 | ||
|
|
9be1547741 |
+1
-1
@@ -1 +1 @@
|
||||
sha256-tSE4nb4E06GFZ8I9UEbo93nlPiAKtW00nkywgKQmMhI=
|
||||
sha256-ObBdUmNSW+vDjzA7EbCDfZrB0nYDmcWmWe+UCoTMh/M=
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo, resolveTempRoot } from "../support/helpers/workspace";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
// The compact toolbar flattens every git action into one menu, and the policy keeps the actions
|
||||
// it can promote to `primary` in `secondary` as well. A promoted action must still show once.
|
||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const task of cleanupTasks.splice(0)) {
|
||||
await task();
|
||||
}
|
||||
});
|
||||
|
||||
async function createRemoteRepo(prefix: string): Promise<string> {
|
||||
const repo = await createTempGitRepo(prefix, {
|
||||
withRemote: true,
|
||||
files: [{ path: "src/app.ts", content: "export const app = 1;\n" }],
|
||||
});
|
||||
cleanupTasks.push(repo.cleanup);
|
||||
// The helper puts the bare remote inside the checkout; keep it out of the status.
|
||||
await writeFile(path.join(repo.path, ".git/info/exclude"), "remote.git/\n");
|
||||
return repo.path;
|
||||
}
|
||||
|
||||
async function registerWorkspace(repoPath: string): Promise<string> {
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push(async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
});
|
||||
const created = await client.createWorkspace({ source: { kind: "directory", path: repoPath } });
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${repoPath}`);
|
||||
}
|
||||
return created.workspace.id;
|
||||
}
|
||||
|
||||
async function openCompactActionsMenu(page: Page, workspaceId: string) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
|
||||
await page.reload();
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
|
||||
await expect(changesTab).toBeVisible({ timeout: 30_000 });
|
||||
await changesTab.click();
|
||||
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const trigger = explorer.getByTestId("changes-actions-menu-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
const menu = page.getByTestId("changes-primary-cta-menu");
|
||||
await expect(menu).toBeVisible();
|
||||
return menu;
|
||||
}
|
||||
|
||||
test("compact git actions menu lists a promoted Pull once", async ({ page }) => {
|
||||
const repoPath = await createRemoteRepo("compact-menu-pull-");
|
||||
cleanupTasks.push(async () => {
|
||||
execFileSync("rm", ["-rf", repoPath]);
|
||||
});
|
||||
|
||||
// Advance origin so the clean checkout is behind it and Pull is the promoted action.
|
||||
const other = await mkdtemp(path.join(await resolveTempRoot(), "compact-menu-other-"));
|
||||
cleanupTasks.push(async () => {
|
||||
execFileSync("rm", ["-rf", other]);
|
||||
});
|
||||
execFileSync("git", ["clone", path.join(repoPath, "remote.git"), other], { stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "e2e@paseo.test"], { cwd: other, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Paseo E2E"], { cwd: other, stdio: "ignore" });
|
||||
await writeFile(path.join(other, "src/incoming.ts"), "export const incoming = 1;\n");
|
||||
execFileSync("git", ["add", "--all"], { cwd: other, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Incoming commit"], { cwd: other, stdio: "ignore" });
|
||||
execFileSync("git", ["push", "origin", "main"], { cwd: other, stdio: "ignore" });
|
||||
execFileSync("git", ["fetch", "origin"], { cwd: repoPath, stdio: "ignore" });
|
||||
|
||||
const workspaceId = await registerWorkspace(repoPath);
|
||||
const menu = await openCompactActionsMenu(page, workspaceId);
|
||||
|
||||
await expect(menu.getByTestId("changes-menu-pull")).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"Pull",
|
||||
"Push",
|
||||
"Pull and push",
|
||||
"Archive workspace",
|
||||
]);
|
||||
});
|
||||
|
||||
test("compact git actions menu lists a promoted Push once", async ({ page }) => {
|
||||
const repoPath = await createRemoteRepo("compact-menu-push-");
|
||||
cleanupTasks.push(async () => {
|
||||
execFileSync("rm", ["-rf", repoPath]);
|
||||
});
|
||||
const workspaceId = await registerWorkspace(repoPath);
|
||||
|
||||
// Commit so the clean checkout is ahead of origin and Push is the promoted action.
|
||||
await writeFile(path.join(repoPath, "src/mine.ts"), "export const mine = 1;\n");
|
||||
execFileSync("git", ["add", "--all"], { cwd: repoPath, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Local commit"], { cwd: repoPath, stdio: "ignore" });
|
||||
|
||||
const menu = await openCompactActionsMenu(page, workspaceId);
|
||||
|
||||
await expect(menu.getByTestId("changes-menu-push")).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"Push",
|
||||
"Pull",
|
||||
"Pull and push",
|
||||
"Archive workspace",
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
|
||||
|
||||
const WHITE = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
const BLACK = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
|
||||
function embeddedImage(page: Page): Locator {
|
||||
return page.getByRole("button", { name: "hero", exact: true });
|
||||
}
|
||||
|
||||
function displayedPixel(image: Locator): Promise<number[]> {
|
||||
return image.evaluate((element: HTMLImageElement) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 1;
|
||||
const context = canvas.getContext("2d")!;
|
||||
context.drawImage(element, 0, 0, 1, 1);
|
||||
return Array.from(context.getImageData(0, 0, 1, 1).data);
|
||||
});
|
||||
}
|
||||
|
||||
async function createGuide(repoPath: string): Promise<void> {
|
||||
await writeFile(path.join(repoPath, "hero.png"), WHITE);
|
||||
await writeFile(path.join(repoPath, "guide.md"), "# Guide\n\n\n");
|
||||
}
|
||||
|
||||
async function openGuidePreview(page: Page): Promise<void> {
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, "guide.md");
|
||||
await expect.poll(() => displayedPixel(embeddedImage(page))).toEqual([255, 255, 255, 255]);
|
||||
}
|
||||
|
||||
async function replaceEmbeddedImage(repoPath: string): Promise<void> {
|
||||
const imagePath = path.join(repoPath, "hero.png");
|
||||
await writeFile(imagePath, BLACK);
|
||||
expect(await readFile(imagePath)).toEqual(BLACK);
|
||||
}
|
||||
|
||||
async function expectReplacementInPreview(page: Page): Promise<void> {
|
||||
await expect
|
||||
.configure({ soft: true })
|
||||
.poll(() => displayedPixel(embeddedImage(page)), { timeout: 5000 })
|
||||
.toEqual([0, 0, 0, 255]);
|
||||
}
|
||||
|
||||
async function remountMarkdownPreview(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Source", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Preview", exact: true }).click();
|
||||
await expect.poll(() => displayedPixel(embeddedImage(page))).toEqual([0, 0, 0, 255]);
|
||||
}
|
||||
|
||||
async function expectReplacementWhenOpenedDirectly(page: Page): Promise<void> {
|
||||
await openFileFromExplorer(page, "hero.png");
|
||||
// The standalone image viewer has no accessible name for its image.
|
||||
const image = page.getByTestId("image-file-preview-canvas").locator("img");
|
||||
await expect.poll(() => displayedPixel(image)).toEqual([0, 0, 0, 255]);
|
||||
}
|
||||
|
||||
test("embedded Markdown image reflects a file replacement while Preview stays open", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "markdown-image-refresh-" });
|
||||
await createGuide(workspace.repoPath);
|
||||
await workspace.navigateTo();
|
||||
await openGuidePreview(page);
|
||||
const originalSource = await embeddedImage(page).getAttribute("src");
|
||||
|
||||
await replaceEmbeddedImage(workspace.repoPath);
|
||||
await expectReplacementInPreview(page);
|
||||
await test.info().attach("replacement-observation", {
|
||||
body: JSON.stringify({
|
||||
displayedPixel: await displayedPixel(embeddedImage(page)),
|
||||
sourceUnchanged: originalSource === (await embeddedImage(page).getAttribute("src")),
|
||||
replacementBytesOnDisk: (await readFile(path.join(workspace.repoPath, "hero.png"))).equals(
|
||||
BLACK,
|
||||
),
|
||||
}),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await remountMarkdownPreview(page);
|
||||
await expectReplacementWhenOpenedDirectly(page);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GitAction, GitActions } from "@/git/policy";
|
||||
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import type {
|
||||
WorkspacePanelPlacement,
|
||||
@@ -581,9 +581,7 @@ export function buildWorkspaceCommandCenterContributions(
|
||||
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
|
||||
];
|
||||
|
||||
const primary = source.gitActions.primary;
|
||||
for (const [index, action] of source.gitActions.secondary.entries()) {
|
||||
if (action.id === primary?.id) continue;
|
||||
for (const [index, action] of actionsWithoutPrimary(source.gitActions).entries()) {
|
||||
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import type { GitAction, GitActions } from "@/git/policy";
|
||||
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
|
||||
import { useGitActionRunner } from "@/git/use-actions";
|
||||
import { buttonControlHeight, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
|
||||
@@ -129,14 +129,10 @@ export function GitActionsSplitButton({
|
||||
[theme.colors.surface2],
|
||||
);
|
||||
|
||||
const menuOnlyActions = useMemo(
|
||||
() => [
|
||||
...(gitActions.primary ? [gitActions.primary] : []),
|
||||
...gitActions.secondary,
|
||||
...gitActions.menu,
|
||||
],
|
||||
[gitActions.menu, gitActions.primary, gitActions.secondary],
|
||||
);
|
||||
const menuOnlyActions = [
|
||||
...(gitActions.primary ? [gitActions.primary] : []),
|
||||
...actionsWithoutPrimary(gitActions),
|
||||
];
|
||||
|
||||
if (menuOnly) {
|
||||
if (menuOnlyActions.length === 0) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
import { buildGitActions, type BuildGitActionsInput } from "./policy";
|
||||
import { actionsWithoutPrimary, buildGitActions, type BuildGitActionsInput } from "./policy";
|
||||
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
|
||||
|
||||
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
|
||||
@@ -190,6 +190,59 @@ describe("git-actions-policy", () => {
|
||||
expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" });
|
||||
});
|
||||
|
||||
it("drops a promoted remote action from the remaining menu actions", () => {
|
||||
const pull = buildGitActions(createInput({ hasRemote: true, behindOfOrigin: 2 }));
|
||||
expect(pull.primary?.id).toBe("pull");
|
||||
expect(actionsWithoutPrimary(pull).map((action) => action.id)).toEqual([
|
||||
"push",
|
||||
"pull-and-push",
|
||||
"archive-workspace",
|
||||
]);
|
||||
|
||||
const push = buildGitActions(createInput({ hasRemote: true, aheadOfOrigin: 1 }));
|
||||
expect(push.primary?.id).toBe("push");
|
||||
expect(actionsWithoutPrimary(push).map((action) => action.id)).toEqual([
|
||||
"pull",
|
||||
"pull-and-push",
|
||||
"archive-workspace",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops a promoted merge action from the remaining menu actions", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
hasRemote: true,
|
||||
isOnBaseBranch: false,
|
||||
aheadCount: 2,
|
||||
hasPullRequest: true,
|
||||
pullRequestUrl: "https://example.com/pr/456",
|
||||
pullRequestState: "open",
|
||||
pullRequestMergeable: "MERGEABLE",
|
||||
pullRequestGithub: githubStatus({
|
||||
repository: {
|
||||
autoMergeAllowed: true,
|
||||
mergeCommitAllowed: true,
|
||||
squashMergeAllowed: false,
|
||||
rebaseMergeAllowed: false,
|
||||
viewerDefaultMergeMethod: "SQUASH",
|
||||
},
|
||||
}),
|
||||
shipDefault: "pr",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(actions.primary).toMatchObject({ id: "merge-pr-merge", label: "Merge PR (merge)" });
|
||||
expect(actionsWithoutPrimary(actions).map((action) => action.id)).toEqual([
|
||||
"pull",
|
||||
"push",
|
||||
"pull-and-push",
|
||||
"merge-from-base",
|
||||
"merge-branch",
|
||||
"pr",
|
||||
"archive-workspace",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps push clickable with a clearer message when the branch diverged", () => {
|
||||
const actions = buildGitActions(
|
||||
createInput({
|
||||
|
||||
@@ -312,6 +312,18 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions a flattened list shows after the primary. `secondary` keeps the actions the policy
|
||||
* can promote to `primary`, and the translator rebuilds those objects, so a flattening consumer
|
||||
* has to drop the promoted one by id or it renders it twice.
|
||||
*/
|
||||
export function actionsWithoutPrimary(gitActions: GitActions): GitAction[] {
|
||||
const primaryActionId = gitActions.primary?.id;
|
||||
return [...gitActions.secondary, ...gitActions.menu].filter(
|
||||
(action) => action.id !== primaryActionId,
|
||||
);
|
||||
}
|
||||
|
||||
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
|
||||
if (input.shouldPromoteArchive) {
|
||||
return "archive-workspace";
|
||||
|
||||
@@ -63,3 +63,66 @@ describe("createValidatedPersistStorage", () => {
|
||||
expect(backing.values.has("settings")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips repeated immutable states only when requested", async () => {
|
||||
let writes = 0;
|
||||
const backing: StateStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
writes += 1;
|
||||
},
|
||||
removeItem: () => {},
|
||||
};
|
||||
const value = { state: { enabled: true }, version: 1 };
|
||||
const regular = createValidatedPersistStorage(backing, StateSchema);
|
||||
await regular.setItem("regular", value);
|
||||
await regular.setItem("regular", value);
|
||||
expect(writes).toBe(2);
|
||||
|
||||
const deduplicated = createValidatedPersistStorage(backing, StateSchema, { skipUnchanged: true });
|
||||
await deduplicated.setItem("first", value);
|
||||
await deduplicated.setItem("first", { ...value });
|
||||
expect(writes).toBe(3);
|
||||
await deduplicated.setItem("second", value);
|
||||
expect(writes).toBe(4);
|
||||
await deduplicated.setItem("first", { ...value, version: 2 });
|
||||
expect(writes).toBe(5);
|
||||
});
|
||||
|
||||
it("retries an unchanged state after a failed save", async () => {
|
||||
let attempts = 0;
|
||||
const backing = new MemoryStorage();
|
||||
const storage = createValidatedPersistStorage(
|
||||
{
|
||||
getItem: (name) => backing.getItem(name),
|
||||
setItem: (name, value) => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) return Promise.reject(new Error("storage unavailable"));
|
||||
backing.setItem(name, value);
|
||||
},
|
||||
removeItem: (name) => backing.removeItem(name),
|
||||
},
|
||||
StateSchema,
|
||||
{ skipUnchanged: true },
|
||||
);
|
||||
const value = { state: { enabled: true }, version: 1 };
|
||||
await expect(storage.setItem("layout", value)).rejects.toThrow("storage unavailable");
|
||||
await storage.setItem("layout", value);
|
||||
expect(attempts).toBe(2);
|
||||
expect(backing.getItem("layout")).toBe(JSON.stringify(value));
|
||||
});
|
||||
|
||||
it("saves the same state again after removal or rehydration clears invalid storage", async () => {
|
||||
const backing = new MemoryStorage();
|
||||
const storage = createValidatedPersistStorage(backing, StateSchema, { skipUnchanged: true });
|
||||
const value = { state: { enabled: true }, version: 1 };
|
||||
await storage.setItem("layout", value);
|
||||
await storage.removeItem("layout");
|
||||
await storage.setItem("layout", value);
|
||||
expect(backing.getItem("layout")).toBe(JSON.stringify(value));
|
||||
|
||||
backing.values.set("layout", "{");
|
||||
await expect(storage.getItem("layout")).resolves.toBeNull();
|
||||
await storage.setItem("layout", value);
|
||||
expect(backing.getItem("layout")).toBe(JSON.stringify(value));
|
||||
});
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { z } from "zod";
|
||||
import type { PersistStorage, StateStorage } from "zustand/middleware";
|
||||
import type { PersistStorage, StateStorage, StorageValue } from "zustand/middleware";
|
||||
|
||||
export function createValidatedPersistStorage<State>(
|
||||
backingStorage: StateStorage,
|
||||
stateSchema: z.ZodType<State>,
|
||||
// Opt in only when callers preserve state references until values change.
|
||||
options: { skipUnchanged?: boolean } = {},
|
||||
): PersistStorage<State> {
|
||||
const envelopeSchema = z.strictObject({
|
||||
state: stateSchema,
|
||||
version: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
const accepted = new Map<string, StorageValue<State>>();
|
||||
|
||||
return {
|
||||
getItem: async (name) => {
|
||||
accepted.delete(name);
|
||||
const raw = await backingStorage.getItem(name);
|
||||
if (raw === null) return null;
|
||||
|
||||
@@ -31,13 +36,31 @@ export function createValidatedPersistStorage<State>(
|
||||
return result.data;
|
||||
},
|
||||
setItem: async (name, value) => {
|
||||
const previous = accepted.get(name);
|
||||
if (
|
||||
options.skipUnchanged &&
|
||||
previous &&
|
||||
previous.state === value.state &&
|
||||
previous.version === value.version
|
||||
)
|
||||
return;
|
||||
const result = envelopeSchema.safeParse(value);
|
||||
if (!result.success) {
|
||||
accepted.delete(name);
|
||||
await backingStorage.removeItem(name);
|
||||
return;
|
||||
}
|
||||
await backingStorage.setItem(name, JSON.stringify(result.data));
|
||||
if (options.skipUnchanged) accepted.set(name, value);
|
||||
try {
|
||||
await backingStorage.setItem(name, JSON.stringify(result.data));
|
||||
} catch (error) {
|
||||
if (accepted.get(name) === value) accepted.delete(name);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
removeItem: (name) => {
|
||||
accepted.delete(name);
|
||||
return backingStorage.removeItem(name);
|
||||
},
|
||||
removeItem: (name) => backingStorage.removeItem(name),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { indexedDB, IDBKeyRange } from "fake-indexeddb";
|
||||
import { expect, it } from "vitest";
|
||||
import type { StateStorage } from "zustand/middleware";
|
||||
import { createWorkspaceLayoutStore } from "./workspace-layout-store";
|
||||
|
||||
Object.assign(globalThis, { indexedDB, IDBKeyRange });
|
||||
|
||||
it("skips unchanged reconciliations and immediately saves real layout changes", async () => {
|
||||
const values = new Map<string, string>();
|
||||
let writes = 0;
|
||||
const storage: StateStorage = {
|
||||
getItem: (name) => values.get(name) ?? null,
|
||||
setItem: (name, value) => {
|
||||
writes += 1;
|
||||
values.set(name, value);
|
||||
},
|
||||
removeItem: (name) => {
|
||||
values.delete(name);
|
||||
},
|
||||
};
|
||||
const store = createWorkspaceLayoutStore(undefined, storage);
|
||||
await store.persist.rehydrate();
|
||||
const workspaceKey = "server:workspace";
|
||||
const openAgent = (agentId: string) =>
|
||||
store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId },
|
||||
intent: "reveal",
|
||||
});
|
||||
openAgent("agent-1");
|
||||
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||
const writesBefore = writes;
|
||||
expect(writesBefore).toBe(1);
|
||||
for (let index = 0; index < 50; index += 1) {
|
||||
store.getState().reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: ["agent-1"],
|
||||
autoOpenAgentIds: ["agent-1"],
|
||||
knownTerminalIds: [],
|
||||
standaloneTerminalIds: [],
|
||||
});
|
||||
}
|
||||
expect(store.getState().layoutByWorkspace[workspaceKey]).toBe(layout);
|
||||
expect(writes).toBe(writesBefore);
|
||||
|
||||
openAgent("agent-2");
|
||||
expect(writes).toBe(writesBefore + 1);
|
||||
const restored = createWorkspaceLayoutStore(undefined, storage);
|
||||
await restored.persist.rehydrate();
|
||||
expect(restored.getState().layoutByWorkspace[workspaceKey]).toEqual(
|
||||
store.getState().layoutByWorkspace[workspaceKey],
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useEffect, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { persist, type StateStorage } from "zustand/middleware";
|
||||
import type { z } from "zod";
|
||||
import { WorkspaceLayoutPersistedStateSchema } from "./workspace-layout-storage";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
@@ -224,6 +224,7 @@ const MAX_TREE_DEPTH = 5;
|
||||
|
||||
const LEGACY_EXPLORER_SIDEBAR_REFERENCE_WIDTH = 1440;
|
||||
const WORKSPACE_LAYOUT_PERSIST_VERSION = 2;
|
||||
type WorkspaceLayoutPersistedState = z.infer<typeof WorkspaceLayoutPersistedStateSchema>;
|
||||
|
||||
function convertLegacyExplorerSidebarRatios(
|
||||
ratiosByWorkspace: Record<string, number>,
|
||||
@@ -939,8 +940,57 @@ function createExplorerSidebarPane(
|
||||
return targetPaneId ? splitPaneEmpty(workspaceKey, { targetPaneId, position: "right" }) : null;
|
||||
}
|
||||
|
||||
const PERSISTED_SLICES = [
|
||||
"layoutByWorkspace",
|
||||
"pinnedAgentIdsByWorkspace",
|
||||
"splitSizesByWorkspace",
|
||||
"explorerSidebarWidthByWorkspace",
|
||||
"explorerSidebarPaneIdByWorkspace",
|
||||
"sidePaneIdByWorkspace",
|
||||
"pullRequestTabAutoOpenedByWorkspace",
|
||||
] as const;
|
||||
|
||||
// Zustand persist re-runs partialize after every set, including ones that return the state
|
||||
// untouched. Reusing the previous projection while every persisted slice is the same
|
||||
// reference lets validated storage skip unchanged writes before validation and serialization.
|
||||
function createPersistedStateProjection() {
|
||||
let previous: { source: WorkspaceLayoutStore; state: WorkspaceLayoutPersistedState } | null =
|
||||
null;
|
||||
return (source: WorkspaceLayoutStore): WorkspaceLayoutPersistedState => {
|
||||
const last = previous;
|
||||
if (last && PERSISTED_SLICES.every((slice) => last.source[slice] === source[slice])) {
|
||||
return last.state;
|
||||
}
|
||||
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
||||
for (const key in source.layoutByWorkspace) {
|
||||
// Strip ephemeral (commit diff) tabs before persisting so they are
|
||||
// dropped on reload rather than restored pointing at a rebased SHA.
|
||||
layoutByWorkspace[key] = stripEphemeralTabsFromLayout(
|
||||
normalizeLayout(source.layoutByWorkspace[key]),
|
||||
);
|
||||
}
|
||||
const state: WorkspaceLayoutPersistedState = {
|
||||
layoutByWorkspace,
|
||||
pinnedAgentIdsByWorkspace: Object.fromEntries(
|
||||
Object.entries(source.pinnedAgentIdsByWorkspace).map(([key, agentIds]) => [
|
||||
key,
|
||||
Array.from(agentIds),
|
||||
]),
|
||||
),
|
||||
splitSizesByWorkspace: source.splitSizesByWorkspace,
|
||||
explorerSidebarWidthByWorkspace: source.explorerSidebarWidthByWorkspace,
|
||||
explorerPaneIdByWorkspace: source.explorerSidebarPaneIdByWorkspace,
|
||||
sidePaneIdByWorkspace: source.sidePaneIdByWorkspace,
|
||||
pullRequestTabAutoOpenedByWorkspace: source.pullRequestTabAutoOpenedByWorkspace,
|
||||
};
|
||||
previous = { source, state };
|
||||
return state;
|
||||
};
|
||||
}
|
||||
|
||||
export function createWorkspaceLayoutStore(
|
||||
ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds,
|
||||
storage: StateStorage = AsyncStorage,
|
||||
) {
|
||||
return create<WorkspaceLayoutStore>()(
|
||||
persist(
|
||||
@@ -2117,33 +2167,12 @@ export function createWorkspaceLayoutStore(
|
||||
{
|
||||
name: "workspace-layout-state",
|
||||
version: WORKSPACE_LAYOUT_PERSIST_VERSION,
|
||||
storage: createValidatedPersistStorage(AsyncStorage, WorkspaceLayoutPersistedStateSchema),
|
||||
storage: createValidatedPersistStorage(storage, WorkspaceLayoutPersistedStateSchema, {
|
||||
skipUnchanged: true,
|
||||
}),
|
||||
migrate: (persistedState, version) =>
|
||||
migrateWorkspaceLayoutPersistedState(persistedState, version, ids),
|
||||
partialize: (state) => {
|
||||
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
|
||||
for (const key in state.layoutByWorkspace) {
|
||||
// Strip ephemeral (commit diff) tabs before persisting so they are
|
||||
// dropped on reload rather than restored pointing at a rebased SHA.
|
||||
layoutByWorkspace[key] = stripEphemeralTabsFromLayout(
|
||||
normalizeLayout(state.layoutByWorkspace[key]),
|
||||
);
|
||||
}
|
||||
return {
|
||||
layoutByWorkspace,
|
||||
pinnedAgentIdsByWorkspace: Object.fromEntries(
|
||||
Object.entries(state.pinnedAgentIdsByWorkspace).map(([key, agentIds]) => [
|
||||
key,
|
||||
Array.from(agentIds),
|
||||
]),
|
||||
),
|
||||
splitSizesByWorkspace: state.splitSizesByWorkspace,
|
||||
explorerSidebarWidthByWorkspace: state.explorerSidebarWidthByWorkspace,
|
||||
explorerPaneIdByWorkspace: state.explorerSidebarPaneIdByWorkspace,
|
||||
sidePaneIdByWorkspace: state.sidePaneIdByWorkspace,
|
||||
pullRequestTabAutoOpenedByWorkspace: state.pullRequestTabAutoOpenedByWorkspace,
|
||||
};
|
||||
},
|
||||
partialize: createPersistedStateProjection(),
|
||||
merge: (persistedState, currentState) => {
|
||||
const result = WorkspaceLayoutPersistedStateSchema.safeParse(persistedState);
|
||||
if (!result.success) {
|
||||
|
||||
Reference in New Issue
Block a user