diff --git a/DESIGN.md b/DESIGN.md index cdddefd..468c96c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -6,7 +6,7 @@ Kubernetes plugin + built-in terminal) with a purpose-built tool that does exact five things well: 1. One tab per project, where a project is a cloned git repository of manifests. -2. A file tree with git change awareness, plus pull / commit / push and diffs. +2. A file tree with git change awareness, plus pull / push and diffs. 3. Light, schema-aware YAML editing and markdown viewing/editing. 4. Diff-before-apply Kubernetes workflows for plain YAML and Helm charts. 5. A first-class embedded terminal for running Claude Code per project. @@ -102,7 +102,8 @@ One process, three layers: - **Project registry** — the list of managed repos and their per-project settings (§4). Owns lifecycle: add (clone or point at existing checkout), open, remove. - **Git service** — wraps `git` invocations per project: `status --porcelain=v2`, - `diff`, `log`, `pull`, `push`, `commit`, branch info. Emits change events. + `diff`, `log`, `pull`, `push`, branch info. Emits change events. Nothing here + writes the index — see §7. - **FS watcher** — fsnotify on each open project's worktree and `.git/HEAD` / `.git/refs`; debounced events drive tree badges and editor reload prompts. Polling fallback where fsnotify is unreliable (network mounts). @@ -125,7 +126,7 @@ One process, three layers: Two channels, chosen by payload profile: -- **Wails bindings** for RPC (open file, git commit, run apply, get project +- **Wails bindings** for RPC (open file, git pull, run apply, get project list): request/response, typed, low volume. - **Loopback WebSocket server** (127.0.0.1, random port, per-launch bearer token required on connect) for streams: PTY input/output (binary frames), live @@ -250,12 +251,18 @@ both ways from the UI without an explicit switch. v1 scope mirrors actual daily use, not a git client: - Status-driven tree badges and a changes list per project. -- Stage/unstage, commit (with message editor), pull (rebase per repo config), - push, current branch + ahead/behind in the status bar. +- Pull (rebase per repo config), push, current branch + ahead/behind in the status bar. - Diff viewer for working-tree changes and for a file's last commit. - Branch switching (existing branches). Branch creation, log browsing, stash, and history tooling are v1.x (§10). +**Staging and committing are not m6t's.** The agent in the terminal (§5) does that +work, running the user's own `git` in the user's own worktree — so m6t offers no commit +box and no stage/unstage control, and its bound surface has no method that writes the +index. Two writers of one index, only one of which the agent can see, is two tools +disagreeing about one repository. The changes list still groups staged and unstaged +separately, because that is what `git status` reports whoever put the paths there. + All operations run the system `git`; failures surface stderr verbatim (the user knows how to read git errors — do not translate them). diff --git a/README.md b/README.md index ab70f47..81fbb99 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ Managing fleets of manifest repos doesn't need an IDE — it needs a small set o things done extremely well: - **Projects** — each cloned manifest repo is a top-level tab. -- **Git** — status badges in the file tree, pull / commit / push, proper diffs. +- **Git** — status badges in the file tree, pull / push, proper diffs. Committing + is the terminal agent's job, not m6t's (DESIGN.md §7). - **Editing** — CodeMirror-based YAML with Kubernetes schema diagnostics, and markdown preview. Light by design. - **Apply** — every cluster mutation goes validate → diff → confirm → apply, diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index c50ed2a..9f096ba 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -25,9 +25,6 @@ import { MODIFIED, emptyStatus } from "./lib/git"; function stubGit(overrides: Partial = {}): Git { return { status: () => Promise.resolve(emptyStatus()), - stage: () => Promise.resolve(), - unstage: () => Promise.resolve(), - commit: () => Promise.resolve(), pull: () => Promise.resolve(), push: () => Promise.resolve(), checkout: () => Promise.resolve(), @@ -614,37 +611,39 @@ describe("the git operations (#9)", () => { /** A repository whose status changes when the seam is written to, so the * refresh-after-an-operation contract is observable rather than asserted on * a call count. */ - function stagingGit() { - let staged = false; + function pullingGit() { + let pulled = false; const empty = emptyStatus(); const branch = { ...empty.branch, name: "main", upstream: "origin/main" }; const status = () => Promise.resolve({ ...empty, branch, - files: [ - { - path: "a.yaml", - staged: staged ? MODIFIED : "", - worktree: staged ? "" : MODIFIED, - conflicted: false, - origPath: "", - }, - ], + files: pulled + ? [] + : [ + { + path: "a.yaml", + staged: "", + worktree: MODIFIED, + conflicted: false, + origPath: "", + }, + ], }); - const stage = vi.fn(() => { - staged = true; + const pull = vi.fn(() => { + pulled = true; return Promise.resolve(); }); - return { seam: stubGit({ status, stage, branches: () => Promise.resolve(["main"]) }), stage }; + return { seam: stubGit({ status, pull, branches: () => Promise.resolve(["main"]) }), pull }; } - // The composition test: the panel's button, the ops hook, the seam, and the - // status re-read that makes the row move. Each of those has its own unit - // test; this is the only thing that fails when they are wired to each other - // wrongly. - it("stages a file from the changes panel and shows the result", async () => { - const { seam, stage } = stagingGit(); + // The composition test: the branch bar's button, the ops hook, the seam, and + // the status re-read that makes the row go away. Each of those has its own + // unit test; this is the only thing that fails when they are wired to each + // other wrongly. + it("pulls from the branch bar and shows the result", async () => { + const { seam, pull } = pullingGit(); render( { />, ); - const button = await screen.findByRole("button", { name: "Stage a.yaml" }); + // The button renders disabled first — the initial status has no upstream — + // and only becomes usable once the real one lands. + const button = await screen.findByRole("button", { name: "Pull" }); + await waitFor(() => { + expect((button as HTMLButtonElement).disabled).toBe(false); + }); + expect(screen.getByRole("button", { name: "Unstaged: a.yaml" })).toBeDefined(); + fireEvent.click(button); await waitFor(() => { - expect(stage).toHaveBeenCalledWith("/w/infra", ["a.yaml"]); + expect(pull).toHaveBeenCalledWith("/w/infra"); }); - // The row moved groups, which only happens if the operation triggered a + // The row went away, which only happens if the operation triggered a // re-read of the status. await waitFor(() => { - expect(screen.getByRole("button", { name: "Staged: a.yaml" })).toBeDefined(); + expect(screen.queryByRole("button", { name: "Unstaged: a.yaml" })).toBeNull(); }); - expect(screen.queryByRole("button", { name: "Unstaged: a.yaml" })).toBeNull(); + }); + + // The commit box and the stage/unstage controls are gone (#39): what records + // work is the agent in the terminal, and this is the assertion that fails if + // one of them comes back through a component the workbench still renders. + it("offers no control anywhere that writes the index", async () => { + const { seam } = pullingGit(); + render( + , + ); + + // A changed file on screen, so the panel is rendering rows rather than + // being absent for some unrelated reason. + await screen.findByRole("button", { name: "Unstaged: a.yaml" }); + + // Exact names, not a prefix: a row's own accessible name starts with + // "Unstaged:", so a prefix match would find the row and pass for the wrong + // reason. + for (const name of ["Stage a.yaml", "Unstage a.yaml", "Stage all", "Unstage all", "Commit"]) { + expect(screen.queryByRole("button", { name })).toBeNull(); + } + expect(screen.queryByLabelText("Commit subject")).toBeNull(); + expect(screen.queryByLabelText("Commit body")).toBeNull(); }); // A failed operation reaches the user with git's own words in it — the whole diff --git a/frontend/src/components/ChangesPanel.test.tsx b/frontend/src/components/ChangesPanel.test.tsx index a48952a..8be7f21 100644 --- a/frontend/src/components/ChangesPanel.test.tsx +++ b/frontend/src/components/ChangesPanel.test.tsx @@ -30,9 +30,6 @@ function renderPanel(props: Partial = {}) { status: emptyStatus(), error: null, onOpenFile: vi.fn(), - onStage: vi.fn(), - onUnstage: vi.fn(), - busy: false, ...props, }; render(); @@ -92,80 +89,45 @@ describe("the changes list", () => { }); }); -describe("staging from the panel", () => { - it("stages one file from its unstaged row", () => { - const { onStage } = renderPanel({ - status: statusOf([file("a.yaml", { worktree: MODIFIED })]), - }); - - fireEvent.click(screen.getByRole("button", { name: "Stage a.yaml" })); - - expect(onStage).toHaveBeenCalledWith(["a.yaml"]); - }); - - it("unstages one file from its staged row", () => { - const { onUnstage } = renderPanel({ - status: statusOf([file("a.yaml", { staged: ADDED })]), - }); - - fireEvent.click(screen.getByRole("button", { name: "Unstage a.yaml" })); - - expect(onUnstage).toHaveBeenCalledWith(["a.yaml"]); - }); - - // The group action sends every path in that group in one call, so the whole - // group moves in one git invocation rather than one per row. - it("stages a whole group at once", () => { - const { onStage } = renderPanel({ +describe("what the panel does not do", () => { + // The panel reports; the agent in the terminal writes (#39). A button here + // would be a second writer of the index that the agent cannot see. + it("offers no staging control on any row", () => { + renderPanel({ status: statusOf([ file("a.yaml", { worktree: MODIFIED }), - file("b.yaml", { worktree: UNTRACKED }), + file("b.yaml", { staged: ADDED }), + file("new.yaml", { staged: RENAMED, origPath: "old.yaml" }), ]), }); - fireEvent.click(screen.getByRole("button", { name: "Stage all" })); - - expect(onStage).toHaveBeenCalledWith(["a.yaml", "b.yaml"]); - }); - - // A file staged and then edited again is one row in each group, and each - // row's action moves only its own side. - it("offers both actions for a file that is in both groups", () => { - const { onStage, onUnstage } = renderPanel({ - status: statusOf([file("a.yaml", { staged: ADDED, worktree: MODIFIED })]), - }); - - fireEvent.click(screen.getByRole("button", { name: "Stage a.yaml" })); - fireEvent.click(screen.getByRole("button", { name: "Unstage a.yaml" })); - - expect(onStage).toHaveBeenCalledWith(["a.yaml"]); - expect(onUnstage).toHaveBeenCalledWith(["a.yaml"]); - }); - - // A rename is one row and two paths. Unstaging only the new name after a - // `git mv` leaves the old one staged as a deletion, which the next commit - // would carry out. - it("unstages both halves of a rename", () => { - const { onUnstage } = renderPanel({ - status: statusOf([file("new.yaml", { staged: RENAMED, origPath: "old.yaml" })]), - }); - - fireEvent.click(screen.getByRole("button", { name: "Unstage new.yaml" })); - - expect(onUnstage).toHaveBeenCalledWith(["new.yaml", "old.yaml"]); - }); - - // git serializes on the index, so a second click during an operation buys a - // lock error rather than a second operation. - it("disables every action while an operation is in flight", () => { - const { onStage } = renderPanel({ - status: statusOf([file("a.yaml", { worktree: MODIFIED })]), - busy: true, + // Exact names, not a prefix: every row's own accessible name starts with + // "Staged:" or "Unstaged:", so a prefix match would find the rows and pass + // for the wrong reason. + for (const name of [ + "Stage a.yaml", + "Unstage b.yaml", + "Unstage new.yaml", + "Stage all", + "Unstage all", + ]) { + expect(screen.queryByRole("button", { name })).toBeNull(); + } + }); + + // Every row is the button that opens its file, and nothing else is. + it("renders one button per row", () => { + renderPanel({ + status: statusOf([ + file("a.yaml", { worktree: MODIFIED }), + file("b.yaml", { staged: ADDED }), + ]), }); - fireEvent.click(screen.getByRole("button", { name: "Stage a.yaml" })); - - expect(onStage).not.toHaveBeenCalled(); + expect(screen.getAllByRole("button").map((b) => b.getAttribute("aria-label"))).toEqual([ + "Staged: b.yaml", + "Unstaged: a.yaml", + ]); }); }); @@ -179,12 +141,12 @@ describe("conflicts", () => { expect(screen.getByRole("status").textContent).toContain("Resolve these in the terminal"); }); - // `git add` on a conflicted file means "I have resolved this". A user who - // has not should not be one misclick from claiming so. - it("gives a conflicted row no staging action", () => { + // A conflicted path groups with unstaged for badge purposes, but it belongs + // to its own section here: an unmerged file is not an ordinary edit waiting + // to be listed beside one. + it("keeps a conflicted path out of the unstaged group", () => { renderPanel({ status: statusOf([file("a.yaml", { conflicted: true })]) }); - expect(screen.queryByRole("button", { name: "Stage a.yaml" })).toBeNull(); expect(screen.queryByText("Unstaged")).toBeNull(); }); diff --git a/frontend/src/components/ChangesPanel.tsx b/frontend/src/components/ChangesPanel.tsx index 3726403..89c318c 100644 --- a/frontend/src/components/ChangesPanel.tsx +++ b/frontend/src/components/ChangesPanel.tsx @@ -1,14 +1,10 @@ import type { FileStatus, Status } from "../lib/git"; import { NOT_A_REPOSITORY, NO_GIT } from "../lib/git"; import { fileBadge, groupChanges } from "../lib/gitStatus"; -import { conflictedFiles, pathsOf, pathsOfAll } from "../lib/gitOps"; +import { conflictedFiles } from "../lib/gitOps"; import { iconKind } from "../lib/tree"; import { FileIcon } from "./Icon"; -/** What a row's action button does, which is the only thing that differs - * between the two groups. */ -type RowAction = "stage" | "unstage" | "none"; - export interface ChangesPanelProps { readonly status: Status; /** A real git failure, as opposed to the two degraded states the status @@ -17,37 +13,30 @@ export interface ChangesPanelProps { /** Opens a changed file in the editor — the same intent the file tree * emits, so a row here and a row there do the same thing. */ readonly onOpenFile: (path: string) => void; - /** Adds paths to the index (#9). */ - readonly onStage: (paths: readonly string[]) => void; - /** Removes paths from the index, leaving the working tree alone (#9). */ - readonly onUnstage: (paths: readonly string[]) => void; - /** An operation is in flight; every action disables until it lands. */ - readonly busy: boolean; } /** * The per-project changes list (DESIGN.md §7): every path git reports, - * grouped staged and unstaged, each row with the one action that moves it to - * the other group. + * grouped staged and unstaged, each row opening the file it names. * * It sits under the file tree rather than beside it because the two answer * different questions about the same repository — "what is in here" and "what * have I touched" — and a user looking for the second should not have to * expand directories to find it. + * + * It reports and it does not write (#39). The rows used to carry stage and + * unstage buttons; what records work in m6t is the agent in the terminal + * below, running the user's own git, and a button here would be a second + * writer of the index that the agent cannot see. The staged/unstaged grouping + * stays because it is what git reports — a path in the index and a path only + * on disk are different facts, whoever put them there. */ -export function ChangesPanel({ - status, - error, - onOpenFile, - onStage, - onUnstage, - busy, -}: ChangesPanelProps) { +export function ChangesPanel({ status, error, onOpenFile }: ChangesPanelProps) { const { staged, unstaged } = groupChanges(status); const conflicts = conflictedFiles(status); // A conflicted path groups with unstaged for badge purposes, but it gets its - // own section here: staging one marks it resolved, which is a decision, not - // the routine move the same button makes on an ordinary edit. + // own section here: it is the one state that stops a pull and a branch + // switch, and it needs to say where to go about it. const editable = unstaged.filter((f) => !f.conflicted); return ( @@ -70,22 +59,8 @@ export function ChangesPanel({ {status.availability !== NO_GIT && status.availability !== NOT_A_REPOSITORY && ( <> - - + + {staged.length === 0 && unstaged.length === 0 && (

Nothing changed.

)} @@ -105,9 +80,7 @@ interface ConflictsProps { * * v1 ships no merge tool (DESIGN.md §7), and the terminal below is a real * shell in this repository — so the honest thing to show is where to go, not - * a button that would pretend to resolve something. The rows have no action - * for the same reason: `git add` on a conflicted file means "I have resolved - * this", and a user who has not should not be one misclick from claiming so. + * a button that would pretend to resolve something. */ function Conflicts({ files, onOpenFile }: ConflictsProps) { if (files.length === 0) { @@ -119,13 +92,16 @@ function Conflicts({ files, onOpenFile }: ConflictsProps) { Conflicted {files.length}

- Resolve these in the terminal, then stage them. m6t has no merge tool. + Resolve these in the terminal. m6t has no merge tool.

    {files.map((file) => ( - - - + ))}
@@ -135,77 +111,43 @@ function Conflicts({ files, onOpenFile }: ConflictsProps) { interface ChangeGroupProps { readonly label: string; readonly files: readonly FileStatus[]; - readonly action: RowAction; - readonly busy: boolean; readonly onOpenFile: (path: string) => void; - readonly onAct: (paths: readonly string[]) => void; } -/** One group's rows, with a group-level action in the heading. An empty group - * renders nothing at all rather than a heading over a blank space. */ -function ChangeGroup({ label, files, action, busy, onOpenFile, onAct }: ChangeGroupProps) { +/** One group's rows. An empty group renders nothing at all rather than a + * heading over a blank space. */ +function ChangeGroup({ label, files, onOpenFile }: ChangeGroupProps) { if (files.length === 0) { return null; } - const verb = action === "stage" ? "Stage" : "Unstage"; return ( <>

{label} {files.length} -

    {files.map((file) => ( - - - - + ))}
); } -/** One row's frame. */ -function Row({ - file, - label, - children, -}: { - readonly file: FileStatus; - readonly label: string; - readonly children: React.ReactNode; -}) { - return ( -
  • - {children} -
  • - ); -} - -/** The part of a row that opens the file. */ -function FileButton({ +/** + * One row: the whole thing opens the file it names. + * + * The row used to be a frame around a path button and an action button, which + * is why the two were separate components. With the action gone (#39) the row + * is the button, and the list reads as paths rather than as a column of + * controls. + */ +function ChangeRow({ file, label, onOpenFile, @@ -215,25 +157,27 @@ function FileButton({ readonly onOpenFile: (path: string) => void; }) { return ( - +
  • + +
  • ); } diff --git a/frontend/src/components/CommitBox.test.tsx b/frontend/src/components/CommitBox.test.tsx deleted file mode 100644 index cbce4fc..0000000 --- a/frontend/src/components/CommitBox.test.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { FileStatus, Status } from "../lib/git"; -import { ADDED, MODIFIED, NOT_A_REPOSITORY, emptyStatus } from "../lib/git"; -import { CommitBox } from "./CommitBox"; - -afterEach(cleanup); - -function file(path: string, overrides: Partial = {}): FileStatus { - return { path, staged: "", worktree: "", conflicted: false, origPath: "", ...overrides }; -} - -function statusOf(files: FileStatus[]): Status { - return { ...emptyStatus(), files }; -} - -function renderBox(status: Status, onCommit = vi.fn(() => Promise.resolve(true)), busy = false) { - render(); - return onCommit; -} - -const subjectField = () => screen.getByRole("textbox", { name: "Commit subject" }); -const bodyField = () => screen.getByRole("textbox", { name: "Commit body" }); -const commitButton = () => screen.getByRole("button", { name: "Commit" }); - -describe("committing", () => { - it("sends the subject and body as one message", async () => { - const onCommit = renderBox(statusOf([file("a.yaml", { staged: ADDED })])); - - fireEvent.change(subjectField(), { target: { value: "add the deployment" } }); - fireEvent.change(bodyField(), { target: { value: "Because X." } }); - fireEvent.click(commitButton()); - - await waitFor(() => { - expect(onCommit).toHaveBeenCalledWith("add the deployment\n\nBecause X."); - }); - }); - - it("clears the editor once the commit is recorded", async () => { - renderBox(statusOf([file("a.yaml", { staged: ADDED })])); - - fireEvent.change(subjectField(), { target: { value: "add the deployment" } }); - fireEvent.click(commitButton()); - - await waitFor(() => { - expect((subjectField() as HTMLInputElement).value).toBe(""); - }); - }); - - // A draft thrown away on a failed commit is a draft the user has to retype, - // and "nothing to commit" is a failure they will immediately want to retry. - it("keeps the draft when the commit fails", async () => { - const onCommit = vi.fn(() => Promise.resolve(false)); - renderBox(statusOf([file("a.yaml", { staged: ADDED })]), onCommit); - - fireEvent.change(subjectField(), { target: { value: "add the deployment" } }); - fireEvent.click(commitButton()); - - await waitFor(() => { - expect(onCommit).toHaveBeenCalled(); - }); - expect((subjectField() as HTMLInputElement).value).toBe("add the deployment"); - }); -}); - -describe("the disabled button", () => { - it("is enabled with a subject and something staged", () => { - renderBox(statusOf([file("a.yaml", { staged: ADDED })])); - fireEvent.change(subjectField(), { target: { value: "s" } }); - - expect((commitButton() as HTMLButtonElement).disabled).toBe(false); - expect(screen.queryByTestId("commit-blocked")).toBeNull(); - }); - - // A disabled control with no explanation is the thing users file bugs about. - it("says why with nothing staged", () => { - const onCommit = renderBox(statusOf([file("a.yaml", { worktree: MODIFIED })])); - fireEvent.change(subjectField(), { target: { value: "s" } }); - - expect((commitButton() as HTMLButtonElement).disabled).toBe(true); - expect(screen.getByTestId("commit-blocked").textContent).toBe("Stage something to commit."); - - fireEvent.click(commitButton()); - expect(onCommit).not.toHaveBeenCalled(); - }); - - it("says why with no subject typed", () => { - renderBox(statusOf([file("a.yaml", { staged: ADDED })])); - - expect((commitButton() as HTMLButtonElement).disabled).toBe(true); - expect(screen.getByTestId("commit-blocked").textContent).toBe("A commit needs a subject line."); - }); - - it("says why while a conflict is open", () => { - renderBox( - statusOf([file("a.yaml", { conflicted: true }), file("b.yaml", { staged: ADDED })]), - ); - fireEvent.change(subjectField(), { target: { value: "s" } }); - - expect(screen.getByTestId("commit-blocked").textContent).toBe( - "Resolve the conflicted files before committing.", - ); - }); - - it("is disabled while an operation is in flight", () => { - renderBox(statusOf([file("a.yaml", { staged: ADDED })]), vi.fn(() => Promise.resolve(true))); - fireEvent.change(subjectField(), { target: { value: "s" } }); - cleanup(); - - renderBox( - statusOf([file("a.yaml", { staged: ADDED })]), - vi.fn(() => Promise.resolve(true)), - true, - ); - - expect((commitButton() as HTMLButtonElement).disabled).toBe(true); - }); -}); - -describe("what is staged", () => { - it("counts the staged files", () => { - renderBox( - statusOf([file("a.yaml", { staged: ADDED }), file("b.yaml", { staged: MODIFIED })]), - ); - - expect(screen.getByTestId("commit-staged").textContent).toBe("2 files staged"); - }); - - it("uses the singular for one", () => { - renderBox(statusOf([file("a.yaml", { staged: ADDED })])); - - expect(screen.getByTestId("commit-staged").textContent).toBe("1 file staged"); - }); - - it("says nothing is staged when nothing is", () => { - renderBox(emptyStatus()); - - expect(screen.getByTestId("commit-staged").textContent).toBe("nothing staged"); - }); -}); - -// A project that is not a repository has nothing to commit to. The changes -// panel already explains why; a commit form beside that message would be a -// control that could never work. -it("renders nothing when git has no answer for the project", () => { - render( - Promise.resolve(true))} - busy={false} - />, - ); - - expect(screen.queryByRole("button", { name: "Commit" })).toBeNull(); -}); diff --git a/frontend/src/components/CommitBox.tsx b/frontend/src/components/CommitBox.tsx deleted file mode 100644 index 3188d7c..0000000 --- a/frontend/src/components/CommitBox.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useState } from "react"; -import type { Status } from "../lib/git"; -import { AVAILABLE } from "../lib/git"; -import type { CommitDraft } from "../lib/gitOps"; -import { EMPTY_DRAFT, commitBlockedReason, commitMessage, stagedPaths } from "../lib/gitOps"; - -export interface CommitBoxProps { - readonly status: Status; - /** Records the index. Resolves true when the commit was made, which is what - * clears the editor — a draft dropped on a failed commit is a draft the - * user has to retype. */ - readonly onCommit: (message: string) => Promise; - readonly busy: boolean; -} - -/** - * The commit message editor (DESIGN.md §7): a subject line, an optional body, - * and a button that is disabled with a reason. - * - * Two fields rather than one textarea because the subject is not just the - * first line — it is what every log, every blame and every PR title shows, and - * a separate input is what makes its length visible while it is being typed. - * - * Signing is not mentioned anywhere here. Whether a commit is signed is the - * repository's `commit.gpgsign`, m6t runs the user's own git, and a checkbox - * offering to override it would be m6t having an opinion about a policy it did - * not set. - */ -export function CommitBox({ status, onCommit, busy }: CommitBoxProps) { - const [draft, setDraft] = useState(EMPTY_DRAFT); - - if (status.availability !== AVAILABLE) { - return null; - } - - const blocked = commitBlockedReason(status, draft); - const staged = stagedPaths(status).length; - - const submit = () => { - void onCommit(commitMessage(draft)).then((committed) => { - if (committed) { - setDraft(EMPTY_DRAFT); - } - }); - }; - - return ( -
    - { - setDraft((current) => ({ ...current, subject: event.target.value })); - }} - /> -