diff --git a/web/src/fastForward.test.ts b/web/src/fastForward.test.ts new file mode 100644 index 0000000..4acd661 --- /dev/null +++ b/web/src/fastForward.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { + buildFastForwardRequest, + fastForwardSeed, + resolveFastForward, +} from "./fastForward.js"; +import type { Project, SpawnPreset } from "./types.js"; + +const presets: SpawnPreset[] = [ + { id: "custom", label: "Custom…", command: "" }, + { id: "claude", label: "Claude", command: "claude" }, +]; + +function project(overrides: Partial): Project { + return { + path: "/repos/p", + name: "p", + last_activity: new Date().toISOString(), + ...overrides, + }; +} + +describe("resolveFastForward (#67)", () => { + it("resolves every default from the most recent project", () => { + const projects = [ + project({ path: "/repos/old", name: "old", last_activity: "2020-01-01T00:00:00Z" }), + project({ + path: "/repos/fresh", + name: "fresh", + base_repo: "/repos/fresh", + last_command: "aider", + }), + ]; + const res = resolveFastForward(projects, presets, "amber-falcon"); + expect(res.project?.path).toBe("/repos/fresh"); + expect(res.command).toBe("aider"); + expect(res.name).toBe("amber-falcon"); + + const req = buildFastForwardRequest(res)!; + expect(req.cwd).toBe("/repos/fresh"); + expect(req.command).toBe("aider"); + expect(req.metadata).toEqual({ name: "amber-falcon" }); + expect(req.worktree).toEqual({ + base_repo: "/repos/fresh", + branch: "amber-falcon", + cleanup_on_kill: true, + create_branch: true, + }); + }); + + it("falls back to the first non-custom preset when there is no last command", () => { + const res = resolveFastForward([project({ last_command: undefined })], presets, "n"); + expect(res.command).toBe("claude"); + }); + + it("treats an empty last command as no command", () => { + const res = resolveFastForward([project({ last_command: "" })], [presets[0]], "n"); + expect(res.command).toBeUndefined(); + expect(buildFastForwardRequest(res)).toBeNull(); + }); + + it("resolves nothing when no projects are known yet", () => { + const res = resolveFastForward([], presets, "n"); + expect(res.project).toBeUndefined(); + expect(buildFastForwardRequest(res)).toBeNull(); + }); + + it("downgrades to a draft when the generated name is missing", () => { + const res = resolveFastForward([project({ last_command: "claude" })], presets); + expect(res.name).toBeUndefined(); + expect(buildFastForwardRequest(res)).toBeNull(); + }); + + it("seeds the fallback draft with whatever did resolve", () => { + const res = resolveFastForward( + [project({ path: "/repos/p", base_repo: "/repos/p", last_command: "aider" })], + presets, + ); + expect(fastForwardSeed(res)).toEqual({ + cwd: "/repos/p", + command: "aider", + name: "", + worktreeEnabled: true, + worktreeBranch: undefined, + }); + }); + + it("seeds worktree off when not even a project resolved", () => { + const seed = fastForwardSeed(resolveFastForward([], presets)); + expect(seed.worktreeEnabled).toBe(false); + expect(seed.cwd).toBe(""); + }); +}); diff --git a/web/src/fastForward.ts b/web/src/fastForward.ts new file mode 100644 index 0000000..fe05dd5 --- /dev/null +++ b/web/src/fastForward.ts @@ -0,0 +1,62 @@ +// Fast-forward spawn (#67): one keystroke, every default resolved, no form. +// Pure resolution only — the caller (main.ts) owns the fetches and the POST, +// and falls back to a seeded draft pane when anything is missing. + +import type { CreateSessionRequest, Project, SpawnPreset } from "./types.js"; +import { sortProjects } from "./projectList.js"; + +/** What one fast-forward keystroke resolved. Anything absent means "open a + * draft pre-filled with the rest instead of spawning". */ +export interface FastForwardResolution { + project?: Project; + command?: string; + name?: string; +} + +/** The defaults: most recent project (fresh-first, same ordering the picker + * shows), its last command falling back to the first non-custom preset, and + * a freshly generated name when the daemon supplied one. */ +export function resolveFastForward( + projects: Project[], + presets: SpawnPreset[], + name?: string, +): FastForwardResolution { + const project = sortProjects(projects, new Date())[0]; + const lastCommand = project?.last_command?.trim(); + const command = + lastCommand || presets.find((p) => p.command !== "")?.command || undefined; + return { project, command, name: name || undefined }; +} + +/** The immediate spawn request — worktree on, new branch named after the + * generated session name — or `null` when any default is missing. */ +export function buildFastForwardRequest( + res: FastForwardResolution, +): CreateSessionRequest | null { + const { project, command, name } = res; + if (!project || !command?.trim() || !name) return null; + return { + cwd: project.path, + command, + metadata: { name }, + worktree: { + base_repo: project.base_repo ?? project.path, + branch: name, + cleanup_on_kill: true, + create_branch: true, + }, + }; +} + +/** Seed for the fallback draft pane: whatever did resolve goes into the + * form; the rest stays with its own defaults. */ +export function fastForwardSeed(res: FastForwardResolution) { + return { + cwd: res.project?.path ?? "", + command: res.command ?? "", + name: res.name ?? "", + worktreeEnabled: Boolean(res.project), + worktreeBranch: + res.project && res.name ? res.name : undefined, + }; +}