diff --git a/.gitignore b/.gitignore index 0710bc1e..2587ee22 100644 --- a/.gitignore +++ b/.gitignore @@ -2,11 +2,18 @@ *.swp *.swo *~ -docs/ +/docs/ .collaborator .superpowers .claude/ notes/ Screenshots/ .worktrees -opencode.json \ No newline at end of file +opencode.json + +# Generated SCM QA repos (scripts in fixtures/git/ are tracked) +fixtures/git/merge-conflict/ +fixtures/git/submodule/ +fixtures/git/rebase-todo/ +fixtures/git/dirty-worktree/ +fixtures/git/_bare-dirty.git/ \ No newline at end of file diff --git a/collab-electron/.gitignore b/collab-electron/.gitignore index face5070..2d3a3098 100644 --- a/collab-electron/.gitignore +++ b/collab-electron/.gitignore @@ -21,6 +21,8 @@ data/ # Playwright MCP .playwright-mcp/ +scripts/scm-screenshots/.deps/node_modules/ +scripts/scm-screenshots/harness/fixture-data.json # Editor/IDE *.swp @@ -40,6 +42,10 @@ electron/out/ # Back up these files separately when migrating machines .claude/ CLAUDE.md -docs/ +# Private docs — allow versioned SCM QA docs (tracked specs live under docs/superpowers/) +docs/* +!docs/SCM_TEST_MATRIX.md +!docs/screenshots/ +!docs/screenshots/** dev-app-update.yml build-debug.log diff --git a/collab-electron/docs/SCM_TEST_MATRIX.md b/collab-electron/docs/SCM_TEST_MATRIX.md new file mode 100644 index 00000000..566204c1 --- /dev/null +++ b/collab-electron/docs/SCM_TEST_MATRIX.md @@ -0,0 +1,71 @@ +# SCM test matrix + +Manual QA checklist for Source Control (Phase 0–2). + +## Foundation + +- [ ] `git status` shows `repoState` (clean, merging, rebasing, interactive-rebase, cherry-picking, reverting) +- [ ] Selected remote in SyncBar matches Commit & Push / Commit & Sync +- [ ] Refresh updates counts and branch + +## Phase A — Viewer diff + +- [ ] Click staged/unstaged file opens Monaco diff in viewer tile +- [ ] Header shows path and ref labels; Esc closes diff +- [ ] Compare with Branch opens branch picker and diff + +## Phase B — Clone & remotes + +- [ ] Clone Repository from non-repo workspace adds workspace +- [ ] Manage remotes: add, remove (with confirm) + +## Phase C — Conflicts + +- [ ] Merge banner shows Continue / Abort during merge +- [ ] Accept Current / Incoming / Mark Resolved on conflict files +- [ ] Commit disabled while conflicts exist + +## Phase D — Context menus + +- [ ] Right-click file: stage, unstage, discard, open changes, reveal +- [ ] SCM header menu: refresh, pull, push, open .gitignore + +## Phase E — History + +- [ ] History section lists commits; context menu revert/cherry-pick/reset +- [ ] Open Graph starts replay + +## Phase F — Interactive rebase + +- [ ] During `git rebase -i`, todo list appears with reorder and action dropdowns +- [ ] Continue / Abort work + +## Phase G — Submodules & worktrees + +- [ ] Submodules listed when `.gitmodules` exists; Update runs +- [ ] Worktrees listed when multiple exist + +## Phase H — Partial staging + +- [ ] Stage hunk from diff viewer (when hunks available) + +## Phase I — Signing & config + +- [ ] Sign commit checkbox when `commit.gpgsign` configured +- [ ] Settings → Git shows read-only user.name, email, credential.helper + +## Fixtures + +From repo root, run `./fixtures/git/setup-all.sh` (see `fixtures/git/README.md`). Open a generated folder as a workspace: + +| Path | Covers | +|------|--------| +| `fixtures/git/merge-conflict` | Phase C — merge banner, conflict markers in `base.txt` | +| `fixtures/git/submodule` | Phase G — `child` submodule | +| `fixtures/git/rebase-todo` | Phase F — interactive rebase paused at `break` | +| `fixtures/git/dirty-worktree` | Overview — staged/unstaged/untracked, tags, stashes, sync bar | + +## Phase I — LFS badge + +- [ ] Repo with `*.bin filter=lfs diff=lfs merge=lfs -text` in `.gitattributes` shows **LFS** badge on matching changed files +- [ ] Badge absent when `git-lfs` is not installed or path is not LFS-tracked diff --git a/collab-electron/docs/screenshots/scm/01-scm-overview.png b/collab-electron/docs/screenshots/scm/01-scm-overview.png new file mode 100644 index 00000000..e732d11f Binary files /dev/null and b/collab-electron/docs/screenshots/scm/01-scm-overview.png differ diff --git a/collab-electron/docs/screenshots/scm/02-commit-box.png b/collab-electron/docs/screenshots/scm/02-commit-box.png new file mode 100644 index 00000000..e6190d3d Binary files /dev/null and b/collab-electron/docs/screenshots/scm/02-commit-box.png differ diff --git a/collab-electron/docs/screenshots/scm/03-branch-picker.png b/collab-electron/docs/screenshots/scm/03-branch-picker.png new file mode 100644 index 00000000..6fbfb403 Binary files /dev/null and b/collab-electron/docs/screenshots/scm/03-branch-picker.png differ diff --git a/collab-electron/docs/screenshots/scm/04-sync-remotes.png b/collab-electron/docs/screenshots/scm/04-sync-remotes.png new file mode 100644 index 00000000..a51fe6a4 Binary files /dev/null and b/collab-electron/docs/screenshots/scm/04-sync-remotes.png differ diff --git a/collab-electron/docs/screenshots/scm/05-merge-conflicts.png b/collab-electron/docs/screenshots/scm/05-merge-conflicts.png new file mode 100644 index 00000000..9328be78 Binary files /dev/null and b/collab-electron/docs/screenshots/scm/05-merge-conflicts.png differ diff --git a/collab-electron/docs/screenshots/scm/06-history.png b/collab-electron/docs/screenshots/scm/06-history.png new file mode 100644 index 00000000..25e99a2f Binary files /dev/null and b/collab-electron/docs/screenshots/scm/06-history.png differ diff --git a/collab-electron/docs/screenshots/scm/07-interactive-rebase.png b/collab-electron/docs/screenshots/scm/07-interactive-rebase.png new file mode 100644 index 00000000..a418e9ee Binary files /dev/null and b/collab-electron/docs/screenshots/scm/07-interactive-rebase.png differ diff --git a/collab-electron/docs/screenshots/scm/08-stash.png b/collab-electron/docs/screenshots/scm/08-stash.png new file mode 100644 index 00000000..196d8cef Binary files /dev/null and b/collab-electron/docs/screenshots/scm/08-stash.png differ diff --git a/collab-electron/docs/screenshots/scm/09-viewer-diff.png b/collab-electron/docs/screenshots/scm/09-viewer-diff.png new file mode 100644 index 00000000..6a468af9 Binary files /dev/null and b/collab-electron/docs/screenshots/scm/09-viewer-diff.png differ diff --git a/collab-electron/docs/screenshots/scm/10-settings-git.png b/collab-electron/docs/screenshots/scm/10-settings-git.png new file mode 100644 index 00000000..fdbcb280 Binary files /dev/null and b/collab-electron/docs/screenshots/scm/10-settings-git.png differ diff --git a/collab-electron/docs/screenshots/scm/QA_RESULTS.md b/collab-electron/docs/screenshots/scm/QA_RESULTS.md new file mode 100644 index 00000000..a6cdde89 --- /dev/null +++ b/collab-electron/docs/screenshots/scm/QA_RESULTS.md @@ -0,0 +1,34 @@ +# SCM QA results + +Run date: 2026-06-01 +Branch: `dev` (PR #44) + +## Automated (fixture + backend) + +| Area | Result | Notes | +|------|--------|-------| +| Fixture setup (`setup-all.sh`) | **PASS** | merge-conflict, submodule, rebase-todo, dirty-worktree | +| `gitStatus` / `gitRepoState` | **PASS** | clean, merging, interactive-rebase states | +| Stage / unstage / commit | **PASS** | temp repo in `git-source-control.fixture.test.ts` | +| Branches / tags / remotes / stash | **PASS** | dirty-worktree fixture | +| Merge conflict detection | **PASS** | unmerged `base.txt` in merge-conflict | +| Rebase todo parse | **PASS** | Falls back to `git-rebase-todo.backup` when paused at `break` | +| Submodule / worktree list | **PASS** | submodule fixture | +| `gitLog` / `gitDiff` | **PASS** | dirty-worktree | +| `npm run build` (collab-electron) | **PASS** | electron-vite production build | +| Dark screenshots 01–10 | **PASS** | `bun scripts/capture-scm-screenshots.mjs` | + +Test command: + +```bash +./fixtures/git/setup-all.sh +cd collab-electron && bun test src/main/git-source-control.fixture.test.ts +``` + +## Manual only (Electron UI) + +Per [SCM_TEST_MATRIX.md](../../SCM_TEST_MATRIX.md): context menus, real push/pull, clone flow, GPG sign commit, LFS badge, graph replay, partial hunk staging in live app. Screenshots use component harness + git bridge, not full Electron shell. + +## Fix applied during QA + +- `gitRebaseTodoList`: read `git-rebase-todo.backup` when primary todo is empty (interactive rebase paused at `break`). diff --git a/collab-electron/docs/screenshots/scm/README.md b/collab-electron/docs/screenshots/scm/README.md new file mode 100644 index 00000000..9b227ca8 --- /dev/null +++ b/collab-electron/docs/screenshots/scm/README.md @@ -0,0 +1,30 @@ +# SCM screenshots + +PNG captures of the Source Control UI (Phase 0–2) for PR #44 review. Rendered with real `@collab/components` styles, dark theme, and live `git` data from `fixtures/git/`. + +## Regenerating + +From the repository root: + +```bash +./fixtures/git/setup-all.sh +cd collab-electron +bun scripts/capture-scm-screenshots.mjs +``` + +Requires Bun, Chromium (installed automatically via Playwright on first run), and `git` on `PATH`. + +## Files + +| File | What it shows | +|------|----------------| +| `01-scm-overview.png` | Full SCM panel: branch header, sync bar, commit box, staged/unstaged/untracked changes (`fixtures/git/dirty-worktree`) | +| `02-commit-box.png` | Commit message area with sample text, Amend, Commit / Commit & Push / Commit & Sync | +| `03-branch-picker.png` | Branch picker dropdown with local branches and tags | +| `04-sync-remotes.png` | **Manage Remotes** modal (origin + backup from dirty-worktree fixture) | +| `05-merge-conflicts.png` | Merge-in-progress banner and conflicted `base.txt` (`fixtures/git/merge-conflict`) | +| `06-history.png` | History section expanded with commit list and selected commit file list | +| `07-interactive-rebase.png` | Interactive rebase todo list with actions (`fixtures/git/rebase-todo`) | +| `08-stash.png` | Stashes section expanded with saved stashes | +| `09-viewer-diff.png` | Viewer-style Monaco side-by-side diff for `base.txt` during merge | +| `10-settings-git.png` | Settings → Git read-only config (`user.name`, `user.email`, etc.) | diff --git a/collab-electron/packages/components/src/DiffEditorView/DiffEditorView.css b/collab-electron/packages/components/src/DiffEditorView/DiffEditorView.css new file mode 100644 index 00000000..4b1151a9 --- /dev/null +++ b/collab-electron/packages/components/src/DiffEditorView/DiffEditorView.css @@ -0,0 +1,33 @@ +.diff-editor-root { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.diff-editor-hunks { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 6px 8px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent); +} + +.diff-editor-hunk-btn { + font-size: 11px; + padding: 2px 8px; + border-radius: 4px; + border: 1px solid var(--border); + background: transparent; + color: var(--foreground); + cursor: pointer; +} + +.diff-editor-hunk-btn:hover { + background: color-mix(in srgb, var(--foreground) 8%, transparent); +} + +.diff-editor-container { + flex: 1; + min-height: 0; +} diff --git a/collab-electron/packages/components/src/DiffEditorView/DiffEditorView.tsx b/collab-electron/packages/components/src/DiffEditorView/DiffEditorView.tsx new file mode 100644 index 00000000..5398c3b5 --- /dev/null +++ b/collab-electron/packages/components/src/DiffEditorView/DiffEditorView.tsx @@ -0,0 +1,106 @@ +import { useEffect, useRef } from "react"; +import * as monaco from "monaco-editor"; +import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; +import "./DiffEditorView.css"; + +self.MonacoEnvironment = { + getWorker() { + return new editorWorker(); + }, +}; + +function languageFromPath(filePath: string): string { + const ext = filePath.slice(filePath.lastIndexOf(".") + 1).toLowerCase(); + const map: Record = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + json: "json", + py: "python", + md: "markdown", + css: "css", + html: "html", + yaml: "yaml", + yml: "yaml", + sh: "shell", + }; + return map[ext] ?? "plaintext"; +} + +export interface DiffEditorViewProps { + filePath: string; + original: string; + modified: string; + readOnly?: boolean; + theme?: "light" | "dark"; + onStageHunk?: (patch: string) => void; + hunks?: Array<{ index: number; header: string; patch: string }>; +} + +export function DiffEditorView({ + filePath, + original, + modified, + readOnly = true, + theme = "dark", + onStageHunk, + hunks, +}: DiffEditorViewProps) { + const containerRef = useRef(null); + const editorRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + const lang = languageFromPath(filePath); + const originalModel = monaco.editor.createModel( + original, + lang, + monaco.Uri.parse(`git-diff-original://${filePath}`), + ); + const modifiedModel = monaco.editor.createModel( + modified, + lang, + monaco.Uri.parse(`git-diff-modified://${filePath}`), + ); + + const diffEditor = monaco.editor.createDiffEditor(containerRef.current, { + readOnly, + renderSideBySide: true, + automaticLayout: true, + theme: theme === "dark" ? "vs-dark" : "vs", + originalEditable: false, + }); + diffEditor.setModel({ original: originalModel, modified: modifiedModel }); + editorRef.current = diffEditor; + + return () => { + diffEditor.dispose(); + originalModel.dispose(); + modifiedModel.dispose(); + editorRef.current = null; + }; + }, [filePath, original, modified, readOnly, theme]); + + return ( +
+ {hunks && hunks.length > 0 && onStageHunk && ( +
+ {hunks.map((hunk) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/collab-electron/packages/components/src/DiffEditorView/index.ts b/collab-electron/packages/components/src/DiffEditorView/index.ts new file mode 100644 index 00000000..4994d35e --- /dev/null +++ b/collab-electron/packages/components/src/DiffEditorView/index.ts @@ -0,0 +1,2 @@ +export { DiffEditorView } from "./DiffEditorView"; +export type { DiffEditorViewProps } from "./DiffEditorView"; diff --git a/collab-electron/packages/components/src/SourceControl/BranchPicker.tsx b/collab-electron/packages/components/src/SourceControl/BranchPicker.tsx new file mode 100644 index 00000000..01a9bb18 --- /dev/null +++ b/collab-electron/packages/components/src/SourceControl/BranchPicker.tsx @@ -0,0 +1,455 @@ +import React, { + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { + GitBranch, + MagnifyingGlass, + Plus, + Trash, + Check, + Tag, +} from '@phosphor-icons/react'; +import type { + GitBranch as GitBranchType, + GitTag, +} from '@collab/shared/git-types'; + +interface BranchPickerProps { + workspacePath: string; + currentBranch: string; + onSwitch: (branch: string) => Promise; + onCreate: (name: string) => Promise; + onDelete: (name: string) => Promise; + onMerge?: (branch: string) => Promise; + onRebase?: (branch: string) => Promise; + onInteractiveRebase?: () => void; + onError: (msg: string) => void; +} + +export function BranchPicker({ + workspacePath, + currentBranch, + onSwitch, + onCreate, + onDelete, + onMerge, + onRebase, + onInteractiveRebase, + onError, +}: BranchPickerProps) { + const [open, setOpen] = useState(false); + const [branches, setBranches] = useState( + [], + ); + const [tags, setTags] = useState([]); + const [filter, setFilter] = useState(''); + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(''); + const [loading, setLoading] = useState(false); + const containerRef = useRef(null); + const inputRef = useRef(null); + + const loadBranches = useCallback(async () => { + try { + const [branchList, tagList] = await Promise.all([ + window.api.gitBranches(workspacePath), + window.api.gitTags(workspacePath), + ]); + setBranches(branchList); + setTags(tagList); + } catch { + setBranches([]); + setTags([]); + } + }, [workspacePath]); + + useEffect(() => { + if (open) { + loadBranches(); + setTimeout(() => inputRef.current?.focus(), 50); + } else { + setFilter(''); + setCreating(false); + setNewName(''); + } + }, [open, loadBranches]); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains( + e.target as Node, + ) + ) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => + document.removeEventListener( + 'mousedown', + handler, + ); + }, [open]); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + window.addEventListener('keydown', handler); + return () => + window.removeEventListener('keydown', handler); + }, [open]); + + const handleSwitch = useCallback( + async (ref: string, isTag?: boolean) => { + if (ref === currentBranch) return; + if ( + isTag && + !window.confirm( + `Checkout tag "${ref}"? This puts you in detached HEAD state.`, + ) + ) { + return; + } + setLoading(true); + try { + await onSwitch(ref); + setOpen(false); + } catch (err) { + onError( + err instanceof Error + ? err.message + : 'Switch failed', + ); + } finally { + setLoading(false); + } + }, + [currentBranch, onSwitch, onError], + ); + + const handleCreate = useCallback(async () => { + if (!newName.trim()) return; + setLoading(true); + try { + await onCreate(newName.trim()); + setOpen(false); + } catch (err) { + onError( + err instanceof Error + ? err.message + : 'Create failed', + ); + } finally { + setLoading(false); + } + }, [newName, onCreate, onError]); + + const handleDelete = useCallback( + async (name: string) => { + if (name === currentBranch) { + onError('Cannot delete the current branch'); + return; + } + if ( + !window.confirm( + `Delete branch "${name}"?`, + ) + ) { + return; + } + setLoading(true); + try { + await onDelete(name); + await loadBranches(); + } catch (err) { + onError( + err instanceof Error + ? err.message + : 'Delete failed', + ); + } finally { + setLoading(false); + } + }, + [currentBranch, onDelete, onError, loadBranches], + ); + + const q = filter.toLowerCase(); + const localBranches = branches.filter( + (b) => + !b.isRemote && + b.name.toLowerCase().includes(q), + ); + const remoteBranches = branches.filter( + (b) => + b.isRemote && + b.name.toLowerCase().includes(q), + ); + const filteredTags = tags.filter((t) => + t.name.toLowerCase().includes(q), + ); + + return ( +
+ + + {open && ( +
+
+ + + setFilter(e.target.value) + } + className="scm-branch-search-input" + /> +
+ +
+ {localBranches.length > 0 && ( +
+
+ Local +
+ {localBranches.map((b) => ( +
+ handleSwitch( + b.name, + ) + } + > + + {b.name} + + {b.current && ( + + )} + {!b.current && ( + + )} +
+ ))} +
+ )} + + {remoteBranches.length > 0 && ( +
+
+ Remote +
+ {remoteBranches.map((b) => ( +
+ handleSwitch( + b.name, + ) + } + > + + {b.name} + +
+ ))} +
+ )} + + {filteredTags.length > 0 && ( +
+
+ Tags +
+ {filteredTags.map((t) => ( +
+ handleSwitch( + t.name, + true, + ) + } + > + + + {t.name} + +
+ ))} +
+ )} +
+ +
+ {onMerge && ( + + )} + {onRebase && ( + + )} + {onInteractiveRebase && ( + + )} + {creating ? ( +
+ + setNewName( + e.target.value, + ) + } + onKeyDown={(e) => { + if ( + e.key === 'Enter' + ) + handleCreate(); + if ( + e.key === 'Escape' + ) + setCreating( + false, + ); + }} + autoFocus + className="scm-branch-search-input" + /> +
+ ) : ( + + )} +
+ + {loading && ( +
+ Working... +
+ )} +
+ )} +
+ ); +} diff --git a/collab-electron/packages/components/src/SourceControl/ChangeSectionHeader.tsx b/collab-electron/packages/components/src/SourceControl/ChangeSectionHeader.tsx new file mode 100644 index 00000000..725e8cd3 --- /dev/null +++ b/collab-electron/packages/components/src/SourceControl/ChangeSectionHeader.tsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; +import { + CaretDown, + Plus, + Minus, + Trash, +} from '@phosphor-icons/react'; + +interface ChangeSectionHeaderProps { + title: string; + count: number; + actionIcon?: 'stage' | 'unstage'; + onAction?: () => void; + secondaryActionIcon?: 'discard'; + onSecondaryAction?: () => void; + children: React.ReactNode; +} + +export function ChangeSectionHeader({ + title, + count, + actionIcon, + onAction, + secondaryActionIcon, + onSecondaryAction, + children, +}: ChangeSectionHeaderProps) { + const [collapsed, setCollapsed] = useState(false); + + if (count === 0) return null; + + const ActionIcon = + actionIcon === 'stage' + ? Plus + : actionIcon === 'unstage' + ? Minus + : null; + const actionTitle = + actionIcon === 'stage' + ? 'Stage All' + : actionIcon === 'unstage' + ? 'Unstage All' + : ''; + + return ( + <> +
setCollapsed((c) => !c)} + > + + + + {title} + {count} + {secondaryActionIcon === 'discard' && + onSecondaryAction && ( + + )} + {ActionIcon && onAction && ( + + )} +
+ {!collapsed && children} + + ); +} diff --git a/collab-electron/packages/components/src/SourceControl/CloneRepositoryModal.tsx b/collab-electron/packages/components/src/SourceControl/CloneRepositoryModal.tsx new file mode 100644 index 00000000..6a1d0531 --- /dev/null +++ b/collab-electron/packages/components/src/SourceControl/CloneRepositoryModal.tsx @@ -0,0 +1,103 @@ +import { useState } from "react"; +import { X } from "@phosphor-icons/react"; + +interface CloneRepositoryModalProps { + open: boolean; + onClose: () => void; + onCloned: (path: string) => void; + onError: (msg: string) => void; +} + +export function CloneRepositoryModal({ + open, + onClose, + onCloned, + onError, +}: CloneRepositoryModalProps) { + const [url, setUrl] = useState(""); + const [parentDir, setParentDir] = useState(""); + const [branch, setBranch] = useState(""); + const [cloning, setCloning] = useState(false); + + if (!open) return null; + + const handlePickParent = async () => { + const picked = await window.api.openFolder(); + if (picked) setParentDir(picked); + }; + + const handleClone = async () => { + if (!url.trim() || !parentDir.trim()) { + onError("URL and parent folder are required"); + return; + } + setCloning(true); + try { + const result = await window.api.gitClone( + url.trim(), + parentDir.trim(), + branch.trim() ? { branch: branch.trim() } : undefined, + ); + onCloned(result.path); + onClose(); + setUrl(""); + setBranch(""); + } catch (err) { + onError(err instanceof Error ? err.message : "Clone failed"); + } finally { + setCloning(false); + } + }; + + return ( +
+
e.stopPropagation()}> +
+

Clone Repository

+ +
+
+ + + + +
+
+
+ ); +} diff --git a/collab-electron/packages/components/src/SourceControl/CommitBox.tsx b/collab-electron/packages/components/src/SourceControl/CommitBox.tsx new file mode 100644 index 00000000..f2d859ea --- /dev/null +++ b/collab-electron/packages/components/src/SourceControl/CommitBox.tsx @@ -0,0 +1,184 @@ +import React, { useCallback } from 'react'; +import { + Check, + Sparkle, + CircleNotch, + ArrowUp, + ArrowsClockwise, +} from '@phosphor-icons/react'; + +interface CommitBoxProps { + message: string; + onMessageChange: (message: string) => void; + hasStagedChanges: boolean; + canGenerate: boolean; + agentName?: string; + hasAnyChanges: boolean; + hasCommits: boolean; + hasMergeConflicts: boolean; + amend: boolean; + onAmendChange: (amend: boolean) => void; + signCommit: boolean; + onSignCommitChange: (sign: boolean) => void; + showSignOption?: boolean; + onCommit: () => void; + onCommitAndPush: () => void; + onCommitAndSync: () => void; + onGenerateMessage: () => void; + committing: boolean; + generating: boolean; +} + +export function CommitBox({ + message, + onMessageChange, + hasStagedChanges: _hasStagedChanges, + canGenerate, + agentName, + hasAnyChanges, + hasCommits, + hasMergeConflicts, + amend, + onAmendChange, + signCommit, + onSignCommitChange, + showSignOption, + onCommit, + onCommitAndPush, + onCommitAndSync, + onGenerateMessage, + committing, + generating, +}: CommitBoxProps) { + const canCommit = + hasAnyChanges && + message.trim().length > 0 && + !committing && + !hasMergeConflicts; + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if ( + (e.metaKey || e.ctrlKey) && + e.key === 'Enter' && + canCommit + ) { + e.preventDefault(); + onCommit(); + } + }, + [canCommit, onCommit], + ); + + const aiDisabled = + !canGenerate || !hasAnyChanges || generating; + + let aiTitle = 'Generate commit message with AI'; + if (!canGenerate) { + aiTitle = + 'Install Claude Code, Codex, or Gemini CLI to generate commit messages'; + } else if (agentName) { + aiTitle = `Generate commit message with ${agentName}`; + } + + return ( +
+