feat(maestro): hang draft state off layout leaves, never persisted (#57)
This commit is contained in:
+17
-2
@@ -11,8 +11,23 @@ import type { Uuid } from "./types.js";
|
||||
|
||||
export type Dir = "row" | "col";
|
||||
|
||||
/** Half-filled spawn-form state for a draft pane (issue #57): an empty leaf
|
||||
* renders the spawn form instead of a placeholder, and each pane's values
|
||||
* hang off its own leaf so two drafts stay independent. Never persisted —
|
||||
* a reload gives back an empty form (persistence strips it). */
|
||||
export interface Draft {
|
||||
command: string;
|
||||
cwd: string;
|
||||
name: string;
|
||||
worktreeEnabled?: boolean;
|
||||
worktreeBranch?: string;
|
||||
worktreeCleanup?: boolean;
|
||||
/** Whether the cwd lives in the raw text input (vs a picked recent row). */
|
||||
cwdRawVisible?: boolean;
|
||||
}
|
||||
|
||||
export type Node =
|
||||
| { kind: "leaf"; id: string; sessionId: Uuid | null }
|
||||
| { kind: "leaf"; id: string; sessionId: Uuid | null; draft?: Draft | null }
|
||||
| { kind: "split"; dir: Dir; a: Node; b: Node; ratio: number };
|
||||
|
||||
export type Leaf = Extract<Node, { kind: "leaf" }>;
|
||||
@@ -89,7 +104,7 @@ export function splitLeaf(
|
||||
dir: Dir,
|
||||
after: boolean,
|
||||
): string {
|
||||
const fresh: Leaf = { kind: "leaf", id: freshId("l"), sessionId };
|
||||
const fresh: Leaf = { kind: "leaf", id: freshId("l"), sessionId, draft: null };
|
||||
const rec = (n: Node): Node => {
|
||||
if (n.kind === "leaf") {
|
||||
if (n.id !== targetId) return n;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hydrate } from "./persistence.js";
|
||||
import { leaves } from "./layout.js";
|
||||
import { hydrate, serializeLayout } from "./persistence.js";
|
||||
import { leaves, type Layout } from "./layout.js";
|
||||
|
||||
describe("hydrate", () => {
|
||||
it("rejects anything that is not a tab-bearing object", () => {
|
||||
@@ -61,4 +61,42 @@ describe("hydrate", () => {
|
||||
});
|
||||
expect(leaves(layout!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s"]);
|
||||
});
|
||||
|
||||
it("never restores draft state, even if a stale blob carries it", () => {
|
||||
const layout = hydrate({
|
||||
tabs: [
|
||||
{ id: "t", root: { kind: "leaf", id: "l1", sessionId: null, draft: { command: "zsh", cwd: "/x" } } },
|
||||
],
|
||||
activeTabId: "t",
|
||||
});
|
||||
expect(leaves(layout!.tabs[0].root)[0]).not.toHaveProperty("draft");
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeLayout", () => {
|
||||
it("strips draft state so a reload gives back an empty form", () => {
|
||||
const layout = hydrate({
|
||||
tabs: [
|
||||
{
|
||||
id: "t",
|
||||
root: {
|
||||
kind: "split",
|
||||
dir: "row",
|
||||
a: { kind: "leaf", id: "l1", sessionId: "s1" },
|
||||
b: { kind: "leaf", id: "l2", sessionId: null },
|
||||
},
|
||||
},
|
||||
],
|
||||
activeTabId: "t",
|
||||
}) as Layout;
|
||||
const draftLeaf = leaves(layout.tabs[0].root).find((l) => !l.sessionId)!;
|
||||
draftLeaf.draft = { command: "claude", cwd: "/home/user/project", name: "amber-fox" };
|
||||
|
||||
const restored = hydrate(JSON.parse(serializeLayout(layout)).layout);
|
||||
expect(restored).not.toBeNull();
|
||||
for (const leaf of leaves(restored!.tabs[0].root)) {
|
||||
expect(leaf.draft ?? null).toBeNull();
|
||||
}
|
||||
expect(leaves(restored!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1", null]);
|
||||
});
|
||||
});
|
||||
|
||||
+19
-1
@@ -14,12 +14,30 @@ const VERSION = 1;
|
||||
|
||||
export function saveLayout(layout: Layout): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify({ v: VERSION, layout }));
|
||||
localStorage.setItem(KEY, serializeLayout(layout));
|
||||
} catch {
|
||||
// Storage full or unavailable (private mode) — persistence is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialise the layout for storage with draft state stripped (issue #57): a
|
||||
* reload gives back an empty form, never a stale half-filled one. */
|
||||
export function serializeLayout(layout: Layout): string {
|
||||
const clean: Layout = {
|
||||
...layout,
|
||||
tabs: layout.tabs.map((t) => ({
|
||||
...t,
|
||||
root: stripDrafts(t.root),
|
||||
})),
|
||||
};
|
||||
return JSON.stringify({ v: VERSION, layout: clean });
|
||||
}
|
||||
|
||||
function stripDrafts(node: Node | null): Node | null {
|
||||
if (!node || node.kind !== "leaf") return node;
|
||||
return { ...node, draft: null };
|
||||
}
|
||||
|
||||
export function loadLayout(): Layout | null {
|
||||
let raw: string | null;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user