diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb4d79b..e3b8f60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,9 @@ jobs: restore-keys: | ${{ runner.os }}-go- + - name: Format + run: go fmt ./... + - name: Build run: go build ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1de3a08 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +releases/ +bin/ diff --git a/Makefile b/Makefile index 2f4f80b..ee1c771 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ BIN ?= bin/diffium .PHONY: build test run fmt -build: +build: fmt go build -o $(BIN) ./cmd/diffium test: diff --git a/cmd/diffium/main.go b/cmd/diffium/main.go index 364d635..1bfbc85 100644 --- a/cmd/diffium/main.go +++ b/cmd/diffium/main.go @@ -1,14 +1,13 @@ package main import ( - "log" + "log" - "github.com/interpretive-systems/diffium/internal/cli" + "github.com/interpretive-systems/diffium/internal/cli" ) func main() { - if err := cli.Execute(); err != nil { - log.Fatal(err) - } + if err := cli.Execute(); err != nil { + log.Fatal(err) + } } - diff --git a/go.mod b/go.mod index 7900641..e0e43a8 100644 --- a/go.mod +++ b/go.mod @@ -2,14 +2,18 @@ module github.com/interpretive-systems/diffium go 1.25.1 +require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.8 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.10.1 + github.com/spf13/cobra v1.10.1 +) + require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v0.21.0 // indirect - github.com/charmbracelet/bubbletea v1.3.8 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect @@ -22,7 +26,6 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/spf13/cobra v1.10.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.36.0 // indirect diff --git a/go.sum b/go.sum index 2e12831..26f8d4c 100644 --- a/go.sum +++ b/go.sum @@ -45,6 +45,8 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= diff --git a/internal/cli/root.go b/internal/cli/root.go index 53f7486..8836f37 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -1,36 +1,35 @@ package cli import ( - "fmt" - "os" + "fmt" + "os" - "github.com/spf13/cobra" + "github.com/spf13/cobra" ) func Execute() error { - root := &cobra.Command{ - Use: "diffium", - Short: "Diff-first TUI for git changes", - Long: "Diffium: Explore and review git diffs in a side-by-side TUI.", - } + root := &cobra.Command{ + Use: "diffium", + Short: "Diff-first TUI for git changes", + Long: "Diffium: Explore and review git diffs in a side-by-side TUI.", + } - root.PersistentFlags().StringP("repo", "r", ".", "Path to repository root (default: current dir)") + root.PersistentFlags().StringP("repo", "r", ".", "Path to repository root (default: current dir)") - // Add subcommands - root.AddCommand(newWatchCmd()) + // Add subcommands + root.AddCommand(newWatchCmd()) - if err := root.Execute(); err != nil { - return fmt.Errorf("execute: %w", err) - } - return nil + if err := root.Execute(); err != nil { + return fmt.Errorf("execute: %w", err) + } + return nil } func mustGetStringFlag(cmd *cobra.Command, name string) string { - v, err := cmd.Flags().GetString(name) - if err != nil { - fmt.Fprintln(os.Stderr, "flag error:", err) - os.Exit(2) - } - return v + v, err := cmd.Flags().GetString(name) + if err != nil { + fmt.Fprintln(os.Stderr, "flag error:", err) + os.Exit(2) + } + return v } - diff --git a/internal/cli/watch.go b/internal/cli/watch.go index b7ce3b6..34f3124 100644 --- a/internal/cli/watch.go +++ b/internal/cli/watch.go @@ -1,26 +1,25 @@ package cli import ( - "fmt" + "fmt" - "github.com/interpretive-systems/diffium/internal/gitx" - "github.com/interpretive-systems/diffium/internal/tui" - "github.com/spf13/cobra" + "github.com/interpretive-systems/diffium/internal/gitx" + "github.com/interpretive-systems/diffium/internal/tui" + "github.com/spf13/cobra" ) func newWatchCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "watch", - Short: "Open the TUI and watch for changes", - RunE: func(cmd *cobra.Command, args []string) error { - repoPath := mustGetStringFlag(cmd.Root(), "repo") - root, err := gitx.RepoRoot(repoPath) - if err != nil { - return fmt.Errorf("not a git repo: %w", err) - } - return tui.Run(root) - }, - } - return cmd + cmd := &cobra.Command{ + Use: "watch", + Short: "Open the TUI and watch for changes", + RunE: func(cmd *cobra.Command, args []string) error { + repoPath := mustGetStringFlag(cmd.Root(), "repo") + root, err := gitx.RepoRoot(repoPath) + if err != nil { + return fmt.Errorf("not a git repo: %w", err) + } + return tui.Run(root) + }, + } + return cmd } - diff --git a/internal/diffview/side_by_side.go b/internal/diffview/side_by_side.go index 17dd6e6..398e191 100644 --- a/internal/diffview/side_by_side.go +++ b/internal/diffview/side_by_side.go @@ -1,28 +1,28 @@ package diffview import ( - "bufio" - "strings" + "bufio" + "strings" ) // RowKind represents the semantic type of a side-by-side row. type RowKind int const ( - RowContext RowKind = iota - RowAdd - RowDel - RowReplace - RowHunk - RowMeta + RowContext RowKind = iota + RowAdd + RowDel + RowReplace + RowHunk + RowMeta ) // Row represents a single visual row for side-by-side rendering. type Row struct { - Left string - Right string - Kind RowKind - Meta string // for hunk header text + Left string + Right string + Kind RowKind + Meta string // for hunk header text } // BuildRowsFromUnified parses a unified diff string into side-by-side rows. @@ -30,77 +30,76 @@ type Row struct { // with subsequent additions as replacements; any remaining lines are shown // as left-only (deletions) or right-only (additions). func BuildRowsFromUnified(unified string) []Row { - s := bufio.NewScanner(strings.NewReader(unified)) - s.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) // allow large lines + s := bufio.NewScanner(strings.NewReader(unified)) + s.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) // allow large lines - rows := make([]Row, 0, 256) - pendingDel := make([]string, 0) + rows := make([]Row, 0, 256) + pendingDel := make([]string, 0) - flushPending := func() { - for _, dl := range pendingDel { - rows = append(rows, Row{Left: trimPrefix(dl), Right: "", Kind: RowDel}) - } - pendingDel = pendingDel[:0] - } + flushPending := func() { + for _, dl := range pendingDel { + rows = append(rows, Row{Left: trimPrefix(dl), Right: "", Kind: RowDel}) + } + pendingDel = pendingDel[:0] + } - inHunk := false - for s.Scan() { - line := s.Text() - if strings.HasPrefix(line, "diff --git ") || strings.HasPrefix(line, "index ") || strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") { - // Metadata; flush any pending deletions - flushPending() - rows = append(rows, Row{Kind: RowMeta, Meta: line}) - continue - } - if strings.HasPrefix(line, "@@ ") { - flushPending() - rows = append(rows, Row{Kind: RowHunk, Meta: line}) - inHunk = true - continue - } - if !inHunk { - // Outside hunks, we don't have meaningful line-level info; skip - continue - } + inHunk := false + for s.Scan() { + line := s.Text() + if strings.HasPrefix(line, "diff --git ") || strings.HasPrefix(line, "index ") || strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") { + // Metadata; flush any pending deletions + flushPending() + rows = append(rows, Row{Kind: RowMeta, Meta: line}) + continue + } + if strings.HasPrefix(line, "@@ ") { + flushPending() + rows = append(rows, Row{Kind: RowHunk, Meta: line}) + inHunk = true + continue + } + if !inHunk { + // Outside hunks, we don't have meaningful line-level info; skip + continue + } - if len(line) == 0 { - // blank line inside hunk: treat as context - flushPending() - rows = append(rows, Row{Left: "", Right: "", Kind: RowContext}) - continue - } + if len(line) == 0 { + // blank line inside hunk: treat as context + flushPending() + rows = append(rows, Row{Left: "", Right: "", Kind: RowContext}) + continue + } - switch line[0] { - case ' ': - flushPending() - t := trimPrefix(line) - rows = append(rows, Row{Left: t, Right: t, Kind: RowContext}) - case '-': - pendingDel = append(pendingDel, line) - case '+': - if len(pendingDel) > 0 { - // Pair with the earliest pending deletion - dl := pendingDel[0] - pendingDel = pendingDel[1:] - rows = append(rows, Row{Left: trimPrefix(dl), Right: trimPrefix(line), Kind: RowReplace}) - } else { - rows = append(rows, Row{Left: "", Right: trimPrefix(line), Kind: RowAdd}) - } - default: - // Unknown line; ignore - } - } - flushPending() - return rows + switch line[0] { + case ' ': + flushPending() + t := trimPrefix(line) + rows = append(rows, Row{Left: t, Right: t, Kind: RowContext}) + case '-': + pendingDel = append(pendingDel, line) + case '+': + if len(pendingDel) > 0 { + // Pair with the earliest pending deletion + dl := pendingDel[0] + pendingDel = pendingDel[1:] + rows = append(rows, Row{Left: trimPrefix(dl), Right: trimPrefix(line), Kind: RowReplace}) + } else { + rows = append(rows, Row{Left: "", Right: trimPrefix(line), Kind: RowAdd}) + } + default: + // Unknown line; ignore + } + } + flushPending() + return rows } func trimPrefix(s string) string { - if s == "" { - return s - } - if s[0] == ' ' || s[0] == '+' || s[0] == '-' { - return s[1:] - } - return s + if s == "" { + return s + } + if s[0] == ' ' || s[0] == '+' || s[0] == '-' { + return s[1:] + } + return s } - diff --git a/internal/diffview/side_by_side_test.go b/internal/diffview/side_by_side_test.go index 127a345..5b9f939 100644 --- a/internal/diffview/side_by_side_test.go +++ b/internal/diffview/side_by_side_test.go @@ -3,7 +3,7 @@ package diffview import "testing" func TestBuildRows_SimpleReplaceAndAdd(t *testing.T) { - unified := `diff --git a/a.txt b/a.txt + unified := `diff --git a/a.txt b/a.txt --- a/a.txt +++ b/a.txt @@ -1,3 +1,4 @@ @@ -13,49 +13,48 @@ func TestBuildRows_SimpleReplaceAndAdd(t *testing.T) { line3 +line4` - rows := BuildRowsFromUnified(unified) - var adds, dels, rep, ctx, hunks int - for _, r := range rows { - switch r.Kind { - case RowAdd: - adds++ - case RowDel: - dels++ - case RowReplace: - rep++ - case RowContext: - ctx++ - case RowHunk: - hunks++ - } - } - if hunks != 1 { - t.Fatalf("expected 1 hunk, got %d", hunks) - } - if rep != 1 { - t.Fatalf("expected 1 replace row, got %d", rep) - } - if adds != 1 { - t.Fatalf("expected 1 add row, got %d", adds) - } - if ctx != 2 { - t.Fatalf("expected 2 context rows, got %d", ctx) - } + rows := BuildRowsFromUnified(unified) + var adds, dels, rep, ctx, hunks int + for _, r := range rows { + switch r.Kind { + case RowAdd: + adds++ + case RowDel: + dels++ + case RowReplace: + rep++ + case RowContext: + ctx++ + case RowHunk: + hunks++ + } + } + if hunks != 1 { + t.Fatalf("expected 1 hunk, got %d", hunks) + } + if rep != 1 { + t.Fatalf("expected 1 replace row, got %d", rep) + } + if adds != 1 { + t.Fatalf("expected 1 add row, got %d", adds) + } + if ctx != 2 { + t.Fatalf("expected 2 context rows, got %d", ctx) + } } func TestBuildRows_DeletionOnly(t *testing.T) { - unified := `@@ -1,2 +0,0 @@ + unified := `@@ -1,2 +0,0 @@ -old1 -old2` - rows := BuildRowsFromUnified(unified) - var dels int - for _, r := range rows { - if r.Kind == RowDel { - dels++ - } - } - if dels != 2 { - t.Fatalf("expected 2 deletions, got %d", dels) - } + rows := BuildRowsFromUnified(unified) + var dels int + for _, r := range rows { + if r.Kind == RowDel { + dels++ + } + } + if dels != 2 { + t.Fatalf("expected 2 deletions, got %d", dels) + } } - diff --git a/internal/gitx/gitx.go b/internal/gitx/gitx.go index 511b2f3..7f57adc 100644 --- a/internal/gitx/gitx.go +++ b/internal/gitx/gitx.go @@ -1,441 +1,441 @@ package gitx import ( - "bytes" - "errors" - "fmt" - "os/exec" - "path/filepath" - "sort" - "strings" + "bytes" + "errors" + "fmt" + "os/exec" + "path/filepath" + "sort" + "strings" ) // FileChange represents a changed file in the repo. type FileChange struct { - Path string - Staged bool - Unstaged bool - Untracked bool - Binary bool - Deleted bool + Path string + Staged bool + Unstaged bool + Untracked bool + Binary bool + Deleted bool } // RepoRoot resolves the git repository root from a given path (or current dir). func RepoRoot(path string) (string, error) { - if path == "" { - path = "." - } - cmd := exec.Command("git", "-C", path, "rev-parse", "--show-toplevel") - out, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("rev-parse: %w", err) - } - root := strings.TrimSpace(string(out)) - if root == "" { - return "", errors.New("empty git root") - } - return root, nil + if path == "" { + path = "." + } + cmd := exec.Command("git", "-C", path, "rev-parse", "--show-toplevel") + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("rev-parse: %w", err) + } + root := strings.TrimSpace(string(out)) + if root == "" { + return "", errors.New("empty git root") + } + return root, nil } // ChangedFiles lists files changed relative to HEAD, combining staged, unstaged, and untracked. func ChangedFiles(repoRoot string) ([]FileChange, error) { - // Unstaged vs index (include deletions) - unstaged, err := listNames(repoRoot, []string{"diff", "--name-only", "--diff-filter=ACDMRTUXB"}) - if err != nil { - return nil, err - } - // Staged vs HEAD - staged, err := listNames(repoRoot, []string{"diff", "--name-only", "--cached", "--diff-filter=ACDMRTUXB"}) - if err != nil { - return nil, err - } - // Untracked files - untracked, err := listNames(repoRoot, []string{"ls-files", "--others", "--exclude-standard"}) - if err != nil { - return nil, err - } - // Deletions detail - deletedUnstaged, _ := listNames(repoRoot, []string{"ls-files", "-d"}) // deleted in WT, not staged - deletedStaged, _ := listNames(repoRoot, []string{"diff", "--cached", "--name-only", "--diff-filter=D"}) + // Unstaged vs index (include deletions) + unstaged, err := listNames(repoRoot, []string{"diff", "--name-only", "--diff-filter=ACDMRTUXB"}) + if err != nil { + return nil, err + } + // Staged vs HEAD + staged, err := listNames(repoRoot, []string{"diff", "--name-only", "--cached", "--diff-filter=ACDMRTUXB"}) + if err != nil { + return nil, err + } + // Untracked files + untracked, err := listNames(repoRoot, []string{"ls-files", "--others", "--exclude-standard"}) + if err != nil { + return nil, err + } + // Deletions detail + deletedUnstaged, _ := listNames(repoRoot, []string{"ls-files", "-d"}) // deleted in WT, not staged + deletedStaged, _ := listNames(repoRoot, []string{"diff", "--cached", "--name-only", "--diff-filter=D"}) - m := map[string]*FileChange{} - mark := func(paths []string, fn func(fc *FileChange)) { - for _, p := range paths { - if p == "" { // skip any empties - continue - } - if !filepath.IsAbs(p) { - // Keep paths relative to repo root for display - } - fc := m[p] - if fc == nil { - fc = &FileChange{Path: p} - m[p] = fc - } - fn(fc) - } - } - mark(unstaged, func(fc *FileChange) { fc.Unstaged = true }) - mark(staged, func(fc *FileChange) { fc.Staged = true }) - mark(untracked, func(fc *FileChange) { fc.Untracked = true }) - mark(deletedUnstaged, func(fc *FileChange) { fc.Deleted = true; fc.Unstaged = true }) - mark(deletedStaged, func(fc *FileChange) { fc.Deleted = true; fc.Staged = true }) + m := map[string]*FileChange{} + mark := func(paths []string, fn func(fc *FileChange)) { + for _, p := range paths { + if p == "" { // skip any empties + continue + } + if !filepath.IsAbs(p) { + // Keep paths relative to repo root for display + } + fc := m[p] + if fc == nil { + fc = &FileChange{Path: p} + m[p] = fc + } + fn(fc) + } + } + mark(unstaged, func(fc *FileChange) { fc.Unstaged = true }) + mark(staged, func(fc *FileChange) { fc.Staged = true }) + mark(untracked, func(fc *FileChange) { fc.Untracked = true }) + mark(deletedUnstaged, func(fc *FileChange) { fc.Deleted = true; fc.Unstaged = true }) + mark(deletedStaged, func(fc *FileChange) { fc.Deleted = true; fc.Staged = true }) - // Determine potential binaries by probing diff header quickly - paths := make([]string, 0, len(m)) - for p := range m { - paths = append(paths, p) - } - sort.Strings(paths) - out := make([]FileChange, 0, len(paths)) - for _, p := range paths { - fc := m[p] - // Lightweight binary check: if unified diff says Binary files differ - if isBinary(repoRoot, p) { - fc.Binary = true - } - out = append(out, *fc) - } - return out, nil + // Determine potential binaries by probing diff header quickly + paths := make([]string, 0, len(m)) + for p := range m { + paths = append(paths, p) + } + sort.Strings(paths) + out := make([]FileChange, 0, len(paths)) + for _, p := range paths { + fc := m[p] + // Lightweight binary check: if unified diff says Binary files differ + if isBinary(repoRoot, p) { + fc.Binary = true + } + out = append(out, *fc) + } + return out, nil } func listNames(repoRoot string, args []string) ([]string, error) { - a := append([]string{"-C", repoRoot}, args...) - cmd := exec.Command("git", a...) - b, err := cmd.Output() - if err != nil { - // On empty sets git exits 0 with empty output; any non-0 means real error - // Return empty result for safety only when output is empty - return nil, fmt.Errorf("git %v: %w", strings.Join(args, " "), err) - } - lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n") - out := make([]string, 0, len(lines)) - for _, l := range lines { - l = strings.TrimSpace(l) - if l != "" { - out = append(out, l) - } - } - return out, nil + a := append([]string{"-C", repoRoot}, args...) + cmd := exec.Command("git", a...) + b, err := cmd.Output() + if err != nil { + // On empty sets git exits 0 with empty output; any non-0 means real error + // Return empty result for safety only when output is empty + return nil, fmt.Errorf("git %v: %w", strings.Join(args, " "), err) + } + lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n") + out := make([]string, 0, len(lines)) + for _, l := range lines { + l = strings.TrimSpace(l) + if l != "" { + out = append(out, l) + } + } + return out, nil } // DiffHEAD returns a unified diff between HEAD and the working tree for a single file. func DiffHEAD(repoRoot, path string) (string, error) { - var args []string - if isTracked(repoRoot, path) { - args = []string{"-C", repoRoot, "diff", "--no-color", "--text", "HEAD", "--", path} - } else { - // For untracked files, show diff vs /dev/null - args = []string{"-C", repoRoot, "diff", "--no-color", "--no-index", "--text", "/dev/null", path} - } - cmd := exec.Command("git", args...) - b, err := cmd.CombinedOutput() - if err != nil { - if len(b) == 0 { - return "", fmt.Errorf("git diff: %w", err) - } - } - return string(b), nil + var args []string + if isTracked(repoRoot, path) { + args = []string{"-C", repoRoot, "diff", "--no-color", "--text", "HEAD", "--", path} + } else { + // For untracked files, show diff vs /dev/null + args = []string{"-C", repoRoot, "diff", "--no-color", "--no-index", "--text", "/dev/null", path} + } + cmd := exec.Command("git", args...) + b, err := cmd.CombinedOutput() + if err != nil { + if len(b) == 0 { + return "", fmt.Errorf("git diff: %w", err) + } + } + return string(b), nil } // DiffStaged returns a unified diff between HEAD and the staged version for a single file. func DiffStaged(repoRoot, path string) (string, error) { - var args []string - if isTracked(repoRoot, path) { - args = []string{"-C", repoRoot, "diff", "--no-color", "--text", "--cached", "HEAD", "--", path} - } else { - args = []string{"-C", repoRoot, "diff", "--no-color", "--cached", "--text", "/dev/null", path} - } - cmd := exec.Command("git", args...) - b, err := cmd.CombinedOutput() - if err != nil { - if len(b) == 0 { - return "", fmt.Errorf("git diff --cached: %w", err) - } - } - return string(b), nil + var args []string + if isTracked(repoRoot, path) { + args = []string{"-C", repoRoot, "diff", "--no-color", "--text", "--cached", "HEAD", "--", path} + } else { + args = []string{"-C", repoRoot, "diff", "--no-color", "--cached", "--text", "/dev/null", path} + } + cmd := exec.Command("git", args...) + b, err := cmd.CombinedOutput() + if err != nil { + if len(b) == 0 { + return "", fmt.Errorf("git diff --cached: %w", err) + } + } + return string(b), nil } func isBinary(repoRoot, path string) bool { - var args []string - if isTracked(repoRoot, path) { - args = []string{"-C", repoRoot, "diff", "--numstat", "HEAD", "--", path} - } else { - args = []string{"-C", repoRoot, "diff", "--numstat", "--no-index", "/dev/null", path} - } - cmd := exec.Command("git", args...) - b, _ := cmd.Output() - line := strings.TrimSpace(string(b)) - if line == "" { - return false - } - // numstat returns "-\t-\tpath" for binary files - parts := strings.Split(line, "\t") - if len(parts) >= 2 && (parts[0] == "-" || parts[1] == "-") { - return true - } - // Fallback: detect textual mention just in case - return bytes.Contains(b, []byte("-\t-\t")) + var args []string + if isTracked(repoRoot, path) { + args = []string{"-C", repoRoot, "diff", "--numstat", "HEAD", "--", path} + } else { + args = []string{"-C", repoRoot, "diff", "--numstat", "--no-index", "/dev/null", path} + } + cmd := exec.Command("git", args...) + b, _ := cmd.Output() + line := strings.TrimSpace(string(b)) + if line == "" { + return false + } + // numstat returns "-\t-\tpath" for binary files + parts := strings.Split(line, "\t") + if len(parts) >= 2 && (parts[0] == "-" || parts[1] == "-") { + return true + } + // Fallback: detect textual mention just in case + return bytes.Contains(b, []byte("-\t-\t")) } func isTracked(repoRoot, path string) bool { - cmd := exec.Command("git", "-C", repoRoot, "ls-files", "--error-unmatch", "--", path) - if err := cmd.Run(); err != nil { - return false - } - return true + cmd := exec.Command("git", "-C", repoRoot, "ls-files", "--error-unmatch", "--", path) + if err := cmd.Run(); err != nil { + return false + } + return true } // StageFiles stages the provided file paths. func StageFiles(repoRoot string, paths []string) error { - if len(paths) == 0 { - return nil - } - // Use -A to ensure deletions are staged too, but still scoped to pathspecs - args := append([]string{"-C", repoRoot, "add", "-A", "--"}, paths...) - cmd := exec.Command("git", args...) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("git add: %w: %s", err, string(out)) - } - return nil + if len(paths) == 0 { + return nil + } + // Use -A to ensure deletions are staged too, but still scoped to pathspecs + args := append([]string{"-C", repoRoot, "add", "-A", "--"}, paths...) + cmd := exec.Command("git", args...) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git add: %w: %s", err, string(out)) + } + return nil } // Commit performs a git commit with the given message. func Commit(repoRoot, message string) error { - if strings.TrimSpace(message) == "" { - return errors.New("empty commit message") - } - cmd := exec.Command("git", "-C", repoRoot, "commit", "-m", message) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("git commit: %w: %s", err, string(out)) - } - return nil + if strings.TrimSpace(message) == "" { + return errors.New("empty commit message") + } + cmd := exec.Command("git", "-C", repoRoot, "commit", "-m", message) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git commit: %w: %s", err, string(out)) + } + return nil } // Push attempts to push the current branch. If no upstream is set, // it falls back to pushing to the first remote (or origin) with -u. func Push(repoRoot string) error { - // Try simple push first - cmd := exec.Command("git", "-C", repoRoot, "push") - if out, err := cmd.CombinedOutput(); err == nil { - return nil - } else { - // Fallback logic - // Get current branch - bcmd := exec.Command("git", "-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD") - bOut, bErr := bcmd.Output() - if bErr != nil { - return fmt.Errorf("git push: %w: %s", err, string(out)) - } - branch := strings.TrimSpace(string(bOut)) - // Choose remote - rcmd := exec.Command("git", "-C", repoRoot, "remote") - rOut, _ := rcmd.Output() - remotes := strings.Fields(string(rOut)) - remote := "origin" - if len(remotes) > 0 { - remote = remotes[0] - } - cmd2 := exec.Command("git", "-C", repoRoot, "push", "-u", remote, branch) - if out2, err2 := cmd2.CombinedOutput(); err2 != nil { - return fmt.Errorf("git push: %w: %s", err2, string(out2)) - } - } - return nil + // Try simple push first + cmd := exec.Command("git", "-C", repoRoot, "push") + if out, err := cmd.CombinedOutput(); err == nil { + return nil + } else { + // Fallback logic + // Get current branch + bcmd := exec.Command("git", "-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD") + bOut, bErr := bcmd.Output() + if bErr != nil { + return fmt.Errorf("git push: %w: %s", err, string(out)) + } + branch := strings.TrimSpace(string(bOut)) + // Choose remote + rcmd := exec.Command("git", "-C", repoRoot, "remote") + rOut, _ := rcmd.Output() + remotes := strings.Fields(string(rOut)) + remote := "origin" + if len(remotes) > 0 { + remote = remotes[0] + } + cmd2 := exec.Command("git", "-C", repoRoot, "push", "-u", remote, branch) + if out2, err2 := cmd2.CombinedOutput(); err2 != nil { + return fmt.Errorf("git push: %w: %s", err2, string(out2)) + } + } + return nil } // LastCommitSummary returns short hash and subject of last commit. func LastCommitSummary(repoRoot string) (string, error) { - cmd := exec.Command("git", "-C", repoRoot, "log", "-1", "--pretty=format:%h %s") - b, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("git log: %w", err) - } - return strings.TrimSpace(string(b)), nil + cmd := exec.Command("git", "-C", repoRoot, "log", "-1", "--pretty=format:%h %s") + b, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git log: %w", err) + } + return strings.TrimSpace(string(b)), nil } // FilesInLastCommit lists file paths modified in the last commit (HEAD) compared to its first parent. func FilesInLastCommit(repoRoot string) ([]string, error) { - // Ensure there is a parent commit - if err := exec.Command("git", "-C", repoRoot, "rev-parse", "--verify", "HEAD^").Run(); err != nil { - return nil, fmt.Errorf("no parent commit (cannot uncommit from initial commit): %w", err) - } - cmd := exec.Command("git", "-C", repoRoot, "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD") - b, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("git diff-tree: %w", err) - } - lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n") - out := make([]string, 0, len(lines)) - for _, l := range lines { - l = strings.TrimSpace(l) - if l != "" { - out = append(out, l) - } - } - sort.Strings(out) - return out, nil + // Ensure there is a parent commit + if err := exec.Command("git", "-C", repoRoot, "rev-parse", "--verify", "HEAD^").Run(); err != nil { + return nil, fmt.Errorf("no parent commit (cannot uncommit from initial commit): %w", err) + } + cmd := exec.Command("git", "-C", repoRoot, "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD") + b, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git diff-tree: %w", err) + } + lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n") + out := make([]string, 0, len(lines)) + for _, l := range lines { + l = strings.TrimSpace(l) + if l != "" { + out = append(out, l) + } + } + sort.Strings(out) + return out, nil } // UncommitFiles removes the selected paths from the last commit by resetting // their index state to HEAD^ and amending the commit. Working tree is left // untouched so changes reappear as unstaged modifications. func UncommitFiles(repoRoot string, paths []string) error { - if len(paths) == 0 { - return nil - } - // Verify parent exists - if err := exec.Command("git", "-C", repoRoot, "rev-parse", "--verify", "HEAD^").Run(); err != nil { - return fmt.Errorf("cannot uncommit from initial commit: %w", err) - } - // Reset index for given paths to first parent - args := append([]string{"-C", repoRoot, "reset", "-q", "HEAD^", "--"}, paths...) - if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { - return fmt.Errorf("git reset HEAD^ -- : %w: %s", err, string(out)) - } - // Amend commit without changing message - if out, err := exec.Command("git", "-C", repoRoot, "commit", "--amend", "--no-edit").CombinedOutput(); err != nil { - return fmt.Errorf("git commit --amend: %w: %s", err, string(out)) - } - return nil + if len(paths) == 0 { + return nil + } + // Verify parent exists + if err := exec.Command("git", "-C", repoRoot, "rev-parse", "--verify", "HEAD^").Run(); err != nil { + return fmt.Errorf("cannot uncommit from initial commit: %w", err) + } + // Reset index for given paths to first parent + args := append([]string{"-C", repoRoot, "reset", "-q", "HEAD^", "--"}, paths...) + if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { + return fmt.Errorf("git reset HEAD^ -- : %w: %s", err, string(out)) + } + // Amend commit without changing message + if out, err := exec.Command("git", "-C", repoRoot, "commit", "--amend", "--no-edit").CombinedOutput(); err != nil { + return fmt.Errorf("git commit --amend: %w: %s", err, string(out)) + } + return nil } // CleanPreview runs a dry-run of git clean and returns the lines that would be removed. func CleanPreview(repoRoot string, includeIgnored bool) ([]string, error) { - args := []string{"-C", repoRoot, "clean", "-d", "-n"} - if includeIgnored { - args = append(args, "-x") - } - cmd := exec.Command("git", args...) - b, err := cmd.CombinedOutput() - if err != nil { - return nil, fmt.Errorf("git clean -n: %w: %s", err, string(b)) - } - lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n") - out := make([]string, 0, len(lines)) - for _, l := range lines { - l = strings.TrimSpace(l) - if l != "" { - out = append(out, l) - } - } - return out, nil + args := []string{"-C", repoRoot, "clean", "-d", "-n"} + if includeIgnored { + args = append(args, "-x") + } + cmd := exec.Command("git", args...) + b, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("git clean -n: %w: %s", err, string(b)) + } + lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n") + out := make([]string, 0, len(lines)) + for _, l := range lines { + l = strings.TrimSpace(l) + if l != "" { + out = append(out, l) + } + } + return out, nil } // ResetHard performs `git reset --hard`. func ResetHard(repoRoot string) error { - cmd := exec.Command("git", "-C", repoRoot, "reset", "--hard") - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("git reset --hard: %w: %s", err, string(out)) - } - return nil + cmd := exec.Command("git", "-C", repoRoot, "reset", "--hard") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git reset --hard: %w: %s", err, string(out)) + } + return nil } // Clean removes untracked files/dirs: `git clean -d -f` (+ -x if includeIgnored). func Clean(repoRoot string, includeIgnored bool) error { - args := []string{"-C", repoRoot, "clean", "-d", "-f"} - if includeIgnored { - args = append(args, "-x") - } - cmd := exec.Command("git", args...) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("git clean -d -f: %w: %s", err, string(out)) - } - return nil + args := []string{"-C", repoRoot, "clean", "-d", "-f"} + if includeIgnored { + args = append(args, "-x") + } + cmd := exec.Command("git", args...) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git clean -d -f: %w: %s", err, string(out)) + } + return nil } // ResetAndClean executes reset and/or clean in order. func ResetAndClean(repoRoot string, doReset, doClean, includeIgnored bool) error { - if !doReset && !doClean { - return nil - } - if doReset { - if err := ResetHard(repoRoot); err != nil { - return err - } - } - if doClean { - if err := Clean(repoRoot, includeIgnored); err != nil { - return err - } - } - return nil + if !doReset && !doClean { + return nil + } + if doReset { + if err := ResetHard(repoRoot); err != nil { + return err + } + } + if doClean { + if err := Clean(repoRoot, includeIgnored); err != nil { + return err + } + } + return nil } // ListBranches returns local branch names and the current branch name. func ListBranches(repoRoot string) ([]string, string, error) { - // Current branch (may be "HEAD" if detached) - curCmd := exec.Command("git", "-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD") - bcur, err := curCmd.Output() - if err != nil { - return nil, "", fmt.Errorf("git rev-parse --abbrev-ref HEAD: %w", err) - } - current := strings.TrimSpace(string(bcur)) + // Current branch (may be "HEAD" if detached) + curCmd := exec.Command("git", "-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD") + bcur, err := curCmd.Output() + if err != nil { + return nil, "", fmt.Errorf("git rev-parse --abbrev-ref HEAD: %w", err) + } + current := strings.TrimSpace(string(bcur)) - // Local branches - listCmd := exec.Command("git", "-C", repoRoot, "for-each-ref", "--format=%(refname:short)", "refs/heads") - blist, err := listCmd.Output() - if err != nil { - return nil, "", fmt.Errorf("git for-each-ref: %w", err) - } - lines := strings.Split(strings.TrimRight(string(blist), "\n"), "\n") - out := make([]string, 0, len(lines)) - for _, l := range lines { - l = strings.TrimSpace(l) - if l != "" { - out = append(out, l) - } - } - sort.Strings(out) - return out, current, nil + // Local branches + listCmd := exec.Command("git", "-C", repoRoot, "for-each-ref", "--format=%(refname:short)", "refs/heads") + blist, err := listCmd.Output() + if err != nil { + return nil, "", fmt.Errorf("git for-each-ref: %w", err) + } + lines := strings.Split(strings.TrimRight(string(blist), "\n"), "\n") + out := make([]string, 0, len(lines)) + for _, l := range lines { + l = strings.TrimSpace(l) + if l != "" { + out = append(out, l) + } + } + sort.Strings(out) + return out, current, nil } // Checkout switches branches using `git checkout `. func Checkout(repoRoot, branch string) error { - if strings.TrimSpace(branch) == "" { - return errors.New("empty branch name") - } - cmd := exec.Command("git", "-C", repoRoot, "checkout", branch) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("git checkout %s: %w: %s", branch, err, string(out)) - } - return nil + if strings.TrimSpace(branch) == "" { + return errors.New("empty branch name") + } + cmd := exec.Command("git", "-C", repoRoot, "checkout", branch) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git checkout %s: %w: %s", branch, err, string(out)) + } + return nil } // CheckoutNew creates and switches to a new branch: `git checkout -b `. func CheckoutNew(repoRoot, name string) error { - if strings.TrimSpace(name) == "" { - return errors.New("empty branch name") - } - cmd := exec.Command("git", "-C", repoRoot, "checkout", "-b", name) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("git checkout -b %s: %w: %s", name, err, string(out)) - } - return nil + if strings.TrimSpace(name) == "" { + return errors.New("empty branch name") + } + cmd := exec.Command("git", "-C", repoRoot, "checkout", "-b", name) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git checkout -b %s: %w: %s", name, err, string(out)) + } + return nil } // Pull runs `git pull` in the repository. func Pull(repoRoot string) error { - _, err := PullWithOutput(repoRoot) - return err + _, err := PullWithOutput(repoRoot) + return err } // PullWithOutput runs `git pull` and returns the raw CLI output. func PullWithOutput(repoRoot string) (string, error) { - cmd := exec.Command("git", "-C", repoRoot, "pull") - out, err := cmd.CombinedOutput() - if err != nil { - return string(out), fmt.Errorf("git pull: %w: %s", err, string(out)) - } - return string(out), nil + cmd := exec.Command("git", "-C", repoRoot, "pull") + out, err := cmd.CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("git pull: %w: %s", err, string(out)) + } + return string(out), nil } // CurrentBranch returns the current branch name (or "HEAD" if detached). func CurrentBranch(repoRoot string) (string, error) { - cmd := exec.Command("git", "-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD") - b, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("git rev-parse --abbrev-ref HEAD: %w", err) - } - return strings.TrimSpace(string(b)), nil + cmd := exec.Command("git", "-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD") + b, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git rev-parse --abbrev-ref HEAD: %w", err) + } + return strings.TrimSpace(string(b)), nil } diff --git a/internal/gitx/gitx_push_test.go b/internal/gitx/gitx_push_test.go index c43d4cd..3926f04 100644 --- a/internal/gitx/gitx_push_test.go +++ b/internal/gitx/gitx_push_test.go @@ -1,45 +1,51 @@ package gitx import ( - "os" - "os/exec" - "path/filepath" - "testing" + "os" + "os/exec" + "path/filepath" + "testing" ) func TestPushToBareRemote(t *testing.T) { - dir := t.TempDir() - mustRunT(t, dir, "git", "-c", "init.defaultBranch=main", "init", "-q") - mustRunT(t, dir, "git", "config", "user.email", "test@example.com") - mustRunT(t, dir, "git", "config", "user.name", "Test User") + dir := t.TempDir() + mustRunT(t, dir, "git", "-c", "init.defaultBranch=main", "init", "-q") + mustRunT(t, dir, "git", "config", "user.email", "test@example.com") + mustRunT(t, dir, "git", "config", "user.name", "Test User") - writeT(t, filepath.Join(dir, "f.txt"), "hello\n") - mustRunT(t, dir, "git", "add", ".") - mustRunT(t, dir, "git", "commit", "-q", "-m", "init") + writeT(t, filepath.Join(dir, "f.txt"), "hello\n") + mustRunT(t, dir, "git", "add", ".") + mustRunT(t, dir, "git", "commit", "-q", "-m", "init") - remote := filepath.Join(dir, "remote.git") - mustRunT(t, dir, "git", "init", "--bare", remote) - mustRunT(t, dir, "git", "remote", "add", "origin", remote) + remote := filepath.Join(dir, "remote.git") + mustRunT(t, dir, "git", "init", "--bare", remote) + mustRunT(t, dir, "git", "remote", "add", "origin", remote) - // change and commit via our helpers - writeT(t, filepath.Join(dir, "f.txt"), "hello world\n") - if err := StageFiles(dir, []string{"f.txt"}); err != nil { t.Fatal(err) } - if err := Commit(dir, "update"); err != nil { t.Fatal(err) } - if err := Push(dir); err != nil { t.Fatalf("push failed: %v", err) } + // change and commit via our helpers + writeT(t, filepath.Join(dir, "f.txt"), "hello world\n") + if err := StageFiles(dir, []string{"f.txt"}); err != nil { + t.Fatal(err) + } + if err := Commit(dir, "update"); err != nil { + t.Fatal(err) + } + if err := Push(dir); err != nil { + t.Fatalf("push failed: %v", err) + } } func mustRunT(t *testing.T, dir string, name string, args ...string) { - t.Helper() - cmd := exec.Command(name, args...) - cmd.Dir = dir - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("command %s %v failed: %v\n%s", name, args, err, out) - } + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("command %s %v failed: %v\n%s", name, args, err, out) + } } func writeT(t *testing.T, path, content string) { - t.Helper() - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatal(err) - } + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } } diff --git a/internal/gitx/gitx_test.go b/internal/gitx/gitx_test.go index 61bd571..1bf808d 100644 --- a/internal/gitx/gitx_test.go +++ b/internal/gitx/gitx_test.go @@ -1,91 +1,90 @@ package gitx import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" ) func TestChangedFiles_AndDiffHEAD(t *testing.T) { - dir := t.TempDir() + dir := t.TempDir() - mustRun(t, dir, "git", "init", "-q") - mustRun(t, dir, "git", "config", "user.email", "test@example.com") - mustRun(t, dir, "git", "config", "user.name", "Test User") + mustRun(t, dir, "git", "init", "-q") + mustRun(t, dir, "git", "config", "user.email", "test@example.com") + mustRun(t, dir, "git", "config", "user.name", "Test User") - // initial commit - write(t, filepath.Join(dir, "f1.txt"), "one\nline\n") - write(t, filepath.Join(dir, "del.txt"), "to delete\n") - mustRun(t, dir, "git", "add", ".") - mustRun(t, dir, "git", "commit", "-q", "-m", "init") + // initial commit + write(t, filepath.Join(dir, "f1.txt"), "one\nline\n") + write(t, filepath.Join(dir, "del.txt"), "to delete\n") + mustRun(t, dir, "git", "add", ".") + mustRun(t, dir, "git", "commit", "-q", "-m", "init") - // modify f1 (unstaged), create new (untracked), delete del.txt (unstaged) - write(t, filepath.Join(dir, "f1.txt"), "one\nline changed\n") - write(t, filepath.Join(dir, "new.txt"), "brand new\n") - if err := os.Remove(filepath.Join(dir, "del.txt")); err != nil { - t.Fatal(err) - } + // modify f1 (unstaged), create new (untracked), delete del.txt (unstaged) + write(t, filepath.Join(dir, "f1.txt"), "one\nline changed\n") + write(t, filepath.Join(dir, "new.txt"), "brand new\n") + if err := os.Remove(filepath.Join(dir, "del.txt")); err != nil { + t.Fatal(err) + } - files, err := ChangedFiles(dir) - if err != nil { - t.Fatalf("ChangedFiles error: %v", err) - } - // Collect map for assertions - m := map[string]FileChange{} - for _, f := range files { - m[f.Path] = f - } - if !m["f1.txt"].Unstaged { - t.Fatalf("expected f1.txt to be unstaged modified, got %+v", m["f1.txt"]) - } - if !m["new.txt"].Untracked { - t.Fatalf("expected new.txt to be untracked, got %+v", m["new.txt"]) - } - if !(m["del.txt"].Deleted && m["del.txt"].Unstaged) { - t.Fatalf("expected del.txt to be deleted unstaged, got %+v", m["del.txt"]) - } + files, err := ChangedFiles(dir) + if err != nil { + t.Fatalf("ChangedFiles error: %v", err) + } + // Collect map for assertions + m := map[string]FileChange{} + for _, f := range files { + m[f.Path] = f + } + if !m["f1.txt"].Unstaged { + t.Fatalf("expected f1.txt to be unstaged modified, got %+v", m["f1.txt"]) + } + if !m["new.txt"].Untracked { + t.Fatalf("expected new.txt to be untracked, got %+v", m["new.txt"]) + } + if !(m["del.txt"].Deleted && m["del.txt"].Unstaged) { + t.Fatalf("expected del.txt to be deleted unstaged, got %+v", m["del.txt"]) + } - // DiffHEAD for modified file should be non-empty - d, err := DiffHEAD(dir, "f1.txt") - if err != nil { - t.Fatalf("DiffHEAD error: %v", err) - } - if !strings.Contains(d, "-line") || !strings.Contains(d, "+line changed") { - t.Fatalf("unexpected diff: %s", d) - } + // DiffHEAD for modified file should be non-empty + d, err := DiffHEAD(dir, "f1.txt") + if err != nil { + t.Fatalf("DiffHEAD error: %v", err) + } + if !strings.Contains(d, "-line") || !strings.Contains(d, "+line changed") { + t.Fatalf("unexpected diff: %s", d) + } - // Stage all three and commit using StageFiles + Commit - if err := StageFiles(dir, []string{"f1.txt", "new.txt", "del.txt"}); err != nil { - t.Fatalf("StageFiles error: %v", err) - } - if err := Commit(dir, "test commit"); err != nil { - t.Fatalf("Commit error: %v", err) - } - // After commit, ChangedFiles should be empty - files2, err := ChangedFiles(dir) - if err != nil { - t.Fatalf("ChangedFiles(2) error: %v", err) - } - if len(files2) != 0 { - t.Fatalf("expected no changes after commit, got %v", files2) - } + // Stage all three and commit using StageFiles + Commit + if err := StageFiles(dir, []string{"f1.txt", "new.txt", "del.txt"}); err != nil { + t.Fatalf("StageFiles error: %v", err) + } + if err := Commit(dir, "test commit"); err != nil { + t.Fatalf("Commit error: %v", err) + } + // After commit, ChangedFiles should be empty + files2, err := ChangedFiles(dir) + if err != nil { + t.Fatalf("ChangedFiles(2) error: %v", err) + } + if len(files2) != 0 { + t.Fatalf("expected no changes after commit, got %v", files2) + } } func mustRun(t *testing.T, dir string, name string, args ...string) { - t.Helper() - cmd := exec.Command(name, args...) - cmd.Dir = dir - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("command %s %v failed: %v\n%s", name, args, err, out) - } + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("command %s %v failed: %v\n%s", name, args, err, out) + } } func write(t *testing.T, path, content string) { - t.Helper() - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatal(err) - } + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } } - diff --git a/internal/prefs/prefs.go b/internal/prefs/prefs.go index 12e95b4..0f698fa 100644 --- a/internal/prefs/prefs.go +++ b/internal/prefs/prefs.go @@ -1,92 +1,95 @@ package prefs import ( - "fmt" - "os/exec" - "strconv" - "strings" + "fmt" + "os/exec" + "strconv" + "strings" ) // Prefs represents persisted UI preferences. type Prefs struct { - Wrap bool - WrapSet bool - SideBySide bool - SideSet bool - LeftWidth int - LeftSet bool + Wrap bool + WrapSet bool + SideBySide bool + SideSet bool + LeftWidth int + LeftSet bool } const ( - keyWrap = "diffium.wrap" - keySideBySide = "diffium.sideBySide" - keyLeftWidth = "diffium.leftWidth" + keyWrap = "diffium.wrap" + keySideBySide = "diffium.sideBySide" + keyLeftWidth = "diffium.leftWidth" ) // Load reads preferences from git local config. func Load(repoRoot string) Prefs { - var p Prefs - if s, ok := get(repoRoot, keyWrap); ok { - p.WrapSet = true - p.Wrap = parseBool(s) - } - if s, ok := get(repoRoot, keySideBySide); ok { - p.SideSet = true - p.SideBySide = parseBool(s) - } - if s, ok := get(repoRoot, keyLeftWidth); ok { - if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && n > 0 { - p.LeftSet = true - p.LeftWidth = n - } - } - return p + var p Prefs + if s, ok := get(repoRoot, keyWrap); ok { + p.WrapSet = true + p.Wrap = parseBool(s) + } + if s, ok := get(repoRoot, keySideBySide); ok { + p.SideSet = true + p.SideBySide = parseBool(s) + } + if s, ok := get(repoRoot, keyLeftWidth); ok { + if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && n > 0 { + p.LeftSet = true + p.LeftWidth = n + } + } + return p } // SaveWrap persists wrap pref. func SaveWrap(repoRoot string, v bool) error { - return set(repoRoot, keyWrap, boolStr(v)) + return set(repoRoot, keyWrap, boolStr(v)) } // SaveSideBySide persists side-by-side pref. func SaveSideBySide(repoRoot string, v bool) error { - return set(repoRoot, keySideBySide, boolStr(v)) + return set(repoRoot, keySideBySide, boolStr(v)) } // SaveLeftWidth persists left column width. func SaveLeftWidth(repoRoot string, w int) error { - if w <= 0 { return fmt.Errorf("invalid left width: %d", w) } - return set(repoRoot, keyLeftWidth, strconv.Itoa(w)) + if w <= 0 { + return fmt.Errorf("invalid left width: %d", w) + } + return set(repoRoot, keyLeftWidth, strconv.Itoa(w)) } func get(repoRoot, key string) (string, bool) { - cmd := exec.Command("git", "-C", repoRoot, "config", "--get", key) - b, err := cmd.Output() - if err != nil { - return "", false - } - return strings.TrimSpace(string(b)), true + cmd := exec.Command("git", "-C", repoRoot, "config", "--get", key) + b, err := cmd.Output() + if err != nil { + return "", false + } + return strings.TrimSpace(string(b)), true } func set(repoRoot, key, value string) error { - cmd := exec.Command("git", "-C", repoRoot, "config", "--local", key, value) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("git config %s: %w: %s", key, err, string(out)) - } - return nil + cmd := exec.Command("git", "-C", repoRoot, "config", "--local", key, value) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git config %s: %w: %s", key, err, string(out)) + } + return nil } func parseBool(s string) bool { - switch strings.ToLower(strings.TrimSpace(s)) { - case "1", "true", "yes", "on": - return true - default: - return false - } + switch strings.ToLower(strings.TrimSpace(s)) { + case "1", "true", "yes", "on": + return true + default: + return false + } } func boolStr(v bool) string { - if v { return "true" } - return "false" + if v { + return "true" + } + return "false" } - diff --git a/internal/tui/program.go b/internal/tui/program.go index a6b7110..d196e8c 100644 --- a/internal/tui/program.go +++ b/internal/tui/program.go @@ -19,260 +19,260 @@ import ( ) const ( - // Normal match: black on bright white - searchMatchStartSeq = "\x1b[30;107m" - // Current match: black on yellow - searchCurrentMatchStartSeq = "\x1b[30;43m" - // Reset all styles - searchMatchEndSeq = "\x1b[0m" + // Normal match: black on bright white + searchMatchStartSeq = "\x1b[30;107m" + // Current match: black on yellow + searchCurrentMatchStartSeq = "\x1b[30;43m" + // Reset all styles + searchMatchEndSeq = "\x1b[0m" ) type model struct { - repoRoot string - theme Theme - files []gitx.FileChange - selected int - rows []diffview.Row - sideBySide bool - diffMode string - width int - height int - status string - lastRefresh time.Time - showHelp bool - leftWidth int - savedLeftWidth int - leftOffset int - rightVP viewport.Model - rightXOffset int - wrapLines bool - - rightContent []string - - keyBuffer string - // commit wizard state - showCommit bool - commitStep int // 0: select files, 1: message, 2: confirm/progress - cwFiles []gitx.FileChange - cwSelected map[string]bool - cwIndex int - cwInput textinput.Model - cwInputActive bool - committing bool - commitErr string - commitDone bool - lastCommit string - - currentBranch string - // uncommit wizard state - showUncommit bool - ucStep int // 0: select files, 1: confirm/progress - ucFiles []gitx.FileChange // list ALL current changes (like commit wizard) - ucSelected map[string]bool // keyed by path - ucIndex int - ucEligible map[string]bool // paths that are part of HEAD (last commit) - uncommitting bool - uncommitErr string - uncommitDone bool - - // reset/clean wizard state - showResetClean bool - rcStep int // 0: select, 1: preview, 2: confirm (yellow), 3: confirm (red) - rcDoReset bool - rcDoClean bool - rcIncludeIgnored bool - rcIndex int - rcPreviewLines []string // from git clean -dn - rcPreviewErr string - rcRunning bool - rcErr string - rcDone bool - - // branch switch wizard - showBranch bool - brStep int // 0: list, 1: confirm/progress - brBranches []string - brCurrent string - brIndex int - brRunning bool - brErr string - brDone bool - brInput textinput.Model - brInputActive bool - - // pull wizard - showPull bool - plRunning bool - plErr string - plDone bool - plOutput string - - // search state - searchActive bool - searchInput textinput.Model - searchQuery string - searchMatches []int - searchIndex int + repoRoot string + theme Theme + files []gitx.FileChange + selected int + rows []diffview.Row + sideBySide bool + diffMode string + width int + height int + status string + lastRefresh time.Time + showHelp bool + leftWidth int + savedLeftWidth int + leftOffset int + rightVP viewport.Model + rightXOffset int + wrapLines bool + + rightContent []string + + keyBuffer string + // commit wizard state + showCommit bool + commitStep int // 0: select files, 1: message, 2: confirm/progress + cwFiles []gitx.FileChange + cwSelected map[string]bool + cwIndex int + cwInput textinput.Model + cwInputActive bool + committing bool + commitErr string + commitDone bool + lastCommit string + + currentBranch string + // uncommit wizard state + showUncommit bool + ucStep int // 0: select files, 1: confirm/progress + ucFiles []gitx.FileChange // list ALL current changes (like commit wizard) + ucSelected map[string]bool // keyed by path + ucIndex int + ucEligible map[string]bool // paths that are part of HEAD (last commit) + uncommitting bool + uncommitErr string + uncommitDone bool + + // reset/clean wizard state + showResetClean bool + rcStep int // 0: select, 1: preview, 2: confirm (yellow), 3: confirm (red) + rcDoReset bool + rcDoClean bool + rcIncludeIgnored bool + rcIndex int + rcPreviewLines []string // from git clean -dn + rcPreviewErr string + rcRunning bool + rcErr string + rcDone bool + + // branch switch wizard + showBranch bool + brStep int // 0: list, 1: confirm/progress + brBranches []string + brCurrent string + brIndex int + brRunning bool + brErr string + brDone bool + brInput textinput.Model + brInputActive bool + + // pull wizard + showPull bool + plRunning bool + plErr string + plDone bool + plOutput string + + // search state + searchActive bool + searchInput textinput.Model + searchQuery string + searchMatches []int + searchIndex int } // messages type tickMsg struct{} type filesMsg struct { - files []gitx.FileChange - err error + files []gitx.FileChange + err error } type diffMsg struct { - path string - rows []diffview.Row - err error + path string + rows []diffview.Row + err error } // Run instantiates and runs the Bubble Tea program. func Run(repoRoot string) error { - m := model{repoRoot: repoRoot, sideBySide: true, diffMode: "head", theme: loadThemeFromRepo(repoRoot)} - p := tea.NewProgram(m, tea.WithAltScreen()) - if _, err := p.Run(); err != nil { - return err - } - return nil + m := model{repoRoot: repoRoot, sideBySide: true, diffMode: "head", theme: loadThemeFromRepo(repoRoot)} + p := tea.NewProgram(m, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + return err + } + return nil } func (m model) Init() tea.Cmd { - return tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), loadCurrentBranch(m.repoRoot), loadPrefs(m.repoRoot), tickOnce()) + return tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), loadCurrentBranch(m.repoRoot), loadPrefs(m.repoRoot), tickOnce()) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: + switch msg := msg.(type) { + case tea.KeyMsg: if m.searchActive { return m.handleSearchKeys(msg) } - if m.showHelp { - switch msg.String() { - case "q": - return m, tea.Quit - case "h", "esc": - (&m).closeSearch() - m.showHelp = false - return m, m.recalcViewport() - default: - return m, nil - } - } - if m.showCommit { - return m.handleCommitKeys(msg) - } - if m.showUncommit { - return m.handleUncommitKeys(msg) - } - if m.showResetClean { - return m.handleResetCleanKeys(msg) - } - if m.showBranch { - return m.handleBranchKeys(msg) - } - if m.showPull { - return m.handlePullKeys(msg) - } - - key := msg.String() - - if isNumericKey(key){ - m.keyBuffer += key - return m, nil - } - - if !isNumericKey(key) && !isMovementKey(key){ - m.keyBuffer = "" - } - - switch key { - case "ctrl+c", "q": - return m, tea.Quit - case "h": + if m.showHelp { + switch msg.String() { + case "q": + return m, tea.Quit + case "h", "esc": + (&m).closeSearch() + m.showHelp = false + return m, m.recalcViewport() + default: + return m, nil + } + } + if m.showCommit { + return m.handleCommitKeys(msg) + } + if m.showUncommit { + return m.handleUncommitKeys(msg) + } + if m.showResetClean { + return m.handleResetCleanKeys(msg) + } + if m.showBranch { + return m.handleBranchKeys(msg) + } + if m.showPull { + return m.handlePullKeys(msg) + } + + key := msg.String() + + if isNumericKey(key) { + m.keyBuffer += key + return m, nil + } + + if !isNumericKey(key) && !isMovementKey(key) { + m.keyBuffer = "" + } + + switch key { + case "ctrl+c", "q": + return m, tea.Quit + case "h": (&m).closeSearch() - m.showHelp = true - return m, m.recalcViewport() - case "c": - // Open commit wizard + m.showHelp = true + return m, m.recalcViewport() + case "c": + // Open commit wizard (&m).closeSearch() - m.openCommitWizard() - return m, m.recalcViewport() - case "u": - // Open uncommit wizard - m.openUncommitWizard() - return m, tea.Batch(loadUncommitFiles(m.repoRoot), loadUncommitEligible(m.repoRoot), m.recalcViewport()) - case "b": - m.openBranchWizard() - return m, tea.Batch(loadBranches(m.repoRoot), m.recalcViewport()) - case "p": - m.openPullWizard() - return m, m.recalcViewport() - case "R": - // Open reset/clean wizard - m.openResetCleanWizard() - return m, m.recalcViewport() - case "/": - (&m).openSearch() - return m, m.recalcViewport() - case "<", "H": - if m.leftWidth == 0 { - m.leftWidth = m.width / 3 - } - m.leftWidth -= 2 - if m.leftWidth < 20 { - m.leftWidth = 20 - } - _ = prefs.SaveLeftWidth(m.repoRoot, m.leftWidth) - return m, m.recalcViewport() - case ">", "L": - if m.leftWidth == 0 { - m.leftWidth = m.width / 3 - } - m.leftWidth += 2 - maxLeft := m.width - 20 - if maxLeft < 20 { - maxLeft = 20 - } - if m.leftWidth > maxLeft { - m.leftWidth = maxLeft - } - _ = prefs.SaveLeftWidth(m.repoRoot, m.leftWidth) - return m, m.recalcViewport() - case "j", "down": - if len(m.files) == 0 { - return m, nil - } - if m.selected < len(m.files)-1 { - if(m.keyBuffer == ""){ + m.openCommitWizard() + return m, m.recalcViewport() + case "u": + // Open uncommit wizard + m.openUncommitWizard() + return m, tea.Batch(loadUncommitFiles(m.repoRoot), loadUncommitEligible(m.repoRoot), m.recalcViewport()) + case "b": + m.openBranchWizard() + return m, tea.Batch(loadBranches(m.repoRoot), m.recalcViewport()) + case "p": + m.openPullWizard() + return m, m.recalcViewport() + case "R": + // Open reset/clean wizard + m.openResetCleanWizard() + return m, m.recalcViewport() + case "/": + (&m).openSearch() + return m, m.recalcViewport() + case "<", "H": + if m.leftWidth == 0 { + m.leftWidth = m.width / 3 + } + m.leftWidth -= 2 + if m.leftWidth < 20 { + m.leftWidth = 20 + } + _ = prefs.SaveLeftWidth(m.repoRoot, m.leftWidth) + return m, m.recalcViewport() + case ">", "L": + if m.leftWidth == 0 { + m.leftWidth = m.width / 3 + } + m.leftWidth += 2 + maxLeft := m.width - 20 + if maxLeft < 20 { + maxLeft = 20 + } + if m.leftWidth > maxLeft { + m.leftWidth = maxLeft + } + _ = prefs.SaveLeftWidth(m.repoRoot, m.leftWidth) + return m, m.recalcViewport() + case "j", "down": + if len(m.files) == 0 { + return m, nil + } + if m.selected < len(m.files)-1 { + if m.keyBuffer == "" { m.selected++ - }else{ + } else { jump, err := strconv.Atoi(m.keyBuffer) - if err != nil{ + if err != nil { m.selected++ } else { m.selected += jump - m.selected = min(m.selected, len(m.files) - 1) + m.selected = min(m.selected, len(m.files)-1) } m.keyBuffer = "" } - m.rows = nil - // Reset scroll for new file - m.rightVP.GotoTop() - return m, tea.Batch(loadDiff(m.repoRoot, m.files[m.selected].Path, m.diffMode), m.recalcViewport()) - } - case "k", "up": - if len(m.files) == 0 { + m.rows = nil + // Reset scroll for new file + m.rightVP.GotoTop() + return m, tea.Batch(loadDiff(m.repoRoot, m.files[m.selected].Path, m.diffMode), m.recalcViewport()) + } + case "k", "up": + if len(m.files) == 0 { m.keyBuffer = "" - } + } if m.selected > 0 { - if(m.keyBuffer == ""){ + if m.keyBuffer == "" { m.selected-- - }else{ + } else { jump, err := strconv.Atoi(m.keyBuffer) - if err != nil{ + if err != nil { m.selected-- } else { m.selected -= jump @@ -280,763 +280,831 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.keyBuffer = "" } - m.rows = nil - m.rightVP.GotoTop() - return m, tea.Batch(loadDiff(m.repoRoot, m.files[m.selected].Path, m.diffMode), m.recalcViewport()) - } - case "g": - if len(m.files) > 0 { - m.selected = 0 - m.rows = nil - m.rightVP.GotoTop() - return m, tea.Batch(loadDiff(m.repoRoot, m.files[m.selected].Path, m.diffMode), m.recalcViewport()) - } - case "G": - if len(m.files) > 0 { - m.selected = len(m.files) - 1 - m.rows = nil - m.rightVP.GotoTop() - return m, tea.Batch(loadDiff(m.repoRoot, m.files[m.selected].Path, m.diffMode), m.recalcViewport()) - } - case "[": - // Page up left pane - vis := m.rightVP.Height - if vis <= 0 { vis = 10 } - step := vis - 1 - if step < 1 { step = 1 } - newOffset := m.leftOffset - step - if newOffset < 0 { newOffset = 0 } - // Keep selection visible within new viewport - if m.selected < newOffset { - newOffset = m.selected - } - maxStart := len(m.files) - vis - if maxStart < 0 { maxStart = 0 } - if newOffset > maxStart { newOffset = maxStart } - m.leftOffset = newOffset - return m, m.recalcViewport() - case "]": - // Page down left pane - vis := m.rightVP.Height - if vis <= 0 { vis = 10 } - step := vis - 1 - if step < 1 { step = 1 } - maxStart := len(m.files) - vis - if maxStart < 0 { maxStart = 0 } - newOffset := m.leftOffset + step - if newOffset > maxStart { newOffset = maxStart } - // Keep selection visible within new viewport - if m.selected >= newOffset+vis { - newOffset = m.selected - vis + 1 - if newOffset < 0 { newOffset = 0 } - } - m.leftOffset = newOffset - return m, m.recalcViewport() - case "n": - return m, (&m).advanceSearch(1) - case "N": - return m, (&m).advanceSearch(-1) - case "r": - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadCurrentDiff(m)) - case "s": - m.sideBySide = !m.sideBySide - _ = prefs.SaveSideBySide(m.repoRoot, m.sideBySide) - return m, m.recalcViewport() - case "t": - if m.diffMode == "head" { - m.diffMode = "staged" - } else { - m.diffMode = "head" - } - m.rows = nil - m.selected = 0 - m.rightVP.GotoTop() - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), m.recalcViewport()) - case "w": - // Toggle wrap in diff pane - m.wrapLines = !m.wrapLines - if m.wrapLines { - m.rightXOffset = 0 - } - _ = prefs.SaveWrap(m.repoRoot, m.wrapLines) - return m, m.recalcViewport() - // Horizontal scroll for right pane - case "left", "{": - if m.wrapLines { return m, nil } - if m.rightXOffset > 0 { - m.rightXOffset -= 4 - if m.rightXOffset < 0 { m.rightXOffset = 0 } - return m, m.recalcViewport() - } - return m, nil - case "right", "}": - if m.wrapLines { return m, nil } - m.rightXOffset += 4 - return m, m.recalcViewport() - case "home": - if m.rightXOffset != 0 { - m.rightXOffset = 0 - return m, m.recalcViewport() - } - return m, nil - // Right pane scrolling - case "pgdown": - m.rightVP.PageDown() - return m, nil - case "pgup": - m.rightVP.PageUp() - return m, nil - case "J", "ctrl+d": - m.rightVP.HalfPageDown() - return m, nil - case "K", "ctrl+u": - m.rightVP.HalfPageUp() - return m, nil - case "ctrl+e": - m.rightVP.LineDown(1) - return m, nil - case "ctrl+y": - m.rightVP.LineUp(1) - return m, nil - } - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - if m.leftWidth == 0 { - // Initialize left width once - if m.savedLeftWidth > 0 { - m.leftWidth = m.savedLeftWidth - } else { - m.leftWidth = m.width / 3 - } - if m.leftWidth < 24 { - m.leftWidth = 24 - } - // Also ensure it doesn't exceed available - maxLeft := m.width - 20 - if maxLeft < 20 { maxLeft = 20 } - if m.leftWidth > maxLeft { m.leftWidth = maxLeft } - } - return m, m.recalcViewport() - case tickMsg: - // Periodic refresh - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadCurrentBranch(m.repoRoot), tickOnce()) - case filesMsg: - if msg.err != nil { - m.status = fmt.Sprintf("status error: %v", msg.err) - return m, nil - } - // Stable-sort files by path for deterministic UI - sort.Slice(msg.files, func(i, j int) bool { return msg.files[i].Path < msg.files[j].Path }) - - // Preserve selection by path if possible - var selPath string - if len(m.files) > 0 && m.selected >= 0 && m.selected < len(m.files) { - selPath = m.files[m.selected].Path - } - m.files = msg.files - m.lastRefresh = time.Now() - - // Reselect - m.selected = 0 - if selPath != "" { - for i, f := range m.files { - if f.Path == selPath { - m.selected = i - break - } - } - } - // Load diff for selected if exists - if len(m.files) > 0 { - return m, tea.Batch(loadDiff(m.repoRoot, m.files[m.selected].Path, m.diffMode), m.recalcViewport()) - } - m.rows = nil - return m, m.recalcViewport() - case diffMsg: - if msg.err != nil { - m.status = fmt.Sprintf("diff error: %v", msg.err) - m.rows = nil - return m, m.recalcViewport() - } - // Only update if this diff is for the currently selected file - if len(m.files) > 0 && m.files[m.selected].Path == msg.path { - m.rows = msg.rows - } - return m, m.recalcViewport() - case lastCommitMsg: - if msg.err == nil { - m.lastCommit = msg.summary - } - return m, nil - case currentBranchMsg: - if msg.err == nil { - m.currentBranch = msg.name - } - return m, nil - case prefsMsg: - if msg.err == nil { - if msg.p.SideSet { m.sideBySide = msg.p.SideBySide } - if msg.p.WrapSet { m.wrapLines = msg.p.Wrap; if m.wrapLines { m.rightXOffset = 0 } } - if msg.p.LeftSet { - m.savedLeftWidth = msg.p.LeftWidth - // If we already know the window size, apply immediately. - if m.width > 0 { - lw := m.savedLeftWidth - if lw < 24 { lw = 24 } - maxLeft := m.width - 20 - if maxLeft < 20 { maxLeft = 20 } - if lw > maxLeft { lw = maxLeft } - m.leftWidth = lw - return m, m.recalcViewport() - } - } - } - return m, nil - case pullResultMsg: - m.plRunning = false - // Always show result output in overlay; close with enter/esc - m.plOutput = msg.out - if msg.err != nil { - m.plErr = msg.err.Error() - } else { - m.plErr = "" - } - m.plDone = true - m.showPull = true - // Refresh repo state after pull - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), loadCurrentBranch(m.repoRoot), m.recalcViewport()) - case branchListMsg: - if msg.err != nil { - m.brErr = msg.err.Error() - m.brBranches = nil - m.brCurrent = "" - m.brIndex = 0 - return m, m.recalcViewport() - } - m.brBranches = msg.names - m.brCurrent = msg.current - m.brErr = "" - // Focus current if present - m.brIndex = 0 - for i, n := range m.brBranches { - if n == m.brCurrent { m.brIndex = i; break } - } - return m, m.recalcViewport() - case branchResultMsg: - m.brRunning = false - if msg.err != nil { - m.brErr = msg.err.Error() - m.brDone = false - return m, m.recalcViewport() - } - m.brErr = "" - m.brDone = true - m.showBranch = false - // refresh files after checkout - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), loadCurrentBranch(m.repoRoot), m.recalcViewport()) - case rcPreviewMsg: - m.rcPreviewErr = "" - if msg.err != nil { - m.rcPreviewErr = msg.err.Error() - m.rcPreviewLines = nil - } else { - m.rcPreviewLines = msg.lines - } - return m, m.recalcViewport() - case rcResultMsg: - m.rcRunning = false - if msg.err != nil { - m.rcErr = msg.err.Error() - m.rcDone = false - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), m.recalcViewport()) - } - m.rcErr = "" - m.rcDone = true - m.showResetClean = false - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), m.recalcViewport()) - case commitProgressMsg: - m.committing = true - m.commitErr = "" - return m, nil - case commitResultMsg: - m.committing = false - if msg.err != nil { - m.commitErr = msg.err.Error() - m.commitDone = false - // refresh even on error (commit may have succeeded but push failed) - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), m.recalcViewport()) - } else { - m.commitErr = "" - m.commitDone = true - m.showCommit = false - // refresh changes and last commit - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), m.recalcViewport()) - } - return m, nil - case uncommitFilesMsg: - if msg.err != nil { - m.uncommitErr = msg.err.Error() - m.ucFiles = nil - m.ucSelected = map[string]bool{} - m.ucIndex = 0 - return m, m.recalcViewport() - } - m.ucFiles = msg.files - m.ucSelected = map[string]bool{} - for _, f := range m.ucFiles { - m.ucSelected[f.Path] = true - } - m.ucIndex = 0 - return m, m.recalcViewport() - case uncommitEligibleMsg: - if msg.err != nil { - // No parent commit or other issue; treat as no eligible files. - m.ucEligible = map[string]bool{} - return m, m.recalcViewport() - } - m.ucEligible = map[string]bool{} - for _, p := range msg.paths { - m.ucEligible[p] = true - } - return m, m.recalcViewport() - case uncommitResultMsg: - m.uncommitting = false - if msg.err != nil { - m.uncommitErr = msg.err.Error() - m.uncommitDone = false - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), m.recalcViewport()) - } - m.uncommitErr = "" - m.uncommitDone = true - m.showUncommit = false - return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), m.recalcViewport()) - } - return m, nil + m.rows = nil + m.rightVP.GotoTop() + return m, tea.Batch(loadDiff(m.repoRoot, m.files[m.selected].Path, m.diffMode), m.recalcViewport()) + } + case "g": + if len(m.files) > 0 { + m.selected = 0 + m.rows = nil + m.rightVP.GotoTop() + return m, tea.Batch(loadDiff(m.repoRoot, m.files[m.selected].Path, m.diffMode), m.recalcViewport()) + } + case "G": + if len(m.files) > 0 { + m.selected = len(m.files) - 1 + m.rows = nil + m.rightVP.GotoTop() + return m, tea.Batch(loadDiff(m.repoRoot, m.files[m.selected].Path, m.diffMode), m.recalcViewport()) + } + case "[": + // Page up left pane + vis := m.rightVP.Height + if vis <= 0 { + vis = 10 + } + step := vis - 1 + if step < 1 { + step = 1 + } + newOffset := m.leftOffset - step + if newOffset < 0 { + newOffset = 0 + } + // Keep selection visible within new viewport + if m.selected < newOffset { + newOffset = m.selected + } + maxStart := len(m.files) - vis + if maxStart < 0 { + maxStart = 0 + } + if newOffset > maxStart { + newOffset = maxStart + } + m.leftOffset = newOffset + return m, m.recalcViewport() + case "]": + // Page down left pane + vis := m.rightVP.Height + if vis <= 0 { + vis = 10 + } + step := vis - 1 + if step < 1 { + step = 1 + } + maxStart := len(m.files) - vis + if maxStart < 0 { + maxStart = 0 + } + newOffset := m.leftOffset + step + if newOffset > maxStart { + newOffset = maxStart + } + // Keep selection visible within new viewport + if m.selected >= newOffset+vis { + newOffset = m.selected - vis + 1 + if newOffset < 0 { + newOffset = 0 + } + } + m.leftOffset = newOffset + return m, m.recalcViewport() + case "n": + return m, (&m).advanceSearch(1) + case "N": + return m, (&m).advanceSearch(-1) + case "r": + return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadCurrentDiff(m)) + case "s": + m.sideBySide = !m.sideBySide + _ = prefs.SaveSideBySide(m.repoRoot, m.sideBySide) + return m, m.recalcViewport() + case "t": + if m.diffMode == "head" { + m.diffMode = "staged" + } else { + m.diffMode = "head" + } + m.rows = nil + m.selected = 0 + m.rightVP.GotoTop() + return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), m.recalcViewport()) + case "w": + // Toggle wrap in diff pane + m.wrapLines = !m.wrapLines + if m.wrapLines { + m.rightXOffset = 0 + } + _ = prefs.SaveWrap(m.repoRoot, m.wrapLines) + return m, m.recalcViewport() + // Horizontal scroll for right pane + case "left", "{": + if m.wrapLines { + return m, nil + } + if m.rightXOffset > 0 { + m.rightXOffset -= 4 + if m.rightXOffset < 0 { + m.rightXOffset = 0 + } + return m, m.recalcViewport() + } + return m, nil + case "right", "}": + if m.wrapLines { + return m, nil + } + m.rightXOffset += 4 + return m, m.recalcViewport() + case "home": + if m.rightXOffset != 0 { + m.rightXOffset = 0 + return m, m.recalcViewport() + } + return m, nil + // Right pane scrolling + case "pgdown": + m.rightVP.PageDown() + return m, nil + case "pgup": + m.rightVP.PageUp() + return m, nil + case "J", "ctrl+d": + m.rightVP.HalfPageDown() + return m, nil + case "K", "ctrl+u": + m.rightVP.HalfPageUp() + return m, nil + case "ctrl+e": + m.rightVP.LineDown(1) + return m, nil + case "ctrl+y": + m.rightVP.LineUp(1) + return m, nil + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + if m.leftWidth == 0 { + // Initialize left width once + if m.savedLeftWidth > 0 { + m.leftWidth = m.savedLeftWidth + } else { + m.leftWidth = m.width / 3 + } + if m.leftWidth < 24 { + m.leftWidth = 24 + } + // Also ensure it doesn't exceed available + maxLeft := m.width - 20 + if maxLeft < 20 { + maxLeft = 20 + } + if m.leftWidth > maxLeft { + m.leftWidth = maxLeft + } + } + return m, m.recalcViewport() + case tickMsg: + // Periodic refresh + return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadCurrentBranch(m.repoRoot), tickOnce()) + case filesMsg: + if msg.err != nil { + m.status = fmt.Sprintf("status error: %v", msg.err) + return m, nil + } + // Stable-sort files by path for deterministic UI + sort.Slice(msg.files, func(i, j int) bool { return msg.files[i].Path < msg.files[j].Path }) + + // Preserve selection by path if possible + var selPath string + if len(m.files) > 0 && m.selected >= 0 && m.selected < len(m.files) { + selPath = m.files[m.selected].Path + } + m.files = msg.files + m.lastRefresh = time.Now() + + // Reselect + m.selected = 0 + if selPath != "" { + for i, f := range m.files { + if f.Path == selPath { + m.selected = i + break + } + } + } + // Load diff for selected if exists + if len(m.files) > 0 { + return m, tea.Batch(loadDiff(m.repoRoot, m.files[m.selected].Path, m.diffMode), m.recalcViewport()) + } + m.rows = nil + return m, m.recalcViewport() + case diffMsg: + if msg.err != nil { + m.status = fmt.Sprintf("diff error: %v", msg.err) + m.rows = nil + return m, m.recalcViewport() + } + // Only update if this diff is for the currently selected file + if len(m.files) > 0 && m.files[m.selected].Path == msg.path { + m.rows = msg.rows + } + return m, m.recalcViewport() + case lastCommitMsg: + if msg.err == nil { + m.lastCommit = msg.summary + } + return m, nil + case currentBranchMsg: + if msg.err == nil { + m.currentBranch = msg.name + } + return m, nil + case prefsMsg: + if msg.err == nil { + if msg.p.SideSet { + m.sideBySide = msg.p.SideBySide + } + if msg.p.WrapSet { + m.wrapLines = msg.p.Wrap + if m.wrapLines { + m.rightXOffset = 0 + } + } + if msg.p.LeftSet { + m.savedLeftWidth = msg.p.LeftWidth + // If we already know the window size, apply immediately. + if m.width > 0 { + lw := m.savedLeftWidth + if lw < 24 { + lw = 24 + } + maxLeft := m.width - 20 + if maxLeft < 20 { + maxLeft = 20 + } + if lw > maxLeft { + lw = maxLeft + } + m.leftWidth = lw + return m, m.recalcViewport() + } + } + } + return m, nil + case pullResultMsg: + m.plRunning = false + // Always show result output in overlay; close with enter/esc + m.plOutput = msg.out + if msg.err != nil { + m.plErr = msg.err.Error() + } else { + m.plErr = "" + } + m.plDone = true + m.showPull = true + // Refresh repo state after pull + return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), loadCurrentBranch(m.repoRoot), m.recalcViewport()) + case branchListMsg: + if msg.err != nil { + m.brErr = msg.err.Error() + m.brBranches = nil + m.brCurrent = "" + m.brIndex = 0 + return m, m.recalcViewport() + } + m.brBranches = msg.names + m.brCurrent = msg.current + m.brErr = "" + // Focus current if present + m.brIndex = 0 + for i, n := range m.brBranches { + if n == m.brCurrent { + m.brIndex = i + break + } + } + return m, m.recalcViewport() + case branchResultMsg: + m.brRunning = false + if msg.err != nil { + m.brErr = msg.err.Error() + m.brDone = false + return m, m.recalcViewport() + } + m.brErr = "" + m.brDone = true + m.showBranch = false + // refresh files after checkout + return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), loadCurrentBranch(m.repoRoot), m.recalcViewport()) + case rcPreviewMsg: + m.rcPreviewErr = "" + if msg.err != nil { + m.rcPreviewErr = msg.err.Error() + m.rcPreviewLines = nil + } else { + m.rcPreviewLines = msg.lines + } + return m, m.recalcViewport() + case rcResultMsg: + m.rcRunning = false + if msg.err != nil { + m.rcErr = msg.err.Error() + m.rcDone = false + return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), m.recalcViewport()) + } + m.rcErr = "" + m.rcDone = true + m.showResetClean = false + return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), m.recalcViewport()) + case commitProgressMsg: + m.committing = true + m.commitErr = "" + return m, nil + case commitResultMsg: + m.committing = false + if msg.err != nil { + m.commitErr = msg.err.Error() + m.commitDone = false + // refresh even on error (commit may have succeeded but push failed) + return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), m.recalcViewport()) + } else { + m.commitErr = "" + m.commitDone = true + m.showCommit = false + // refresh changes and last commit + return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), m.recalcViewport()) + } + return m, nil + case uncommitFilesMsg: + if msg.err != nil { + m.uncommitErr = msg.err.Error() + m.ucFiles = nil + m.ucSelected = map[string]bool{} + m.ucIndex = 0 + return m, m.recalcViewport() + } + m.ucFiles = msg.files + m.ucSelected = map[string]bool{} + for _, f := range m.ucFiles { + m.ucSelected[f.Path] = true + } + m.ucIndex = 0 + return m, m.recalcViewport() + case uncommitEligibleMsg: + if msg.err != nil { + // No parent commit or other issue; treat as no eligible files. + m.ucEligible = map[string]bool{} + return m, m.recalcViewport() + } + m.ucEligible = map[string]bool{} + for _, p := range msg.paths { + m.ucEligible[p] = true + } + return m, m.recalcViewport() + case uncommitResultMsg: + m.uncommitting = false + if msg.err != nil { + m.uncommitErr = msg.err.Error() + m.uncommitDone = false + return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), m.recalcViewport()) + } + m.uncommitErr = "" + m.uncommitDone = true + m.showUncommit = false + return m, tea.Batch(loadFiles(m.repoRoot, m.diffMode), loadLastCommit(m.repoRoot), m.recalcViewport()) + } + return m, nil } func (m model) View() string { - // Layout - if m.width == 0 || m.height == 0 { - return "Loading..." - } - - // Column widths - leftW := m.leftWidth - if leftW < 20 { - leftW = 20 - } - rightW := m.width - leftW - 1 // vertical divider column - if rightW < 1 { - rightW = 1 - } - sep := m.theme.DividerText("│") - - // Row 1: top bar with right-aligned current branch - leftTop := "Changes | " + m.topRightTitle() - rightTop := m.currentBranch - if rightTop != "" { - rightTop = lipgloss.NewStyle().Faint(true).Render(rightTop) - } - // Compose with right part visible and left truncated if needed - { - rightW := lipgloss.Width(rightTop) - if rightW >= m.width { - leftTop = ansi.Truncate(rightTop, m.width, "…") - } else { - avail := m.width - rightW - 1 - if lipgloss.Width(leftTop) > avail { - leftTop = ansi.Truncate(leftTop, avail, "…") - } else if lipgloss.Width(leftTop) < avail { - leftTop = leftTop + strings.Repeat(" ", avail-lipgloss.Width(leftTop)) - } - leftTop = leftTop + " " + rightTop - } - } - // Row 2: horizontal rule - hr := m.theme.DividerText(strings.Repeat("─", m.width)) - - // Row 3: columns, then optional overlays, then bottom rule + bar - var overlay []string - if m.showHelp { - overlay = m.helpOverlayLines(m.width) - } - if m.showCommit { - overlay = append(overlay, m.commitOverlayLines(m.width)...) - } - if m.showUncommit { - overlay = append(overlay, m.uncommitOverlayLines(m.width)...) - } - if m.showResetClean { - overlay = append(overlay, m.resetCleanOverlayLines(m.width)...) - } - if m.showBranch { - overlay = append(overlay, m.branchOverlayLines(m.width)...) - } - if m.showPull { - overlay = append(overlay, m.pullOverlayLines(m.width)...) - } - if m.searchActive { - overlay = append(overlay, m.searchOverlayLines(m.width)...) - } - overlayH := len(overlay) - - contentHeight := m.height - 4 - overlayH // top + top rule + bottom rule + bottom bar - if contentHeight < 1 { - contentHeight = 1 - } - - leftLines := m.leftBodyLines(contentHeight) - // Right viewport already holds content and scroll state; ensure dims - // The viewport content is updated via recalcViewport() - m.rightVP.Width = rightW - m.rightVP.Height = contentHeight - rightView := m.rightVP.View() - rightLines := strings.Split(rightView, "\n") - maxLines := contentHeight - - var b strings.Builder - b.WriteString(leftTop) - b.WriteByte('\n') - b.WriteString(hr) - b.WriteByte('\n') - for i := 0; i < maxLines; i++ { - var l, r string - if i < len(leftLines) { - l = padToWidth(leftLines[i], leftW) - } else { - l = strings.Repeat(" ", leftW) - } - if i < len(rightLines) { - r = rightLines[i] - } else { - r = "" - } - b.WriteString(l) - b.WriteString(sep) - b.WriteString(padToWidth(r, rightW)) - if i < maxLines-1 { - b.WriteByte('\n') - } - } - // Optional overlay right above bottom bar - if overlayH > 0 { - b.WriteByte('\n') - for i, line := range overlay { - b.WriteString(padToWidth(line, m.width)) - if i < overlayH-1 { - b.WriteByte('\n') - } - } - } - // Bottom rule and bottom bar - b.WriteByte('\n') - b.WriteString(strings.Repeat("─", m.width)) - b.WriteByte('\n') - b.WriteString(m.bottomBar()) - return b.String() + // Layout + if m.width == 0 || m.height == 0 { + return "Loading..." + } + + // Column widths + leftW := m.leftWidth + if leftW < 20 { + leftW = 20 + } + rightW := m.width - leftW - 1 // vertical divider column + if rightW < 1 { + rightW = 1 + } + sep := m.theme.DividerText("│") + + // Row 1: top bar with right-aligned current branch + leftTop := "Changes | " + m.topRightTitle() + rightTop := m.currentBranch + if rightTop != "" { + rightTop = lipgloss.NewStyle().Faint(true).Render(rightTop) + } + // Compose with right part visible and left truncated if needed + { + rightW := lipgloss.Width(rightTop) + if rightW >= m.width { + leftTop = ansi.Truncate(rightTop, m.width, "…") + } else { + avail := m.width - rightW - 1 + if lipgloss.Width(leftTop) > avail { + leftTop = ansi.Truncate(leftTop, avail, "…") + } else if lipgloss.Width(leftTop) < avail { + leftTop = leftTop + strings.Repeat(" ", avail-lipgloss.Width(leftTop)) + } + leftTop = leftTop + " " + rightTop + } + } + // Row 2: horizontal rule + hr := m.theme.DividerText(strings.Repeat("─", m.width)) + + // Row 3: columns, then optional overlays, then bottom rule + bar + var overlay []string + if m.showHelp { + overlay = m.helpOverlayLines(m.width) + } + if m.showCommit { + overlay = append(overlay, m.commitOverlayLines(m.width)...) + } + if m.showUncommit { + overlay = append(overlay, m.uncommitOverlayLines(m.width)...) + } + if m.showResetClean { + overlay = append(overlay, m.resetCleanOverlayLines(m.width)...) + } + if m.showBranch { + overlay = append(overlay, m.branchOverlayLines(m.width)...) + } + if m.showPull { + overlay = append(overlay, m.pullOverlayLines(m.width)...) + } + if m.searchActive { + overlay = append(overlay, m.searchOverlayLines(m.width)...) + } + overlayH := len(overlay) + + contentHeight := m.height - 4 - overlayH // top + top rule + bottom rule + bottom bar + if contentHeight < 1 { + contentHeight = 1 + } + + leftLines := m.leftBodyLines(contentHeight) + // Right viewport already holds content and scroll state; ensure dims + // The viewport content is updated via recalcViewport() + m.rightVP.Width = rightW + m.rightVP.Height = contentHeight + rightView := m.rightVP.View() + rightLines := strings.Split(rightView, "\n") + maxLines := contentHeight + + var b strings.Builder + b.WriteString(leftTop) + b.WriteByte('\n') + b.WriteString(hr) + b.WriteByte('\n') + for i := 0; i < maxLines; i++ { + var l, r string + if i < len(leftLines) { + l = padToWidth(leftLines[i], leftW) + } else { + l = strings.Repeat(" ", leftW) + } + if i < len(rightLines) { + r = rightLines[i] + } else { + r = "" + } + b.WriteString(l) + b.WriteString(sep) + b.WriteString(padToWidth(r, rightW)) + if i < maxLines-1 { + b.WriteByte('\n') + } + } + // Optional overlay right above bottom bar + if overlayH > 0 { + b.WriteByte('\n') + for i, line := range overlay { + b.WriteString(padToWidth(line, m.width)) + if i < overlayH-1 { + b.WriteByte('\n') + } + } + } + // Bottom rule and bottom bar + b.WriteByte('\n') + b.WriteString(strings.Repeat("─", m.width)) + b.WriteByte('\n') + b.WriteString(m.bottomBar()) + return b.String() } func (m model) leftBodyLines(max int) []string { - lines := make([]string, 0, max) - if len(m.files) == 0 { - lines = append(lines, "No changes detected") - return lines - } - start := m.leftOffset - if start < 0 { start = 0 } - if start > len(m.files) { start = len(m.files) } - end := start + max - if end > len(m.files) { end = len(m.files) } - for i := start; i < end; i++ { - f := m.files[i] - marker := " " - if i == m.selected { - marker = "> " - } - status := fileStatusLabel(f) - line := fmt.Sprintf("%s%s %s", marker, status, f.Path) - lines = append(lines, line) - } - return lines + lines := make([]string, 0, max) + if len(m.files) == 0 { + lines = append(lines, "No changes detected") + return lines + } + start := m.leftOffset + if start < 0 { + start = 0 + } + if start > len(m.files) { + start = len(m.files) + } + end := start + max + if end > len(m.files) { + end = len(m.files) + } + for i := start; i < end; i++ { + f := m.files[i] + marker := " " + if i == m.selected { + marker = "> " + } + status := fileStatusLabel(f) + line := fmt.Sprintf("%s%s %s", marker, status, f.Path) + lines = append(lines, line) + } + return lines } func (m model) rightBodyLines(max, width int) []string { - lines := make([]string, 0, max) - if len(m.files) == 0 { - return lines - } - if m.files[m.selected].Binary { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("(Binary file; no text diff)")) - return lines - } - if m.rows == nil { - lines = append(lines, "Loading diff…") - return lines - } - if m.sideBySide { - colsW := (width - 1) / 2 - if colsW < 10 { - colsW = 10 - } - mid := m.theme.DividerText("│") - for _, r := range m.rows { - switch r.Kind { - case diffview.RowHunk: - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render(r.Meta)) - case diffview.RowMeta: - // skip - default: - l := padToWidth(m.colorizeLeft(r), colsW) - rr := padToWidth(m.colorizeRight(r), colsW) - lines = append(lines, l+mid+rr) - } - if len(lines) >= max { - break - } - } - } else { - for _, r := range m.rows { - switch r.Kind { - case diffview.RowHunk: - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render(r.Meta)) - case diffview.RowContext: - lines = append(lines, " "+r.Left) - case diffview.RowAdd: - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("34")).Render("+ "+r.Right)) - case diffview.RowDel: - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("- "+r.Left)) - case diffview.RowReplace: - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("- "+r.Left)) - if len(lines) >= max { break } - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("34")).Render("+ "+r.Right)) - } - if len(lines) >= max { - break - } - } - } - return lines + lines := make([]string, 0, max) + if len(m.files) == 0 { + return lines + } + if m.files[m.selected].Binary { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("(Binary file; no text diff)")) + return lines + } + if m.rows == nil { + lines = append(lines, "Loading diff…") + return lines + } + if m.sideBySide { + colsW := (width - 1) / 2 + if colsW < 10 { + colsW = 10 + } + mid := m.theme.DividerText("│") + for _, r := range m.rows { + switch r.Kind { + case diffview.RowHunk: + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render(r.Meta)) + case diffview.RowMeta: + // skip + default: + l := padToWidth(m.colorizeLeft(r), colsW) + rr := padToWidth(m.colorizeRight(r), colsW) + lines = append(lines, l+mid+rr) + } + if len(lines) >= max { + break + } + } + } else { + for _, r := range m.rows { + switch r.Kind { + case diffview.RowHunk: + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render(r.Meta)) + case diffview.RowContext: + lines = append(lines, " "+r.Left) + case diffview.RowAdd: + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("34")).Render("+ "+r.Right)) + case diffview.RowDel: + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("- "+r.Left)) + case diffview.RowReplace: + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("- "+r.Left)) + if len(lines) >= max { + break + } + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("34")).Render("+ "+r.Right)) + } + if len(lines) >= max { + break + } + } + } + return lines } func (m model) topRightTitle() string { - if len(m.files) == 0 { - return fmt.Sprintf("[%s]", strings.ToUpper(m.diffMode)) - } - header := fmt.Sprintf("%s (%s) [%s]", m.files[m.selected].Path, fileStatusLabel(m.files[m.selected]), strings.ToUpper(m.diffMode)) - return header + if len(m.files) == 0 { + return fmt.Sprintf("[%s]", strings.ToUpper(m.diffMode)) + } + header := fmt.Sprintf("%s (%s) [%s]", m.files[m.selected].Path, fileStatusLabel(m.files[m.selected]), strings.ToUpper(m.diffMode)) + return header } func (m model) bottomBar() string { leftText := "h: help" - if m.keyBuffer != ""{ + if m.keyBuffer != "" { leftText = m.keyBuffer } - if m.lastCommit != "" { - leftText += " | last: " + m.lastCommit - } - leftStyled := lipgloss.NewStyle().Faint(true).Render(leftText) - right := lipgloss.NewStyle().Faint(true).Render("refreshed: " + m.lastRefresh.Format("15:04:05")) - w := m.width - // Ensure the right part is always visible; truncate left if needed - rightW := lipgloss.Width(right) - if rightW >= w { - // Degenerate case: screen too small; just show right truncated - return ansi.Truncate(right, w, "…") - } - avail := w - rightW - 1 // 1 space gap - leftRendered := leftStyled - if lipgloss.Width(leftRendered) > avail { - leftRendered = ansi.Truncate(leftRendered, avail, "…") - } else if lipgloss.Width(leftRendered) < avail { - leftRendered = leftRendered + strings.Repeat(" ", avail-lipgloss.Width(leftRendered)) - } - return leftRendered + " " + right + if m.lastCommit != "" { + leftText += " | last: " + m.lastCommit + } + leftStyled := lipgloss.NewStyle().Faint(true).Render(leftText) + right := lipgloss.NewStyle().Faint(true).Render("refreshed: " + m.lastRefresh.Format("15:04:05")) + w := m.width + // Ensure the right part is always visible; truncate left if needed + rightW := lipgloss.Width(right) + if rightW >= w { + // Degenerate case: screen too small; just show right truncated + return ansi.Truncate(right, w, "…") + } + avail := w - rightW - 1 // 1 space gap + leftRendered := leftStyled + if lipgloss.Width(leftRendered) > avail { + leftRendered = ansi.Truncate(leftRendered, avail, "…") + } else if lipgloss.Width(leftRendered) < avail { + leftRendered = leftRendered + strings.Repeat(" ", avail-lipgloss.Width(leftRendered)) + } + return leftRendered + " " + right } func fileStatusLabel(f gitx.FileChange) string { - var tags []string - if f.Deleted { - tags = append(tags, "D") - } - if f.Untracked { - tags = append(tags, "U") - } - if f.Staged { - tags = append(tags, "S") - } - if f.Unstaged { - tags = append(tags, "M") - } - if len(tags) == 0 { - return "-" - } - return strings.Join(tags, "") + var tags []string + if f.Deleted { + tags = append(tags, "D") + } + if f.Untracked { + tags = append(tags, "U") + } + if f.Staged { + tags = append(tags, "S") + } + if f.Unstaged { + tags = append(tags, "M") + } + if len(tags) == 0 { + return "-" + } + return strings.Join(tags, "") } func loadFiles(repoRoot, diffMode string) tea.Cmd { - return func() tea.Msg { - allFiles, err := gitx.ChangedFiles(repoRoot) - if err != nil { - return filesMsg{files: nil, err: err} - } - - // Filter files based on diff mode - var filteredFiles []gitx.FileChange - for _, file := range allFiles { - if diffMode == "staged" { - if file.Staged { - filteredFiles = append(filteredFiles, file) - } - } else { - if file.Unstaged || file.Untracked { - filteredFiles = append(filteredFiles, file) - } - } - } - - return filesMsg{files: filteredFiles, err: nil} - } + return func() tea.Msg { + allFiles, err := gitx.ChangedFiles(repoRoot) + if err != nil { + return filesMsg{files: nil, err: err} + } + + // Filter files based on diff mode + var filteredFiles []gitx.FileChange + for _, file := range allFiles { + if diffMode == "staged" { + if file.Staged { + filteredFiles = append(filteredFiles, file) + } + } else { + if file.Unstaged || file.Untracked { + filteredFiles = append(filteredFiles, file) + } + } + } + + return filesMsg{files: filteredFiles, err: nil} + } } func loadDiff(repoRoot, path, diffMode string) tea.Cmd { - return func() tea.Msg { - var d string - var err error - if diffMode == "staged" { - d, err = gitx.DiffStaged(repoRoot, path) - } else { - d, err = gitx.DiffHEAD(repoRoot, path) - } - if err != nil { - return diffMsg{path: path, err: err} - } - rows := diffview.BuildRowsFromUnified(d) - return diffMsg{path: path, rows: rows} - } + return func() tea.Msg { + var d string + var err error + if diffMode == "staged" { + d, err = gitx.DiffStaged(repoRoot, path) + } else { + d, err = gitx.DiffHEAD(repoRoot, path) + } + if err != nil { + return diffMsg{path: path, err: err} + } + rows := diffview.BuildRowsFromUnified(d) + return diffMsg{path: path, rows: rows} + } } func loadCurrentDiff(m model) tea.Cmd { - if len(m.files) == 0 { - return nil - } - return loadDiff(m.repoRoot, m.files[m.selected].Path, m.diffMode) + if len(m.files) == 0 { + return nil + } + return loadDiff(m.repoRoot, m.files[m.selected].Path, m.diffMode) } func tickOnce() tea.Cmd { - return tea.Tick(time.Second, func(time.Time) tea.Msg { return tickMsg{} }) + return tea.Tick(time.Second, func(time.Time) tea.Msg { return tickMsg{} }) } func padToWidth(s string, w int) string { - width := lipgloss.Width(s) - if width == w { - return s - } - if width < w { - return s + strings.Repeat(" ", w-width) - } - return ansi.Truncate(s, w, "…") + width := lipgloss.Width(s) + if width == w { + return s + } + if width < w { + return s + strings.Repeat(" ", w-width) + } + return ansi.Truncate(s, w, "…") } func (m model) viewHelp() string { - // Full-screen simple help panel - var b strings.Builder - title := lipgloss.NewStyle().Bold(true).Render("Diffium Help") - lines := []string{ - "", - "j/k or arrows Move selection", - "J/K, PgDn/PgUp Scroll diff", - " or H/L Adjust left pane width", - "[/] Page left file list", - "{/} Horizontal scroll (diff)", - "b Switch branch (open wizard)", - "s Toggle side-by-side / inline", - "r Refresh now", - "g / G Top / Bottom", - "h or Esc Close help", - "q Quit", - "", - "Press 'h' or 'Esc' to close.", - } - // Center-ish: add left padding - pad := 4 - if m.width > 60 { - pad = (m.width - 60) / 2 - if pad < 4 { pad = 4 } - } - leftPad := strings.Repeat(" ", pad) - fmt.Fprintln(&b, leftPad+title) - for _, l := range lines { - fmt.Fprintln(&b, leftPad+l) - } - // Bottom hint - hint := lipgloss.NewStyle().Faint(true).Render("h: help refreshed: " + m.lastRefresh.Format("15:04:05")) - fmt.Fprintln(&b) - fmt.Fprint(&b, padToWidth(hint, m.width)) - return b.String() + // Full-screen simple help panel + var b strings.Builder + title := lipgloss.NewStyle().Bold(true).Render("Diffium Help") + lines := []string{ + "", + "j/k or arrows Move selection", + "J/K, PgDn/PgUp Scroll diff", + " or H/L Adjust left pane width", + "[/] Page left file list", + "{/} Horizontal scroll (diff)", + "b Switch branch (open wizard)", + "s Toggle side-by-side / inline", + "r Refresh now", + "g / G Top / Bottom", + "h or Esc Close help", + "q Quit", + "", + "Press 'h' or 'Esc' to close.", + } + // Center-ish: add left padding + pad := 4 + if m.width > 60 { + pad = (m.width - 60) / 2 + if pad < 4 { + pad = 4 + } + } + leftPad := strings.Repeat(" ", pad) + fmt.Fprintln(&b, leftPad+title) + for _, l := range lines { + fmt.Fprintln(&b, leftPad+l) + } + // Bottom hint + hint := lipgloss.NewStyle().Faint(true).Render("h: help refreshed: " + m.lastRefresh.Format("15:04:05")) + fmt.Fprintln(&b) + fmt.Fprint(&b, padToWidth(hint, m.width)) + return b.String() } // recalcViewport recalculates right viewport size and content based on current state. func (m *model) recalcViewport() tea.Cmd { - if m.width == 0 || m.height == 0 { - return nil - } - leftW := m.leftWidth - if leftW < 20 { - leftW = 20 - } - rightW := m.width - leftW - 1 - if rightW < 1 { - rightW = 1 - } - overlayH := 0 - if m.showHelp { - overlayH += len(m.helpOverlayLines(m.width)) - } - if m.showCommit { - overlayH += len(m.commitOverlayLines(m.width)) - } - if m.showUncommit { - overlayH += len(m.uncommitOverlayLines(m.width)) - } - if m.showResetClean { - overlayH += len(m.resetCleanOverlayLines(m.width)) - } - if m.showBranch { - overlayH += len(m.branchOverlayLines(m.width)) - } - if m.showPull { - overlayH += len(m.pullOverlayLines(m.width)) - } - if m.searchActive { - overlayH += len(m.searchOverlayLines(m.width)) - } - contentHeight := m.height - 4 - overlayH - if contentHeight < 1 { - contentHeight = 1 - } - // Clamp leftOffset and keep selection visible in left pane - vis := contentHeight - if vis < 1 { vis = 1 } - if m.leftOffset < 0 { m.leftOffset = 0 } - maxStart := len(m.files) - vis - if maxStart < 0 { maxStart = 0 } - if m.leftOffset > maxStart { m.leftOffset = maxStart } - if len(m.files) > 0 { - if m.selected < m.leftOffset { - m.leftOffset = m.selected - } else if m.selected >= m.leftOffset+vis { - m.leftOffset = m.selected - vis + 1 - if m.leftOffset < 0 { m.leftOffset = 0 } - } - if m.leftOffset > maxStart { m.leftOffset = maxStart } - } - - // Set dimensions - m.rightVP.Width = rightW - m.rightVP.Height = contentHeight - // Build content - m.rightContent = m.rightBodyLinesAll(rightW) + if m.width == 0 || m.height == 0 { + return nil + } + leftW := m.leftWidth + if leftW < 20 { + leftW = 20 + } + rightW := m.width - leftW - 1 + if rightW < 1 { + rightW = 1 + } + overlayH := 0 + if m.showHelp { + overlayH += len(m.helpOverlayLines(m.width)) + } + if m.showCommit { + overlayH += len(m.commitOverlayLines(m.width)) + } + if m.showUncommit { + overlayH += len(m.uncommitOverlayLines(m.width)) + } + if m.showResetClean { + overlayH += len(m.resetCleanOverlayLines(m.width)) + } + if m.showBranch { + overlayH += len(m.branchOverlayLines(m.width)) + } + if m.showPull { + overlayH += len(m.pullOverlayLines(m.width)) + } + if m.searchActive { + overlayH += len(m.searchOverlayLines(m.width)) + } + contentHeight := m.height - 4 - overlayH + if contentHeight < 1 { + contentHeight = 1 + } + // Clamp leftOffset and keep selection visible in left pane + vis := contentHeight + if vis < 1 { + vis = 1 + } + if m.leftOffset < 0 { + m.leftOffset = 0 + } + maxStart := len(m.files) - vis + if maxStart < 0 { + maxStart = 0 + } + if m.leftOffset > maxStart { + m.leftOffset = maxStart + } + if len(m.files) > 0 { + if m.selected < m.leftOffset { + m.leftOffset = m.selected + } else if m.selected >= m.leftOffset+vis { + m.leftOffset = m.selected - vis + 1 + if m.leftOffset < 0 { + m.leftOffset = 0 + } + } + if m.leftOffset > maxStart { + m.leftOffset = maxStart + } + } + + // Set dimensions + m.rightVP.Width = rightW + m.rightVP.Height = contentHeight + // Build content + m.rightContent = m.rightBodyLinesAll(rightW) // Update search matches + highlight state if m.searchQuery == "" { @@ -1047,938 +1115,1037 @@ func (m *model) recalcViewport() tea.Cmd { } m.refreshSearchHighlights() - return nil + return nil } // helpOverlayLines returns the bottom overlay lines (without trailing newline). func (m model) helpOverlayLines(width int) []string { - if !m.showHelp { - return nil - } - // Header - title := lipgloss.NewStyle().Bold(true).Render("Help — press 'h' or Esc to close") - // Keys - keys := []string{ - "j/k or arrows Move selection", - "J/K, PgDn/PgUp Scroll diff", - "{/} Horizontal scroll (diff)", - " or H/L Adjust left pane width", - "[/] Page left file list", - "b Switch branch (open wizard)", - "p Pull (open wizard)", - "u Uncommit (open wizard)", - "R Reset/Clean (open wizard)", - "c Commit & push (open wizard)", - "s Toggle side-by-side / inline", - "t Toggle HEAD / staged diffs", - "w Toggle line wrap (diff)", - "r Refresh now", - "g / G Top / Bottom", - "q Quit", - } - lines := make([]string, 0, 2+len(keys)) - // Overlay top rule - lines = append(lines, strings.Repeat("─", width)) - lines = append(lines, title) - for _, k := range keys { - lines = append(lines, k) - } - return lines + if !m.showHelp { + return nil + } + // Header + title := lipgloss.NewStyle().Bold(true).Render("Help — press 'h' or Esc to close") + // Keys + keys := []string{ + "j/k or arrows Move selection", + "J/K, PgDn/PgUp Scroll diff", + "{/} Horizontal scroll (diff)", + " or H/L Adjust left pane width", + "[/] Page left file list", + "b Switch branch (open wizard)", + "p Pull (open wizard)", + "u Uncommit (open wizard)", + "R Reset/Clean (open wizard)", + "c Commit & push (open wizard)", + "s Toggle side-by-side / inline", + "t Toggle HEAD / staged diffs", + "w Toggle line wrap (diff)", + "r Refresh now", + "g / G Top / Bottom", + "q Quit", + } + lines := make([]string, 0, 2+len(keys)) + // Overlay top rule + lines = append(lines, strings.Repeat("─", width)) + lines = append(lines, title) + for _, k := range keys { + lines = append(lines, k) + } + return lines } func (m model) commitOverlayLines(width int) []string { - if !m.showCommit { - return nil - } - lines := make([]string, 0, 64) - lines = append(lines, strings.Repeat("─", width)) - switch m.commitStep { - case 0: - title := lipgloss.NewStyle().Bold(true).Render("Commit — Select files (space: toggle, a: all, enter: continue, esc: cancel)") - lines = append(lines, title) - if len(m.cwFiles) == 0 { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No changes to commit")) - return lines - } - for i, f := range m.cwFiles { - cur := " " - if i == m.cwIndex { - cur = "> " - } - mark := "[ ]" - if m.cwSelected[f.Path] { - mark = "[x]" - } - status := fileStatusLabel(f) - lines = append(lines, fmt.Sprintf("%s%s %s %s", cur, mark, status, f.Path)) - } - case 1: - mode := "action" - if m.cwInputActive { mode = "input" } - title := lipgloss.NewStyle().Bold(true).Render("Commit — Message (i: input, enter: continue, b: back, esc: " + map[bool]string{true:"leave input", false:"cancel"}[m.cwInputActive] + ") ["+mode+"]") - lines = append(lines, title) - lines = append(lines, m.cwInput.View()) - case 2: - title := lipgloss.NewStyle().Bold(true).Render("Commit — Confirm (y/enter: commit & push, b: back, esc: cancel)") - lines = append(lines, title) - // Summary - sel := m.selectedPaths() - lines = append(lines, fmt.Sprintf("Files: %d", len(sel))) - lines = append(lines, "Message: "+m.cwInput.Value()) - if m.committing { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Committing & pushing...")) - } - if m.commitErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.commitErr) - } - } - return lines + if !m.showCommit { + return nil + } + lines := make([]string, 0, 64) + lines = append(lines, strings.Repeat("─", width)) + switch m.commitStep { + case 0: + title := lipgloss.NewStyle().Bold(true).Render("Commit — Select files (space: toggle, a: all, enter: continue, esc: cancel)") + lines = append(lines, title) + if len(m.cwFiles) == 0 { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No changes to commit")) + return lines + } + for i, f := range m.cwFiles { + cur := " " + if i == m.cwIndex { + cur = "> " + } + mark := "[ ]" + if m.cwSelected[f.Path] { + mark = "[x]" + } + status := fileStatusLabel(f) + lines = append(lines, fmt.Sprintf("%s%s %s %s", cur, mark, status, f.Path)) + } + case 1: + mode := "action" + if m.cwInputActive { + mode = "input" + } + title := lipgloss.NewStyle().Bold(true).Render("Commit — Message (i: input, enter: continue, b: back, esc: " + map[bool]string{true: "leave input", false: "cancel"}[m.cwInputActive] + ") [" + mode + "]") + lines = append(lines, title) + lines = append(lines, m.cwInput.View()) + case 2: + title := lipgloss.NewStyle().Bold(true).Render("Commit — Confirm (y/enter: commit & push, b: back, esc: cancel)") + lines = append(lines, title) + // Summary + sel := m.selectedPaths() + lines = append(lines, fmt.Sprintf("Files: %d", len(sel))) + lines = append(lines, "Message: "+m.cwInput.Value()) + if m.committing { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Committing & pushing...")) + } + if m.commitErr != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.commitErr) + } + } + return lines } // --- Uncommit wizard --- type uncommitFilesMsg struct { - files []gitx.FileChange - err error + files []gitx.FileChange + err error } // --- Reset/Clean wizard --- -type rcPreviewMsg struct{ - lines []string - err error +type rcPreviewMsg struct { + lines []string + err error } // --- Branch switch wizard --- -type branchListMsg struct{ - names []string - current string - err error +type branchListMsg struct { + names []string + current string + err error } // --- Pull wizard --- -type pullResultMsg struct{ out string; err error } +type pullResultMsg struct { + out string + err error +} func (m *model) openPullWizard() { - m.showPull = true - m.plRunning = false - m.plErr = "" - m.plDone = false - m.plOutput = "" + m.showPull = true + m.plRunning = false + m.plErr = "" + m.plDone = false + m.plOutput = "" } func (m model) pullOverlayLines(width int) []string { - if !m.showPull { return nil } - lines := make([]string, 0, 32) - lines = append(lines, strings.Repeat("─", width)) - if m.plDone { - title := lipgloss.NewStyle().Bold(true).Render("Pull — Result (enter/esc: close)") - lines = append(lines, title) - if m.plErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.plErr) - } - if m.plOutput != "" { - // Show up to 12 lines of output - outLines := strings.Split(strings.TrimRight(m.plOutput, "\n"), "\n") - max := 12 - for i, l := range outLines { - if i >= max { break } - lines = append(lines, l) - } - if len(outLines) > max { - lines = append(lines, fmt.Sprintf("… and %d more", len(outLines)-max)) - } - } else if m.plErr == "" { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("(no output)")) - } - } else { - title := lipgloss.NewStyle().Bold(true).Render("Pull — Confirm (y/enter: pull, esc: cancel)") - lines = append(lines, title) - if m.plRunning { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Pulling…")) - } - if m.plErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.plErr) - } - } - return lines + if !m.showPull { + return nil + } + lines := make([]string, 0, 32) + lines = append(lines, strings.Repeat("─", width)) + if m.plDone { + title := lipgloss.NewStyle().Bold(true).Render("Pull — Result (enter/esc: close)") + lines = append(lines, title) + if m.plErr != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.plErr) + } + if m.plOutput != "" { + // Show up to 12 lines of output + outLines := strings.Split(strings.TrimRight(m.plOutput, "\n"), "\n") + max := 12 + for i, l := range outLines { + if i >= max { + break + } + lines = append(lines, l) + } + if len(outLines) > max { + lines = append(lines, fmt.Sprintf("… and %d more", len(outLines)-max)) + } + } else if m.plErr == "" { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("(no output)")) + } + } else { + title := lipgloss.NewStyle().Bold(true).Render("Pull — Confirm (y/enter: pull, esc: cancel)") + lines = append(lines, title) + if m.plRunning { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Pulling…")) + } + if m.plErr != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.plErr) + } + } + return lines } func (m model) handlePullKeys(key tea.KeyMsg) (tea.Model, tea.Cmd) { - switch key.String() { - case "esc": - if m.plDone && !m.plRunning { - m.showPull = false - m.plDone = false - m.plOutput = "" - m.plErr = "" - return m, m.recalcViewport() - } - if !m.plRunning { m.showPull = false; return m, m.recalcViewport() } - return m, nil - case "y", "enter": - if m.plDone && !m.plRunning { - // Close results view - m.showPull = false - m.plDone = false - m.plOutput = "" - m.plErr = "" - return m, m.recalcViewport() - } - if !m.plRunning && !m.plDone { - m.plRunning = true - m.plErr = "" - return m, runPull(m.repoRoot) - } - return m, nil - } - return m, nil + switch key.String() { + case "esc": + if m.plDone && !m.plRunning { + m.showPull = false + m.plDone = false + m.plOutput = "" + m.plErr = "" + return m, m.recalcViewport() + } + if !m.plRunning { + m.showPull = false + return m, m.recalcViewport() + } + return m, nil + case "y", "enter": + if m.plDone && !m.plRunning { + // Close results view + m.showPull = false + m.plDone = false + m.plOutput = "" + m.plErr = "" + return m, m.recalcViewport() + } + if !m.plRunning && !m.plDone { + m.plRunning = true + m.plErr = "" + return m, runPull(m.repoRoot) + } + return m, nil + } + return m, nil } func runPull(repoRoot string) tea.Cmd { - return func() tea.Msg { - out, err := gitx.PullWithOutput(repoRoot) - if err != nil { - return pullResultMsg{out: out, err: err} - } - return pullResultMsg{out: out, err: nil} - } + return func() tea.Msg { + out, err := gitx.PullWithOutput(repoRoot) + if err != nil { + return pullResultMsg{out: out, err: err} + } + return pullResultMsg{out: out, err: nil} + } } type branchResultMsg struct{ err error } func loadBranches(repoRoot string) tea.Cmd { - return func() tea.Msg { - names, current, err := gitx.ListBranches(repoRoot) - return branchListMsg{names: names, current: current, err: err} - } + return func() tea.Msg { + names, current, err := gitx.ListBranches(repoRoot) + return branchListMsg{names: names, current: current, err: err} + } } func (m *model) openBranchWizard() { - m.showBranch = true - m.brStep = 0 - m.brBranches = nil - m.brCurrent = "" - m.brIndex = 0 - m.brRunning = false - m.brErr = "" - m.brDone = false - m.brInput = textinput.Model{} - m.brInputActive = false + m.showBranch = true + m.brStep = 0 + m.brBranches = nil + m.brCurrent = "" + m.brIndex = 0 + m.brRunning = false + m.brErr = "" + m.brDone = false + m.brInput = textinput.Model{} + m.brInputActive = false } func (m model) branchOverlayLines(width int) []string { - if !m.showBranch { return nil } - lines := make([]string, 0, 128) - lines = append(lines, strings.Repeat("─", width)) - switch m.brStep { - case 0: - title := lipgloss.NewStyle().Bold(true).Render("Branches — Select (enter: continue, n: new, esc: cancel)") - lines = append(lines, title) - if m.brErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.brErr) - } - if len(m.brBranches) == 0 && m.brErr == "" { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("Loading branches…")) - return lines - } - for i, n := range m.brBranches { - cur := " " - if i == m.brIndex { cur = "> " } - mark := " " - if n == m.brCurrent { mark = "[*]" } - lines = append(lines, fmt.Sprintf("%s%s %s", cur, mark, n)) - } - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("[*] current branch")) - case 1: - title := lipgloss.NewStyle().Bold(true).Render("Checkout — Confirm (y/enter: checkout, b: back, esc: cancel)") - lines = append(lines, title) - if len(m.brBranches) > 0 { - name := m.brBranches[m.brIndex] - lines = append(lines, fmt.Sprintf("Branch: %s", name)) - } - if m.brRunning { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Checking out…")) - } - if m.brErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.brErr) - } - case 2: - // New branch: name input - mode := "action" - if m.brInputActive { mode = "input" } - title := lipgloss.NewStyle().Bold(true).Render("New Branch — Name (i: input, enter: continue, b: back, esc: " + map[bool]string{true:"leave input", false:"cancel"}[m.brInputActive] + ") ["+mode+"]") - lines = append(lines, title) - lines = append(lines, m.brInput.View()) - if m.brErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.brErr) - } - case 3: - // New branch: confirm - title := lipgloss.NewStyle().Bold(true).Render("New Branch — Confirm (y/enter: create, b: back, esc: cancel)") - lines = append(lines, title) - lines = append(lines, "Name: "+m.brInput.Value()) - if m.brRunning { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Creating…")) - } - if m.brErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.brErr) - } - } - return lines + if !m.showBranch { + return nil + } + lines := make([]string, 0, 128) + lines = append(lines, strings.Repeat("─", width)) + switch m.brStep { + case 0: + title := lipgloss.NewStyle().Bold(true).Render("Branches — Select (enter: continue, n: new, esc: cancel)") + lines = append(lines, title) + if m.brErr != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.brErr) + } + if len(m.brBranches) == 0 && m.brErr == "" { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("Loading branches…")) + return lines + } + for i, n := range m.brBranches { + cur := " " + if i == m.brIndex { + cur = "> " + } + mark := " " + if n == m.brCurrent { + mark = "[*]" + } + lines = append(lines, fmt.Sprintf("%s%s %s", cur, mark, n)) + } + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("[*] current branch")) + case 1: + title := lipgloss.NewStyle().Bold(true).Render("Checkout — Confirm (y/enter: checkout, b: back, esc: cancel)") + lines = append(lines, title) + if len(m.brBranches) > 0 { + name := m.brBranches[m.brIndex] + lines = append(lines, fmt.Sprintf("Branch: %s", name)) + } + if m.brRunning { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Checking out…")) + } + if m.brErr != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.brErr) + } + case 2: + // New branch: name input + mode := "action" + if m.brInputActive { + mode = "input" + } + title := lipgloss.NewStyle().Bold(true).Render("New Branch — Name (i: input, enter: continue, b: back, esc: " + map[bool]string{true: "leave input", false: "cancel"}[m.brInputActive] + ") [" + mode + "]") + lines = append(lines, title) + lines = append(lines, m.brInput.View()) + if m.brErr != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.brErr) + } + case 3: + // New branch: confirm + title := lipgloss.NewStyle().Bold(true).Render("New Branch — Confirm (y/enter: create, b: back, esc: cancel)") + lines = append(lines, title) + lines = append(lines, "Name: "+m.brInput.Value()) + if m.brRunning { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Creating…")) + } + if m.brErr != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.brErr) + } + } + return lines } func (m model) handleBranchKeys(key tea.KeyMsg) (tea.Model, tea.Cmd) { - switch m.brStep { - case 0: - switch key.String() { - case "esc": - m.showBranch = false - return m, m.recalcViewport() - case "j", "down": - if len(m.brBranches) > 0 && m.brIndex < len(m.brBranches)-1 { m.brIndex++ } - return m, nil - case "k", "up": - if m.brIndex > 0 { m.brIndex-- } - return m, nil - case "n": - // New branch flow - ti := textinput.New() - ti.Placeholder = "Branch name" - ti.Prompt = "> " - // Start in action mode; 'i' toggles input focus - m.brInput = ti - m.brInputActive = false - m.brStep = 2 - m.brErr = "" - m.brDone = false - m.brRunning = false - return m, m.recalcViewport() - case "enter": - if len(m.brBranches) == 0 { return m, nil } - m.brStep = 1 - m.brErr = "" - m.brDone = false - m.brRunning = false - return m, m.recalcViewport() - } - case 1: - switch key.String() { - case "esc": - if !m.brRunning { m.showBranch = false; return m, m.recalcViewport() } - return m, nil - case "b": - if !m.brRunning && !m.brDone { m.brStep = 0; return m, m.recalcViewport() } - return m, nil - case "y", "enter": - if !m.brRunning && !m.brDone { - if len(m.brBranches) == 0 { return m, nil } - name := m.brBranches[m.brIndex] - m.brRunning = true - m.brErr = "" - return m, runCheckout(m.repoRoot, name) - } - return m, nil - } - case 2: // new branch name - switch key.String() { - case "esc": - if m.brInputActive { - m.brInputActive = false - return m, m.recalcViewport() - } - m.showBranch = false - return m, m.recalcViewport() - case "i": - if !m.brInputActive { - m.brInputActive = true - m.brInput.Focus() - return m, m.recalcViewport() - } - // already active, treat as input - case "b": - if !m.brInputActive { - m.brStep = 0 - return m, m.recalcViewport() - } - // else forward to input - case "enter": - if !m.brInputActive { - if strings.TrimSpace(m.brInput.Value()) == "" { - m.brErr = "empty branch name" - return m, nil - } - m.brStep = 3 - m.brErr = "" - m.brDone = false - m.brRunning = false - return m, m.recalcViewport() - } - // in input mode, forward to text input - } - if m.brInputActive { - var cmd tea.Cmd - m.brInput, cmd = m.brInput.Update(key) - return m, cmd - } - return m, nil - case 3: // confirm new branch - switch key.String() { - case "esc": - if !m.brRunning { m.showBranch = false; return m, m.recalcViewport() } - return m, nil - case "b": - if !m.brRunning && !m.brDone { m.brStep = 2; return m, m.recalcViewport() } - return m, nil - case "y", "enter": - if !m.brRunning && !m.brDone { - name := strings.TrimSpace(m.brInput.Value()) - if name == "" { - m.brErr = "empty branch name" - return m, nil - } - m.brRunning = true - m.brErr = "" - return m, runCreateBranch(m.repoRoot, name) - } - return m, nil - } - } - return m, nil + switch m.brStep { + case 0: + switch key.String() { + case "esc": + m.showBranch = false + return m, m.recalcViewport() + case "j", "down": + if len(m.brBranches) > 0 && m.brIndex < len(m.brBranches)-1 { + m.brIndex++ + } + return m, nil + case "k", "up": + if m.brIndex > 0 { + m.brIndex-- + } + return m, nil + case "n": + // New branch flow + ti := textinput.New() + ti.Placeholder = "Branch name" + ti.Prompt = "> " + // Start in action mode; 'i' toggles input focus + m.brInput = ti + m.brInputActive = false + m.brStep = 2 + m.brErr = "" + m.brDone = false + m.brRunning = false + return m, m.recalcViewport() + case "enter": + if len(m.brBranches) == 0 { + return m, nil + } + m.brStep = 1 + m.brErr = "" + m.brDone = false + m.brRunning = false + return m, m.recalcViewport() + } + case 1: + switch key.String() { + case "esc": + if !m.brRunning { + m.showBranch = false + return m, m.recalcViewport() + } + return m, nil + case "b": + if !m.brRunning && !m.brDone { + m.brStep = 0 + return m, m.recalcViewport() + } + return m, nil + case "y", "enter": + if !m.brRunning && !m.brDone { + if len(m.brBranches) == 0 { + return m, nil + } + name := m.brBranches[m.brIndex] + m.brRunning = true + m.brErr = "" + return m, runCheckout(m.repoRoot, name) + } + return m, nil + } + case 2: // new branch name + switch key.String() { + case "esc": + if m.brInputActive { + m.brInputActive = false + return m, m.recalcViewport() + } + m.showBranch = false + return m, m.recalcViewport() + case "i": + if !m.brInputActive { + m.brInputActive = true + m.brInput.Focus() + return m, m.recalcViewport() + } + // already active, treat as input + case "b": + if !m.brInputActive { + m.brStep = 0 + return m, m.recalcViewport() + } + // else forward to input + case "enter": + if !m.brInputActive { + if strings.TrimSpace(m.brInput.Value()) == "" { + m.brErr = "empty branch name" + return m, nil + } + m.brStep = 3 + m.brErr = "" + m.brDone = false + m.brRunning = false + return m, m.recalcViewport() + } + // in input mode, forward to text input + } + if m.brInputActive { + var cmd tea.Cmd + m.brInput, cmd = m.brInput.Update(key) + return m, cmd + } + return m, nil + case 3: // confirm new branch + switch key.String() { + case "esc": + if !m.brRunning { + m.showBranch = false + return m, m.recalcViewport() + } + return m, nil + case "b": + if !m.brRunning && !m.brDone { + m.brStep = 2 + return m, m.recalcViewport() + } + return m, nil + case "y", "enter": + if !m.brRunning && !m.brDone { + name := strings.TrimSpace(m.brInput.Value()) + if name == "" { + m.brErr = "empty branch name" + return m, nil + } + m.brRunning = true + m.brErr = "" + return m, runCreateBranch(m.repoRoot, name) + } + return m, nil + } + } + return m, nil } func runCheckout(repoRoot, branch string) tea.Cmd { - return func() tea.Msg { - if err := gitx.Checkout(repoRoot, branch); err != nil { - return branchResultMsg{err: err} - } - return branchResultMsg{err: nil} - } + return func() tea.Msg { + if err := gitx.Checkout(repoRoot, branch); err != nil { + return branchResultMsg{err: err} + } + return branchResultMsg{err: nil} + } } func runCreateBranch(repoRoot, name string) tea.Cmd { - return func() tea.Msg { - if err := gitx.CheckoutNew(repoRoot, name); err != nil { - return branchResultMsg{err: err} - } - return branchResultMsg{err: nil} - } + return func() tea.Msg { + if err := gitx.CheckoutNew(repoRoot, name); err != nil { + return branchResultMsg{err: err} + } + return branchResultMsg{err: nil} + } } type rcResultMsg struct{ err error } func (m *model) openResetCleanWizard() { - m.showResetClean = true - m.rcStep = 0 - m.rcDoReset = false - m.rcDoClean = false - m.rcIncludeIgnored = false - m.rcIndex = 0 - m.rcPreviewLines = nil - m.rcPreviewErr = "" - m.rcRunning = false - m.rcErr = "" - m.rcDone = false + m.showResetClean = true + m.rcStep = 0 + m.rcDoReset = false + m.rcDoClean = false + m.rcIncludeIgnored = false + m.rcIndex = 0 + m.rcPreviewLines = nil + m.rcPreviewErr = "" + m.rcRunning = false + m.rcErr = "" + m.rcDone = false } func (m model) resetCleanOverlayLines(width int) []string { - if !m.showResetClean { return nil } - lines := make([]string, 0, 128) - lines = append(lines, strings.Repeat("─", width)) - switch m.rcStep { - case 0: // select actions/options - title := lipgloss.NewStyle().Bold(true).Render("Reset/Clean — Select actions (space: toggle, a: toggle both, enter: continue, esc: cancel)") - lines = append(lines, title) - items := []struct{ label string; on bool }{ - {"Reset working tree (git reset --hard)", m.rcDoReset}, - {"Clean untracked (git clean -d -f)", m.rcDoClean}, - {"Include ignored in clean (-x)", m.rcIncludeIgnored}, - } - for i, it := range items { - cur := " " - if i == m.rcIndex { cur = "> " } - lines = append(lines, fmt.Sprintf("%s%s %s", cur, checkbox(it.on), it.label)) - } - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("A preview will be shown before confirmation")) - if m.rcErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.rcErr) - } - case 1: // preview - title := lipgloss.NewStyle().Bold(true).Render("Reset/Clean — Preview (enter: continue, b: back, esc: cancel)") - lines = append(lines, title) - // Reset preview summary from current file list (tracked changes) - if m.rcDoReset { - tracked := 0 - for _, f := range m.files { - if !f.Untracked && (f.Staged || f.Unstaged || f.Deleted) { - tracked++ - } - } - lines = append(lines, fmt.Sprintf("Reset would discard tracked changes for ~%d file(s)", tracked)) - } else { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("Reset: (not selected)")) - } - // Clean preview - if m.rcDoClean { - if m.rcPreviewErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Clean preview error: ")+m.rcPreviewErr) - } else if len(m.rcPreviewLines) == 0 { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("Clean: nothing to remove")) - } else { - lines = append(lines, lipgloss.NewStyle().Bold(true).Render("Clean would remove:")) - max := 10 - for i, l := range m.rcPreviewLines { - if i >= max { break } - lines = append(lines, l) - } - if len(m.rcPreviewLines) > max { - lines = append(lines, fmt.Sprintf("… and %d more", len(m.rcPreviewLines)-max)) - } - if m.rcIncludeIgnored { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("(including ignored files)")) - } - } - } else { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("Clean: (not selected)")) - } - // Show exact commands - var cmds []string - if m.rcDoReset { cmds = append(cmds, "git reset --hard") } - if m.rcDoClean { - c := "git clean -d -f" - if m.rcIncludeIgnored { c += " -x" } - cmds = append(cmds, c) - } - if len(cmds) > 0 { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("Commands: "+strings.Join(cmds, " && "))) - } else { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No actions selected")) - } - case 2: // first (yellow) confirmation - title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("220")).Render("Confirm — This will discard local changes (enter: continue, b: back, esc: cancel)") - lines = append(lines, title) - lines = append(lines, "Proceed to final confirmation?") - case 3: // final (red) confirmation - title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("196")).Render("FINAL CONFIRMATION — Destructive action (y/enter: execute, b: back, esc: cancel)") - lines = append(lines, title) - if m.rcRunning { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Running…")) - } - if m.rcErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.rcErr) - } - } - return lines -} - -func checkbox(on bool) string { if on { return "[x]" } ; return "[ ]" } + if !m.showResetClean { + return nil + } + lines := make([]string, 0, 128) + lines = append(lines, strings.Repeat("─", width)) + switch m.rcStep { + case 0: // select actions/options + title := lipgloss.NewStyle().Bold(true).Render("Reset/Clean — Select actions (space: toggle, a: toggle both, enter: continue, esc: cancel)") + lines = append(lines, title) + items := []struct { + label string + on bool + }{ + {"Reset working tree (git reset --hard)", m.rcDoReset}, + {"Clean untracked (git clean -d -f)", m.rcDoClean}, + {"Include ignored in clean (-x)", m.rcIncludeIgnored}, + } + for i, it := range items { + cur := " " + if i == m.rcIndex { + cur = "> " + } + lines = append(lines, fmt.Sprintf("%s%s %s", cur, checkbox(it.on), it.label)) + } + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("A preview will be shown before confirmation")) + if m.rcErr != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.rcErr) + } + case 1: // preview + title := lipgloss.NewStyle().Bold(true).Render("Reset/Clean — Preview (enter: continue, b: back, esc: cancel)") + lines = append(lines, title) + // Reset preview summary from current file list (tracked changes) + if m.rcDoReset { + tracked := 0 + for _, f := range m.files { + if !f.Untracked && (f.Staged || f.Unstaged || f.Deleted) { + tracked++ + } + } + lines = append(lines, fmt.Sprintf("Reset would discard tracked changes for ~%d file(s)", tracked)) + } else { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("Reset: (not selected)")) + } + // Clean preview + if m.rcDoClean { + if m.rcPreviewErr != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Clean preview error: ")+m.rcPreviewErr) + } else if len(m.rcPreviewLines) == 0 { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("Clean: nothing to remove")) + } else { + lines = append(lines, lipgloss.NewStyle().Bold(true).Render("Clean would remove:")) + max := 10 + for i, l := range m.rcPreviewLines { + if i >= max { + break + } + lines = append(lines, l) + } + if len(m.rcPreviewLines) > max { + lines = append(lines, fmt.Sprintf("… and %d more", len(m.rcPreviewLines)-max)) + } + if m.rcIncludeIgnored { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("(including ignored files)")) + } + } + } else { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("Clean: (not selected)")) + } + // Show exact commands + var cmds []string + if m.rcDoReset { + cmds = append(cmds, "git reset --hard") + } + if m.rcDoClean { + c := "git clean -d -f" + if m.rcIncludeIgnored { + c += " -x" + } + cmds = append(cmds, c) + } + if len(cmds) > 0 { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("Commands: "+strings.Join(cmds, " && "))) + } else { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No actions selected")) + } + case 2: // first (yellow) confirmation + title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("220")).Render("Confirm — This will discard local changes (enter: continue, b: back, esc: cancel)") + lines = append(lines, title) + lines = append(lines, "Proceed to final confirmation?") + case 3: // final (red) confirmation + title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("196")).Render("FINAL CONFIRMATION — Destructive action (y/enter: execute, b: back, esc: cancel)") + lines = append(lines, title) + if m.rcRunning { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Running…")) + } + if m.rcErr != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.rcErr) + } + } + return lines +} + +func checkbox(on bool) string { + if on { + return "[x]" + } + return "[ ]" +} func (m model) handleResetCleanKeys(key tea.KeyMsg) (tea.Model, tea.Cmd) { - switch m.rcStep { - case 0: // select - switch key.String() { - case "esc": - m.showResetClean = false - return m, m.recalcViewport() - case "j", "down": - if m.rcIndex < 2 { m.rcIndex++ } - return m, nil - case "k", "up": - if m.rcIndex > 0 { m.rcIndex-- } - return m, nil - case " ": - switch m.rcIndex { - case 0: - m.rcDoReset = !m.rcDoReset - case 1: - m.rcDoClean = !m.rcDoClean - case 2: - m.rcIncludeIgnored = !m.rcIncludeIgnored - } - return m, nil - case "a": - both := m.rcDoReset && m.rcDoClean - m.rcDoReset = !both - m.rcDoClean = !both - return m, nil - case "enter": - if !m.rcDoReset && !m.rcDoClean { - m.rcErr = "no actions selected" - return m, m.recalcViewport() - } - m.rcStep = 1 - m.rcPreviewErr = "" - m.rcPreviewLines = nil - if m.rcDoClean { - return m, loadRCPreview(m.repoRoot, m.rcIncludeIgnored) - } - return m, m.recalcViewport() - } - case 1: // preview - switch key.String() { - case "esc": - m.showResetClean = false - return m, m.recalcViewport() - case "b": - m.rcStep = 0 - return m, m.recalcViewport() - case "enter": - m.rcStep = 2 - return m, m.recalcViewport() - } - case 2: // yellow confirm - switch key.String() { - case "esc": - m.showResetClean = false - return m, m.recalcViewport() - case "b": - m.rcStep = 1 - return m, m.recalcViewport() - case "enter": - m.rcStep = 3 - return m, m.recalcViewport() - } - case 3: // red confirm - switch key.String() { - case "esc": - if !m.rcRunning { - m.showResetClean = false - return m, m.recalcViewport() - } - return m, nil - case "b": - if !m.rcRunning { - m.rcStep = 2 - return m, m.recalcViewport() - } - return m, nil - case "y", "enter": - if !m.rcRunning && !m.rcDone { - m.rcRunning = true - m.rcErr = "" - return m, runResetClean(m.repoRoot, m.rcDoReset, m.rcDoClean, m.rcIncludeIgnored) - } - return m, nil - } - } - return m, nil + switch m.rcStep { + case 0: // select + switch key.String() { + case "esc": + m.showResetClean = false + return m, m.recalcViewport() + case "j", "down": + if m.rcIndex < 2 { + m.rcIndex++ + } + return m, nil + case "k", "up": + if m.rcIndex > 0 { + m.rcIndex-- + } + return m, nil + case " ": + switch m.rcIndex { + case 0: + m.rcDoReset = !m.rcDoReset + case 1: + m.rcDoClean = !m.rcDoClean + case 2: + m.rcIncludeIgnored = !m.rcIncludeIgnored + } + return m, nil + case "a": + both := m.rcDoReset && m.rcDoClean + m.rcDoReset = !both + m.rcDoClean = !both + return m, nil + case "enter": + if !m.rcDoReset && !m.rcDoClean { + m.rcErr = "no actions selected" + return m, m.recalcViewport() + } + m.rcStep = 1 + m.rcPreviewErr = "" + m.rcPreviewLines = nil + if m.rcDoClean { + return m, loadRCPreview(m.repoRoot, m.rcIncludeIgnored) + } + return m, m.recalcViewport() + } + case 1: // preview + switch key.String() { + case "esc": + m.showResetClean = false + return m, m.recalcViewport() + case "b": + m.rcStep = 0 + return m, m.recalcViewport() + case "enter": + m.rcStep = 2 + return m, m.recalcViewport() + } + case 2: // yellow confirm + switch key.String() { + case "esc": + m.showResetClean = false + return m, m.recalcViewport() + case "b": + m.rcStep = 1 + return m, m.recalcViewport() + case "enter": + m.rcStep = 3 + return m, m.recalcViewport() + } + case 3: // red confirm + switch key.String() { + case "esc": + if !m.rcRunning { + m.showResetClean = false + return m, m.recalcViewport() + } + return m, nil + case "b": + if !m.rcRunning { + m.rcStep = 2 + return m, m.recalcViewport() + } + return m, nil + case "y", "enter": + if !m.rcRunning && !m.rcDone { + m.rcRunning = true + m.rcErr = "" + return m, runResetClean(m.repoRoot, m.rcDoReset, m.rcDoClean, m.rcIncludeIgnored) + } + return m, nil + } + } + return m, nil } func loadRCPreview(repoRoot string, includeIgnored bool) tea.Cmd { - return func() tea.Msg { - lines, err := gitx.CleanPreview(repoRoot, includeIgnored) - return rcPreviewMsg{lines: lines, err: err} - } + return func() tea.Msg { + lines, err := gitx.CleanPreview(repoRoot, includeIgnored) + return rcPreviewMsg{lines: lines, err: err} + } } func runResetClean(repoRoot string, doReset, doClean bool, includeIgnored bool) tea.Cmd { - return func() tea.Msg { - if err := gitx.ResetAndClean(repoRoot, doReset, doClean, includeIgnored); err != nil { - return rcResultMsg{err: err} - } - return rcResultMsg{err: nil} - } + return func() tea.Msg { + if err := gitx.ResetAndClean(repoRoot, doReset, doClean, includeIgnored); err != nil { + return rcResultMsg{err: err} + } + return rcResultMsg{err: nil} + } } type uncommitResultMsg struct{ err error } func loadUncommitFiles(repoRoot string) tea.Cmd { - return func() tea.Msg { - files, err := gitx.ChangedFiles(repoRoot) - return uncommitFilesMsg{files: files, err: err} - } + return func() tea.Msg { + files, err := gitx.ChangedFiles(repoRoot) + return uncommitFilesMsg{files: files, err: err} + } } type uncommitEligibleMsg struct { - paths []string - err error + paths []string + err error } func loadUncommitEligible(repoRoot string) tea.Cmd { - return func() tea.Msg { - ps, err := gitx.FilesInLastCommit(repoRoot) - return uncommitEligibleMsg{paths: ps, err: err} - } + return func() tea.Msg { + ps, err := gitx.FilesInLastCommit(repoRoot) + return uncommitEligibleMsg{paths: ps, err: err} + } } func (m *model) openUncommitWizard() { - m.showUncommit = true - m.ucStep = 0 - m.ucFiles = nil - m.ucSelected = map[string]bool{} - m.ucIndex = 0 - m.uncommitting = false - m.uncommitErr = "" - m.uncommitDone = false + m.showUncommit = true + m.ucStep = 0 + m.ucFiles = nil + m.ucSelected = map[string]bool{} + m.ucIndex = 0 + m.uncommitting = false + m.uncommitErr = "" + m.uncommitDone = false } func (m model) uncommitOverlayLines(width int) []string { - if !m.showUncommit { - return nil - } - lines := make([]string, 0, 64) - lines = append(lines, strings.Repeat("─", width)) - switch m.ucStep { - case 0: - title := lipgloss.NewStyle().Bold(true).Render("Uncommit — Select files (space: toggle, a: all, enter: continue, esc: cancel)") - lines = append(lines, title) - if len(m.ucFiles) == 0 && m.uncommitErr == "" { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("Loading files…")) - return lines - } - if m.uncommitErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.uncommitErr) - } - if len(m.ucFiles) == 0 && m.uncommitErr == "" { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No changes to choose from")) - return lines - } - for i, f := range m.ucFiles { - cur := " " - if i == m.ucIndex { cur = "> " } - mark := "[ ]" - if m.ucSelected[f.Path] { mark = "[x]" } - status := fileStatusLabel(f) - lines = append(lines, fmt.Sprintf("%s%s %s %s", cur, mark, status, f.Path)) - } - case 1: - title := lipgloss.NewStyle().Bold(true).Render("Uncommit — Confirm (y/enter: uncommit, b: back, esc: cancel)") - lines = append(lines, title) - sel := m.uncommitSelectedPaths() - total := len(sel) - elig := 0 - if m.ucEligible != nil { - for _, p := range sel { if m.ucEligible[p] { elig++ } } - } - inelig := total - elig - lines = append(lines, fmt.Sprintf("Selected: %d Eligible to uncommit: %d Ignored: %d", total, elig, inelig)) - if m.ucEligible == nil { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("(resolving eligibility…)")) - } - if m.uncommitting { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Uncommitting…")) - } - if m.uncommitErr != "" { - lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.uncommitErr) - } - } - return lines + if !m.showUncommit { + return nil + } + lines := make([]string, 0, 64) + lines = append(lines, strings.Repeat("─", width)) + switch m.ucStep { + case 0: + title := lipgloss.NewStyle().Bold(true).Render("Uncommit — Select files (space: toggle, a: all, enter: continue, esc: cancel)") + lines = append(lines, title) + if len(m.ucFiles) == 0 && m.uncommitErr == "" { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("Loading files…")) + return lines + } + if m.uncommitErr != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.uncommitErr) + } + if len(m.ucFiles) == 0 && m.uncommitErr == "" { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No changes to choose from")) + return lines + } + for i, f := range m.ucFiles { + cur := " " + if i == m.ucIndex { + cur = "> " + } + mark := "[ ]" + if m.ucSelected[f.Path] { + mark = "[x]" + } + status := fileStatusLabel(f) + lines = append(lines, fmt.Sprintf("%s%s %s %s", cur, mark, status, f.Path)) + } + case 1: + title := lipgloss.NewStyle().Bold(true).Render("Uncommit — Confirm (y/enter: uncommit, b: back, esc: cancel)") + lines = append(lines, title) + sel := m.uncommitSelectedPaths() + total := len(sel) + elig := 0 + if m.ucEligible != nil { + for _, p := range sel { + if m.ucEligible[p] { + elig++ + } + } + } + inelig := total - elig + lines = append(lines, fmt.Sprintf("Selected: %d Eligible to uncommit: %d Ignored: %d", total, elig, inelig)) + if m.ucEligible == nil { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("(resolving eligibility…)")) + } + if m.uncommitting { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("63")).Render("Uncommitting…")) + } + if m.uncommitErr != "" { + lines = append(lines, lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("Error: ")+m.uncommitErr) + } + } + return lines } func (m model) handleUncommitKeys(key tea.KeyMsg) (tea.Model, tea.Cmd) { - switch m.ucStep { - case 0: - switch key.String() { - case "esc": - m.showUncommit = false - return m, m.recalcViewport() - case "enter": - if len(m.ucFiles) == 0 { - return m, nil - } - m.ucStep = 1 - m.uncommitErr = "" - m.uncommitDone = false - m.uncommitting = false - return m, m.recalcViewport() - case "j", "down": - if len(m.ucFiles) > 0 && m.ucIndex < len(m.ucFiles)-1 { m.ucIndex++ } - return m, nil - case "k", "up": - if m.ucIndex > 0 { m.ucIndex-- } - return m, nil - case " ": - if len(m.ucFiles) > 0 { - p := m.ucFiles[m.ucIndex].Path - m.ucSelected[p] = !m.ucSelected[p] - } - return m, nil - case "a": - all := true - for _, f := range m.ucFiles { if !m.ucSelected[f.Path] { all = false; break } } - set := !all - for _, f := range m.ucFiles { m.ucSelected[f.Path] = set } - return m, nil - } - case 1: - switch key.String() { - case "esc": - if !m.uncommitting { - m.showUncommit = false - return m, m.recalcViewport() - } - return m, nil - case "b": - if !m.uncommitting && !m.uncommitDone { - m.ucStep = 0 - return m, m.recalcViewport() - } - return m, nil - case "y", "enter": - if !m.uncommitting && !m.uncommitDone { - sel := m.uncommitSelectedPaths() - if len(sel) == 0 { - m.uncommitErr = "no files selected" - return m, nil - } - m.uncommitErr = "" - m.uncommitting = true - return m, runUncommit(m.repoRoot, sel) - } - return m, nil - } - } - return m, nil + switch m.ucStep { + case 0: + switch key.String() { + case "esc": + m.showUncommit = false + return m, m.recalcViewport() + case "enter": + if len(m.ucFiles) == 0 { + return m, nil + } + m.ucStep = 1 + m.uncommitErr = "" + m.uncommitDone = false + m.uncommitting = false + return m, m.recalcViewport() + case "j", "down": + if len(m.ucFiles) > 0 && m.ucIndex < len(m.ucFiles)-1 { + m.ucIndex++ + } + return m, nil + case "k", "up": + if m.ucIndex > 0 { + m.ucIndex-- + } + return m, nil + case " ": + if len(m.ucFiles) > 0 { + p := m.ucFiles[m.ucIndex].Path + m.ucSelected[p] = !m.ucSelected[p] + } + return m, nil + case "a": + all := true + for _, f := range m.ucFiles { + if !m.ucSelected[f.Path] { + all = false + break + } + } + set := !all + for _, f := range m.ucFiles { + m.ucSelected[f.Path] = set + } + return m, nil + } + case 1: + switch key.String() { + case "esc": + if !m.uncommitting { + m.showUncommit = false + return m, m.recalcViewport() + } + return m, nil + case "b": + if !m.uncommitting && !m.uncommitDone { + m.ucStep = 0 + return m, m.recalcViewport() + } + return m, nil + case "y", "enter": + if !m.uncommitting && !m.uncommitDone { + sel := m.uncommitSelectedPaths() + if len(sel) == 0 { + m.uncommitErr = "no files selected" + return m, nil + } + m.uncommitErr = "" + m.uncommitting = true + return m, runUncommit(m.repoRoot, sel) + } + return m, nil + } + } + return m, nil } func (m model) uncommitSelectedPaths() []string { - var out []string - for _, f := range m.ucFiles { - if m.ucSelected[f.Path] { out = append(out, f.Path) } - } - return out + var out []string + for _, f := range m.ucFiles { + if m.ucSelected[f.Path] { + out = append(out, f.Path) + } + } + return out } func runUncommit(repoRoot string, paths []string) tea.Cmd { - return func() tea.Msg { - // Filter to eligible paths (present in last commit) - eligible, err := gitx.FilesInLastCommit(repoRoot) - if err != nil { - return uncommitResultMsg{err: err} - } - eligSet := map[string]bool{} - for _, p := range eligible { eligSet[p] = true } - var toUncommit []string - for _, p := range paths { if eligSet[p] { toUncommit = append(toUncommit, p) } } - if len(toUncommit) == 0 { - return uncommitResultMsg{err: fmt.Errorf("no selected files are in the last commit")} - } - if err := gitx.UncommitFiles(repoRoot, toUncommit); err != nil { - return uncommitResultMsg{err: err} - } - return uncommitResultMsg{err: nil} - } + return func() tea.Msg { + // Filter to eligible paths (present in last commit) + eligible, err := gitx.FilesInLastCommit(repoRoot) + if err != nil { + return uncommitResultMsg{err: err} + } + eligSet := map[string]bool{} + for _, p := range eligible { + eligSet[p] = true + } + var toUncommit []string + for _, p := range paths { + if eligSet[p] { + toUncommit = append(toUncommit, p) + } + } + if len(toUncommit) == 0 { + return uncommitResultMsg{err: fmt.Errorf("no selected files are in the last commit")} + } + if err := gitx.UncommitFiles(repoRoot, toUncommit); err != nil { + return uncommitResultMsg{err: err} + } + return uncommitResultMsg{err: nil} + } } func (m model) rightBodyLinesAll(width int) []string { - lines := make([]string, 0, 1024) - if len(m.files) == 0 { - return lines - } - if m.files[m.selected].Binary { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render("(Binary file; no text diff)")) - return lines - } - if m.rows == nil { - lines = append(lines, "Loading diff…") - return lines - } - if m.sideBySide { - colsW := (width - 1) / 2 - if colsW < 10 { - colsW = 10 - } - mid := m.theme.DividerText("│") - for _, r := range m.rows { - switch r.Kind { - case diffview.RowHunk: - // subtle separator fills full width - lines = append(lines, lipgloss.NewStyle().Faint(true).Render(strings.Repeat("·", width))) - case diffview.RowMeta: - // skip - default: - if m.wrapLines { - lLines := m.renderSideCellWrap(r, "left", colsW) - rLines := m.renderSideCellWrap(r, "right", colsW) - n := len(lLines) - if len(rLines) > n { n = len(rLines) } - for i := 0; i < n; i++ { - var l, rr string - if i < len(lLines) { l = lLines[i] } else { l = strings.Repeat(" ", colsW) } - if i < len(rLines) { rr = rLines[i] } else { rr = strings.Repeat(" ", colsW) } - lines = append(lines, l+mid+rr) - } - } else { - l := m.renderSideCell(r, "left", colsW) - rr := m.renderSideCell(r, "right", colsW) - l = padExact(l, colsW) - rr = padExact(rr, colsW) - lines = append(lines, l+mid+rr) - } - } - } - } else { - for _, r := range m.rows { - switch r.Kind { - case diffview.RowHunk: - lines = append(lines, lipgloss.NewStyle().Faint(true).Render(strings.Repeat("·", width))) - case diffview.RowContext: - base := " "+r.Left - if m.wrapLines { - wrapped := ansi.Hardwrap(base, width, false) - lines = append(lines, strings.Split(wrapped, "\n")...) - } else { - line := base - if m.rightXOffset > 0 { - line = sliceANSI(line, m.rightXOffset, width) - line = padExact(line, width) - } - lines = append(lines, line) - } - case diffview.RowAdd: - base := m.theme.AddText("+ "+r.Right) - if m.wrapLines { - wrapped := ansi.Hardwrap(base, width, false) - lines = append(lines, strings.Split(wrapped, "\n")...) - } else { - line := base - if m.rightXOffset > 0 { - line = sliceANSI(line, m.rightXOffset, width) - line = padExact(line, width) - } - lines = append(lines, line) - } - case diffview.RowDel: - base := m.theme.DelText("- "+r.Left) - if m.wrapLines { - wrapped := ansi.Hardwrap(base, width, false) - lines = append(lines, strings.Split(wrapped, "\n")...) - } else { - line := base - if m.rightXOffset > 0 { - line = sliceANSI(line, m.rightXOffset, width) - line = padExact(line, width) - } - lines = append(lines, line) - } - case diffview.RowReplace: - base1 := m.theme.DelText("- "+r.Left) - base2 := m.theme.AddText("+ "+r.Right) - if m.wrapLines { - wrapped1 := strings.Split(ansi.Hardwrap(base1, width, false), "\n") - wrapped2 := strings.Split(ansi.Hardwrap(base2, width, false), "\n") - lines = append(lines, wrapped1...) - lines = append(lines, wrapped2...) - } else { - line1 := base1 - if m.rightXOffset > 0 { - line1 = sliceANSI(line1, m.rightXOffset, width) - line1 = padExact(line1, width) - } - lines = append(lines, line1) - line2 := base2 - if m.rightXOffset > 0 { - line2 = sliceANSI(line2, m.rightXOffset, width) - line2 = padExact(line2, width) - } - lines = append(lines, line2) - } - } - } - } - return lines - + lines := make([]string, 0, 1024) + if len(m.files) == 0 { + return lines + } + if m.files[m.selected].Binary { + lines = append(lines, lipgloss.NewStyle().Faint(true).Render("(Binary file; no text diff)")) + return lines + } + if m.rows == nil { + lines = append(lines, "Loading diff…") + return lines + } + if m.sideBySide { + colsW := (width - 1) / 2 + if colsW < 10 { + colsW = 10 + } + mid := m.theme.DividerText("│") + for _, r := range m.rows { + switch r.Kind { + case diffview.RowHunk: + // subtle separator fills full width + lines = append(lines, lipgloss.NewStyle().Faint(true).Render(strings.Repeat("·", width))) + case diffview.RowMeta: + // skip + default: + if m.wrapLines { + lLines := m.renderSideCellWrap(r, "left", colsW) + rLines := m.renderSideCellWrap(r, "right", colsW) + n := len(lLines) + if len(rLines) > n { + n = len(rLines) + } + for i := 0; i < n; i++ { + var l, rr string + if i < len(lLines) { + l = lLines[i] + } else { + l = strings.Repeat(" ", colsW) + } + if i < len(rLines) { + rr = rLines[i] + } else { + rr = strings.Repeat(" ", colsW) + } + lines = append(lines, l+mid+rr) + } + } else { + l := m.renderSideCell(r, "left", colsW) + rr := m.renderSideCell(r, "right", colsW) + l = padExact(l, colsW) + rr = padExact(rr, colsW) + lines = append(lines, l+mid+rr) + } + } + } + } else { + for _, r := range m.rows { + switch r.Kind { + case diffview.RowHunk: + lines = append(lines, lipgloss.NewStyle().Faint(true).Render(strings.Repeat("·", width))) + case diffview.RowContext: + base := " " + r.Left + if m.wrapLines { + wrapped := ansi.Hardwrap(base, width, false) + lines = append(lines, strings.Split(wrapped, "\n")...) + } else { + line := base + if m.rightXOffset > 0 { + line = sliceANSI(line, m.rightXOffset, width) + line = padExact(line, width) + } + lines = append(lines, line) + } + case diffview.RowAdd: + base := m.theme.AddText("+ " + r.Right) + if m.wrapLines { + wrapped := ansi.Hardwrap(base, width, false) + lines = append(lines, strings.Split(wrapped, "\n")...) + } else { + line := base + if m.rightXOffset > 0 { + line = sliceANSI(line, m.rightXOffset, width) + line = padExact(line, width) + } + lines = append(lines, line) + } + case diffview.RowDel: + base := m.theme.DelText("- " + r.Left) + if m.wrapLines { + wrapped := ansi.Hardwrap(base, width, false) + lines = append(lines, strings.Split(wrapped, "\n")...) + } else { + line := base + if m.rightXOffset > 0 { + line = sliceANSI(line, m.rightXOffset, width) + line = padExact(line, width) + } + lines = append(lines, line) + } + case diffview.RowReplace: + base1 := m.theme.DelText("- " + r.Left) + base2 := m.theme.AddText("+ " + r.Right) + if m.wrapLines { + wrapped1 := strings.Split(ansi.Hardwrap(base1, width, false), "\n") + wrapped2 := strings.Split(ansi.Hardwrap(base2, width, false), "\n") + lines = append(lines, wrapped1...) + lines = append(lines, wrapped2...) + } else { + line1 := base1 + if m.rightXOffset > 0 { + line1 = sliceANSI(line1, m.rightXOffset, width) + line1 = padExact(line1, width) + } + lines = append(lines, line1) + line2 := base2 + if m.rightXOffset > 0 { + line2 = sliceANSI(line2, m.rightXOffset, width) + line2 = padExact(line2, width) + } + lines = append(lines, line2) + } + } + } + } + return lines + } func (m *model) openSearch() { @@ -2006,40 +2173,38 @@ func (m model) handleSearchKeys(key tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } - m.searchInput.Focus() - - switch key.String() { - case "esc": - m.closeSearch() - return m, m.recalcViewport() - case "ctrl+c": - return m, tea.Quit - } - - - // Navigation that does NOT leave input mode - switch key.Type { - case tea.KeyEnter: - return m, (&m).advanceSearch(1) - case tea.KeyDown: - return m, (&m).advanceSearch(1) - case tea.KeyUp: - return m, (&m).advanceSearch(-1) - } - - - // Fallback: always let input handle it - var cmd tea.Cmd + m.searchInput.Focus() + + switch key.String() { + case "esc": + m.closeSearch() + return m, m.recalcViewport() + case "ctrl+c": + return m, tea.Quit + } + + // Navigation that does NOT leave input mode + switch key.Type { + case tea.KeyEnter: + return m, (&m).advanceSearch(1) + case tea.KeyDown: + return m, (&m).advanceSearch(1) + case tea.KeyUp: + return m, (&m).advanceSearch(-1) + } + + // Fallback: always let input handle it + var cmd tea.Cmd m.searchInput, cmd = m.searchInput.Update(key) - m.searchQuery = m.searchInput.Value() - m.recomputeSearchMatches(true) - m.refreshSearchHighlights() + m.searchQuery = m.searchInput.Value() + m.recomputeSearchMatches(true) + m.refreshSearchHighlights() if scrollCmd := m.scrollToCurrentMatch(); scrollCmd != nil { return m, tea.Batch(cmd, scrollCmd) } - return m, cmd + return m, cmd } func (m *model) advanceSearch(delta int) tea.Cmd { @@ -2326,358 +2491,361 @@ func (m model) searchOverlayLines(width int) []string { // --- Commit wizard --- -type lastCommitMsg struct{ - summary string - err error +type lastCommitMsg struct { + summary string + err error } func loadLastCommit(repoRoot string) tea.Cmd { - return func() tea.Msg { - s, err := gitx.LastCommitSummary(repoRoot) - return lastCommitMsg{summary: s, err: err} - } + return func() tea.Msg { + s, err := gitx.LastCommitSummary(repoRoot) + return lastCommitMsg{summary: s, err: err} + } } -type currentBranchMsg struct{ - name string - err error +type currentBranchMsg struct { + name string + err error } func loadCurrentBranch(repoRoot string) tea.Cmd { - return func() tea.Msg { - name, err := gitx.CurrentBranch(repoRoot) - return currentBranchMsg{name: name, err: err} - } + return func() tea.Msg { + name, err := gitx.CurrentBranch(repoRoot) + return currentBranchMsg{name: name, err: err} + } } -type prefsMsg struct{ - p prefs.Prefs - err error +type prefsMsg struct { + p prefs.Prefs + err error } func loadPrefs(repoRoot string) tea.Cmd { - return func() tea.Msg { - // Loading never errors for now; returns zero-vals on missing keys - p := prefs.Load(repoRoot) - return prefsMsg{p: p, err: nil} - } + return func() tea.Msg { + // Loading never errors for now; returns zero-vals on missing keys + p := prefs.Load(repoRoot) + return prefsMsg{p: p, err: nil} + } } func (m *model) openCommitWizard() { - m.showCommit = true - m.commitStep = 0 - // snapshot files list - m.cwFiles = append([]gitx.FileChange(nil), m.files...) - m.cwSelected = map[string]bool{} - for _, f := range m.cwFiles { - m.cwSelected[f.Path] = true // default include all - } - m.cwIndex = 0 - m.commitDone = false - m.commitErr = "" - m.committing = false - m.cwInput = textinput.Model{} - m.cwInput.Placeholder = "Commit message" - m.cwInput.CharLimit = 0 - m.cwInputActive = false + m.showCommit = true + m.commitStep = 0 + // snapshot files list + m.cwFiles = append([]gitx.FileChange(nil), m.files...) + m.cwSelected = map[string]bool{} + for _, f := range m.cwFiles { + m.cwSelected[f.Path] = true // default include all + } + m.cwIndex = 0 + m.commitDone = false + m.commitErr = "" + m.committing = false + m.cwInput = textinput.Model{} + m.cwInput.Placeholder = "Commit message" + m.cwInput.CharLimit = 0 + m.cwInputActive = false } // handle commit wizard keys func (m model) handleCommitKeys(key tea.KeyMsg) (tea.Model, tea.Cmd) { - switch m.commitStep { - case 0: // select files - switch key.String() { - case "esc": - m.showCommit = false - return m, m.recalcViewport() - case "enter": - m.commitStep = 1 - // focus text input - ti := textinput.New() - ti.Placeholder = "Commit message" - ti.Prompt = "> " - ti.Focus() - m.cwInput = ti - return m, m.recalcViewport() - case "j", "down": - if len(m.cwFiles) > 0 && m.cwIndex < len(m.cwFiles)-1 { - m.cwIndex++ - } - return m, nil - case "k", "up": - if m.cwIndex > 0 { - m.cwIndex-- - } - return m, nil - case " ": - if len(m.cwFiles) > 0 { - p := m.cwFiles[m.cwIndex].Path - m.cwSelected[p] = !m.cwSelected[p] - } - return m, nil - case "a": - all := true - for _, f := range m.cwFiles { - if !m.cwSelected[f.Path] { all = false; break } - } - // toggle all - set := !all - for _, f := range m.cwFiles { - m.cwSelected[f.Path] = set - } - return m, nil - } - case 1: // message (input/action modes) - switch key.String() { - case "esc": - if m.cwInputActive { - // leave input mode - m.cwInputActive = false - return m, m.recalcViewport() - } - m.showCommit = false - return m, m.recalcViewport() - case "i": - if !m.cwInputActive { - m.cwInputActive = true - m.cwInput.Focus() - return m, m.recalcViewport() - } - // if already active, treat as input - case "b": - if !m.cwInputActive { - m.commitStep = 0 - return m, m.recalcViewport() - } - // in input mode, 'b' is literal - case "enter": - if !m.cwInputActive { - m.commitStep = 2 - m.commitDone = false - m.commitErr = "" - m.committing = false - return m, m.recalcViewport() - } - // in input mode, forward to input - } - // Default: if input mode, forward to text input; else ignore - if m.cwInputActive { - var cmd tea.Cmd - m.cwInput, cmd = m.cwInput.Update(key) - return m, cmd - } - return m, nil - case 2: // confirm/progress - switch key.String() { - case "esc": - // can't cancel mid-commit, but if not running: exit - if !m.committing { - m.showCommit = false - return m, m.recalcViewport() - } - return m, nil - case "b": - if !m.committing && !m.commitDone { - m.commitStep = 1 - return m, m.recalcViewport() - } - return m, nil - case "y", "enter": - if !m.committing && !m.commitDone { - sel := m.selectedPaths() - if len(sel) == 0 { - m.commitErr = "no files selected" - return m, nil - } - if strings.TrimSpace(m.cwInput.Value()) == "" { - m.commitErr = "empty commit message" - return m, nil - } - m.commitErr = "" - m.committing = true - return m, runCommit(m.repoRoot, sel, m.cwInput.Value()) - } - return m, nil - } - } - return m, nil + switch m.commitStep { + case 0: // select files + switch key.String() { + case "esc": + m.showCommit = false + return m, m.recalcViewport() + case "enter": + m.commitStep = 1 + // focus text input + ti := textinput.New() + ti.Placeholder = "Commit message" + ti.Prompt = "> " + ti.Focus() + m.cwInput = ti + return m, m.recalcViewport() + case "j", "down": + if len(m.cwFiles) > 0 && m.cwIndex < len(m.cwFiles)-1 { + m.cwIndex++ + } + return m, nil + case "k", "up": + if m.cwIndex > 0 { + m.cwIndex-- + } + return m, nil + case " ": + if len(m.cwFiles) > 0 { + p := m.cwFiles[m.cwIndex].Path + m.cwSelected[p] = !m.cwSelected[p] + } + return m, nil + case "a": + all := true + for _, f := range m.cwFiles { + if !m.cwSelected[f.Path] { + all = false + break + } + } + // toggle all + set := !all + for _, f := range m.cwFiles { + m.cwSelected[f.Path] = set + } + return m, nil + } + case 1: // message (input/action modes) + switch key.String() { + case "esc": + if m.cwInputActive { + // leave input mode + m.cwInputActive = false + return m, m.recalcViewport() + } + m.showCommit = false + return m, m.recalcViewport() + case "i": + if !m.cwInputActive { + m.cwInputActive = true + m.cwInput.Focus() + return m, m.recalcViewport() + } + // if already active, treat as input + case "b": + if !m.cwInputActive { + m.commitStep = 0 + return m, m.recalcViewport() + } + // in input mode, 'b' is literal + case "enter": + if !m.cwInputActive { + m.commitStep = 2 + m.commitDone = false + m.commitErr = "" + m.committing = false + return m, m.recalcViewport() + } + // in input mode, forward to input + } + // Default: if input mode, forward to text input; else ignore + if m.cwInputActive { + var cmd tea.Cmd + m.cwInput, cmd = m.cwInput.Update(key) + return m, cmd + } + return m, nil + case 2: // confirm/progress + switch key.String() { + case "esc": + // can't cancel mid-commit, but if not running: exit + if !m.committing { + m.showCommit = false + return m, m.recalcViewport() + } + return m, nil + case "b": + if !m.committing && !m.commitDone { + m.commitStep = 1 + return m, m.recalcViewport() + } + return m, nil + case "y", "enter": + if !m.committing && !m.commitDone { + sel := m.selectedPaths() + if len(sel) == 0 { + m.commitErr = "no files selected" + return m, nil + } + if strings.TrimSpace(m.cwInput.Value()) == "" { + m.commitErr = "empty commit message" + return m, nil + } + m.commitErr = "" + m.committing = true + return m, runCommit(m.repoRoot, sel, m.cwInput.Value()) + } + return m, nil + } + } + return m, nil } func (m model) selectedPaths() []string { - var out []string - for _, f := range m.cwFiles { - if m.cwSelected[f.Path] { - out = append(out, f.Path) - } - } - return out + var out []string + for _, f := range m.cwFiles { + if m.cwSelected[f.Path] { + out = append(out, f.Path) + } + } + return out } type commitProgressMsg struct{} type commitResultMsg struct{ err error } func runCommit(repoRoot string, paths []string, message string) tea.Cmd { - return func() tea.Msg { - // Stage selected files - if err := gitx.StageFiles(repoRoot, paths); err != nil { - return commitResultMsg{err: err} - } - // Commit - if err := gitx.Commit(repoRoot, message); err != nil { - return commitResultMsg{err: err} - } - // Push - if err := gitx.Push(repoRoot); err != nil { - return commitResultMsg{err: err} - } - return commitResultMsg{err: nil} - } + return func() tea.Msg { + // Stage selected files + if err := gitx.StageFiles(repoRoot, paths); err != nil { + return commitResultMsg{err: err} + } + // Commit + if err := gitx.Commit(repoRoot, message); err != nil { + return commitResultMsg{err: err} + } + // Push + if err := gitx.Push(repoRoot); err != nil { + return commitResultMsg{err: err} + } + return commitResultMsg{err: nil} + } } - - func (m model) colorizeLeft(r diffview.Row) string { - switch r.Kind { - case diffview.RowContext: - return r.Left - case diffview.RowDel: - return m.theme.DelText(r.Left) - case diffview.RowReplace: - return m.theme.DelText(r.Left) - case diffview.RowAdd: - return "" - default: - return r.Left - } + switch r.Kind { + case diffview.RowContext: + return r.Left + case diffview.RowDel: + return m.theme.DelText(r.Left) + case diffview.RowReplace: + return m.theme.DelText(r.Left) + case diffview.RowAdd: + return "" + default: + return r.Left + } } func (m model) colorizeRight(r diffview.Row) string { - switch r.Kind { - case diffview.RowContext: - return r.Right - case diffview.RowAdd: - return m.theme.AddText(r.Right) - case diffview.RowReplace: - return m.theme.AddText(r.Right) - case diffview.RowDel: - return "" - default: - return r.Right - } + switch r.Kind { + case diffview.RowContext: + return r.Right + case diffview.RowAdd: + return m.theme.AddText(r.Right) + case diffview.RowReplace: + return m.theme.AddText(r.Right) + case diffview.RowDel: + return "" + default: + return r.Right + } } // renderSideCell renders a left or right cell with a colored marker and padding. // side is "left" or "right". width is the total cell width. func (m model) renderSideCell(r diffview.Row, side string, width int) string { - marker := " " - content := "" - switch side { - case "left": - content = r.Left - switch r.Kind { - case diffview.RowContext: - marker = " " - case diffview.RowDel, diffview.RowReplace: - marker = m.theme.DelText("-") - content = m.theme.DelText(content) - case diffview.RowAdd: - marker = " " - content = "" - } - case "right": - content = r.Right - switch r.Kind { - case diffview.RowContext: - marker = " " - case diffview.RowAdd, diffview.RowReplace: - marker = m.theme.AddText("+") - content = m.theme.AddText(content) - case diffview.RowDel: - marker = " " - content = "" - } - } - // Reserve 2 cols: marker + space - if width <= 2 { - return ansi.Truncate(marker+" ", width, "") - } - bodyW := width - 2 - - clipped := sliceANSI(content, m.rightXOffset, bodyW) - - return marker + " " + clipped + marker := " " + content := "" + switch side { + case "left": + content = r.Left + switch r.Kind { + case diffview.RowContext: + marker = " " + case diffview.RowDel, diffview.RowReplace: + marker = m.theme.DelText("-") + content = m.theme.DelText(content) + case diffview.RowAdd: + marker = " " + content = "" + } + case "right": + content = r.Right + switch r.Kind { + case diffview.RowContext: + marker = " " + case diffview.RowAdd, diffview.RowReplace: + marker = m.theme.AddText("+") + content = m.theme.AddText(content) + case diffview.RowDel: + marker = " " + content = "" + } + } + // Reserve 2 cols: marker + space + if width <= 2 { + return ansi.Truncate(marker+" ", width, "") + } + bodyW := width - 2 + + clipped := sliceANSI(content, m.rightXOffset, bodyW) + + return marker + " " + clipped } // renderSideCellWrap renders a cell like renderSideCell but wraps the content // to the given width and returns multiple visual lines. The marker is repeated // on each wrapped line. func (m model) renderSideCellWrap(r diffview.Row, side string, width int) []string { - marker := " " - content := "" - switch side { - case "left": - content = r.Left - switch r.Kind { - case diffview.RowContext: - marker = " " - case diffview.RowDel, diffview.RowReplace: - marker = m.theme.DelText("-") - content = m.theme.DelText(content) - case diffview.RowAdd: - marker = " " - content = "" - } - case "right": - content = r.Right - switch r.Kind { - case diffview.RowContext: - marker = " " - case diffview.RowAdd, diffview.RowReplace: - marker = m.theme.AddText("+") - content = m.theme.AddText(content) - case diffview.RowDel: - marker = " " - content = "" - } - } - // Reserve 2 cols for marker and a space - if width <= 2 { - return []string{ansi.Truncate(marker+" ", width, "")} - } - bodyW := width - 2 - wrapped := ansi.Hardwrap(content, bodyW, false) - parts := strings.Split(wrapped, "\n") - out := make([]string, 0, len(parts)) - for _, p := range parts { - out = append(out, marker+" "+padExact(p, bodyW)) - } - if len(out) == 0 { - out = append(out, marker+" "+strings.Repeat(" ", bodyW)) - } - return out + marker := " " + content := "" + switch side { + case "left": + content = r.Left + switch r.Kind { + case diffview.RowContext: + marker = " " + case diffview.RowDel, diffview.RowReplace: + marker = m.theme.DelText("-") + content = m.theme.DelText(content) + case diffview.RowAdd: + marker = " " + content = "" + } + case "right": + content = r.Right + switch r.Kind { + case diffview.RowContext: + marker = " " + case diffview.RowAdd, diffview.RowReplace: + marker = m.theme.AddText("+") + content = m.theme.AddText(content) + case diffview.RowDel: + marker = " " + content = "" + } + } + // Reserve 2 cols for marker and a space + if width <= 2 { + return []string{ansi.Truncate(marker+" ", width, "")} + } + bodyW := width - 2 + wrapped := ansi.Hardwrap(content, bodyW, false) + parts := strings.Split(wrapped, "\n") + out := make([]string, 0, len(parts)) + for _, p := range parts { + out = append(out, marker+" "+padExact(p, bodyW)) + } + if len(out) == 0 { + out = append(out, marker+" "+strings.Repeat(" ", bodyW)) + } + return out } // sliceANSI returns a substring of s starting at visual column `start` with at most `w` columns, preserving ANSI escapes. func sliceANSI(s string, start, w int) string { - if start <= 0 { - return ansi.Truncate(s, w, "") - } - // First keep only the left portion up to start+w, then drop the first `start` columns. - head := ansi.Truncate(s, start+w, "") - return ansi.TruncateLeft(head, start, "") + if start <= 0 { + return ansi.Truncate(s, w, "") + } + // First keep only the left portion up to start+w, then drop the first `start` columns. + head := ansi.Truncate(s, start+w, "") + return ansi.TruncateLeft(head, start, "") } // padExact pads s with spaces to exactly width w (ANSI-aware width). func padExact(s string, w int) string { - sw := lipgloss.Width(s) - if sw >= w { return s } - return s + strings.Repeat(" ", w-sw) + sw := lipgloss.Width(s) + if sw >= w { + return s + } + return s + strings.Repeat(" ", w-sw) } -func isMovementKey(key string) bool{ - return key == "j" || key == "k" +func isMovementKey(key string) bool { + return key == "j" || key == "k" } -func isNumericKey(key string) bool{ - return key <= "9" && key >= "0" +func isNumericKey(key string) bool { + return key <= "9" && key >= "0" } diff --git a/internal/tui/program_test.go b/internal/tui/program_test.go index 1f706bd..503bdf8 100644 --- a/internal/tui/program_test.go +++ b/internal/tui/program_test.go @@ -1,73 +1,72 @@ package tui import ( - "strings" - "testing" - "time" + "strings" + "testing" + "time" - "github.com/charmbracelet/x/ansi" - "github.com/interpretive-systems/diffium/internal/diffview" - "github.com/interpretive-systems/diffium/internal/gitx" + "github.com/charmbracelet/x/ansi" + "github.com/interpretive-systems/diffium/internal/diffview" + "github.com/interpretive-systems/diffium/internal/gitx" ) func baseModelForTest() model { - m := model{} - m.repoRoot = "." - m.theme = defaultTheme() - m.files = []gitx.FileChange{ - {Path: "file1.txt", Unstaged: true}, - {Path: "file2.txt", Staged: true}, - } - m.selected = 0 - m.width = 80 - m.height = 16 - m.leftWidth = 24 - m.lastRefresh = time.Date(2024, 10, 1, 12, 34, 56, 0, time.UTC) - m.showHelp = false - m.showCommit = false - return m + m := model{} + m.repoRoot = "." + m.theme = defaultTheme() + m.files = []gitx.FileChange{ + {Path: "file1.txt", Unstaged: true}, + {Path: "file2.txt", Staged: true}, + } + m.selected = 0 + m.width = 80 + m.height = 16 + m.leftWidth = 24 + m.lastRefresh = time.Date(2024, 10, 1, 12, 34, 56, 0, time.UTC) + m.showHelp = false + m.showCommit = false + return m } func sampleUnified() string { - return "@@ -1,3 +1,3 @@\n line1\n-line2\n+line2 changed\n line3\n" + return "@@ -1,3 +1,3 @@\n line1\n-line2\n+line2 changed\n line3\n" } func TestView_SideBySide_Render(t *testing.T) { - m := baseModelForTest() - m.sideBySide = true - m.rows = diffview.BuildRowsFromUnified(sampleUnified()) - (&m).recalcViewport() - out := m.View() - plain := ansi.Strip(out) + m := baseModelForTest() + m.sideBySide = true + m.rows = diffview.BuildRowsFromUnified(sampleUnified()) + (&m).recalcViewport() + out := m.View() + plain := ansi.Strip(out) - // Basic snapshot-like assertions - if !strings.HasPrefix(plain, "Changes | file1.txt (M)") { - t.Fatalf("unexpected header: %q", strings.SplitN(plain, "\n", 2)[0]) - } - if !strings.Contains(plain, "│") { - t.Fatalf("expected vertical divider in view") - } - if !strings.Contains(plain, "line2 changed") { - t.Fatalf("expected changed text in right pane") - } - if !strings.Contains(plain, "refreshed: 12:34:56") { - t.Fatalf("expected bottom bar timestamp, got: %q", plain) - } + // Basic snapshot-like assertions + if !strings.HasPrefix(plain, "Changes | file1.txt (M)") { + t.Fatalf("unexpected header: %q", strings.SplitN(plain, "\n", 2)[0]) + } + if !strings.Contains(plain, "│") { + t.Fatalf("expected vertical divider in view") + } + if !strings.Contains(plain, "line2 changed") { + t.Fatalf("expected changed text in right pane") + } + if !strings.Contains(plain, "refreshed: 12:34:56") { + t.Fatalf("expected bottom bar timestamp, got: %q", plain) + } } func TestView_Inline_Render(t *testing.T) { - m := baseModelForTest() - m.sideBySide = false - m.rows = diffview.BuildRowsFromUnified(sampleUnified()) - (&m).recalcViewport() - out := m.View() - plain := ansi.Strip(out) + m := baseModelForTest() + m.sideBySide = false + m.rows = diffview.BuildRowsFromUnified(sampleUnified()) + (&m).recalcViewport() + out := m.View() + plain := ansi.Strip(out) - if !strings.Contains(plain, "+ line2 changed") { - t.Fatalf("expected inline added line, got: %q", plain) - } - if !strings.Contains(plain, "- line2") { - t.Fatalf("expected inline deleted line, got: %q", plain) - } + if !strings.Contains(plain, "+ line2 changed") { + t.Fatalf("expected inline added line, got: %q", plain) + } + if !strings.Contains(plain, "- line2") { + t.Fatalf("expected inline deleted line, got: %q", plain) + } } - diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 88559cf..acb84f6 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -1,67 +1,66 @@ package tui import ( - "encoding/json" - "os" - "path/filepath" + "encoding/json" + "os" + "path/filepath" - "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss" ) // Theme defines customizable colors for rendering. type Theme struct { - AddColor string `json:"addColor"` // e.g. "34" or "#22c55e" - DelColor string `json:"delColor"` // e.g. "196" or "#ef4444" - MetaColor string `json:"metaColor"` // optional, currently unused - DividerColor string `json:"dividerColor"` // e.g. "240" + AddColor string `json:"addColor"` // e.g. "34" or "#22c55e" + DelColor string `json:"delColor"` // e.g. "196" or "#ef4444" + MetaColor string `json:"metaColor"` // optional, currently unused + DividerColor string `json:"dividerColor"` // e.g. "240" } func defaultTheme() Theme { - return Theme{ - AddColor: "34", - DelColor: "196", - MetaColor: "63", - DividerColor: "240", - } + return Theme{ + AddColor: "34", + DelColor: "196", + MetaColor: "63", + DividerColor: "240", + } } // loadThemeFromRepo tries .diffium/theme.json at repoRoot. func loadThemeFromRepo(repoRoot string) Theme { - t := defaultTheme() - path := filepath.Join(repoRoot, ".diffium", "theme.json") - b, err := os.ReadFile(path) - if err != nil { - return t - } - var u Theme - if err := json.Unmarshal(b, &u); err != nil { - return t - } - // Merge, keeping defaults for empty fields - if u.AddColor != "" { - t.AddColor = u.AddColor - } - if u.DelColor != "" { - t.DelColor = u.DelColor - } - if u.MetaColor != "" { - t.MetaColor = u.MetaColor - } - if u.DividerColor != "" { - t.DividerColor = u.DividerColor - } - return t + t := defaultTheme() + path := filepath.Join(repoRoot, ".diffium", "theme.json") + b, err := os.ReadFile(path) + if err != nil { + return t + } + var u Theme + if err := json.Unmarshal(b, &u); err != nil { + return t + } + // Merge, keeping defaults for empty fields + if u.AddColor != "" { + t.AddColor = u.AddColor + } + if u.DelColor != "" { + t.DelColor = u.DelColor + } + if u.MetaColor != "" { + t.MetaColor = u.MetaColor + } + if u.DividerColor != "" { + t.DividerColor = u.DividerColor + } + return t } func (t Theme) AddText(s string) string { - return lipgloss.NewStyle().Foreground(lipgloss.Color(t.AddColor)).Render(s) + return lipgloss.NewStyle().Foreground(lipgloss.Color(t.AddColor)).Render(s) } func (t Theme) DelText(s string) string { - return lipgloss.NewStyle().Foreground(lipgloss.Color(t.DelColor)).Render(s) + return lipgloss.NewStyle().Foreground(lipgloss.Color(t.DelColor)).Render(s) } func (t Theme) DividerText(s string) string { - return lipgloss.NewStyle().Foreground(lipgloss.Color(t.DividerColor)).Render(s) + return lipgloss.NewStyle().Foreground(lipgloss.Color(t.DividerColor)).Render(s) } -