Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

15 changed files with 67 additions and 440 deletions

View File

@ -1,29 +0,0 @@
---
name: 🐛 Bug Report
about: Create a report to help us improve difi
title: "[BUG] "
labels: bug
assignees: ""
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Press keys '...'
3. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Environment (please complete the following information):**
- OS: [e.g. macOS / Linux / Windows]
- Terminal Emulator: [e.g. iTerm2, Alacritty, tmux]
- Difi Version: [e.g. v0.1.0 or commit hash]
**Screenshots**
If applicable, add screenshots to help explain your problem.

View File

@ -1,16 +0,0 @@
---
name: 🚀 Feature Request
about: Suggest an idea for this project
title: "[FEAT] "
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex: I'm always frustrated when...
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.

View File

@ -1,15 +0,0 @@
## Description
Please include a summary of the change and which issue is fixed.
Fixes # (issue)
## Type of change
- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
- [ ] 🚀 New feature (non-breaking change which adds functionality)
- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] 📚 Documentation update
## Checklist:
- [ ] My code follows the style guidelines of this project (`go fmt ./...`)
- [ ] I have performed a self-review of my own code
- [ ] I have added tests that prove my fix is effective or that my feature works

View File

@ -20,7 +20,7 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version-file: go.mod go-version: stable
- name: Run GoReleaser - name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v6

14
.gitignore vendored
View File

@ -1,14 +0,0 @@
# build artifacts
/dist/
/bin/
/coverage/
# Go test binaries and coverage
*.test
*.out
# logs
*.log
# OS
.DS_Store

View File

@ -7,10 +7,6 @@ before:
builds: builds:
- env: - env:
- CGO_ENABLED=0 - CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X main.version={{ .Version }}
goos: goos:
- linux - linux
- windows - windows

View File

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 Xiyuan Guo Copyright (c) 2026 Tommy Guo
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@ -1,5 +1,4 @@
<a id="readme-top"></a> <a id="readme-top"></a>
<h1 align="center"><code>difi</code></h1> <h1 align="center"><code>difi</code></h1>
<p align="center"><em>Review and refine Git diffs before you push</em></p> <p align="center"><em>Review and refine Git diffs before you push</em></p>
@ -10,12 +9,12 @@
</p> </p>
<p align="center"> <p align="center">
<img src= "https://github.com/user-attachments/assets/3695cfd2-148c-463d-9630-547d152adde0" alt="difi_demo" /> <img src="https://github.com/user-attachments/assets/70c177cb-9ad8-4e53-8837-f5e7b3f22fa0" alt="difi" />
</p> </p>
## Why difi? ## Why difi?
**git diff** shows changes. **difi** helps you _review_ them. **git diff** shows changes. **difi** helps you *review* them.
- ⚡️ **Instant** — Built in Go. Launches immediately with no daemon or indexing. - ⚡️ **Instant** — Built in Go. Launches immediately with no daemon or indexing.
- 🎨 **Structured** — A clean file tree and focused diffs for fast mental parsing. - 🎨 **Structured** — A clean file tree and focused diffs for fast mental parsing.
@ -39,20 +38,6 @@ brew install difi
go install github.com/oug-t/difi/cmd/difi@latest go install github.com/oug-t/difi/cmd/difi@latest
``` ```
#### AUR (Arch Linux)
**Binary (pre-built):**
```bash
pikaur -S difi-bin
```
**Build from source:**
```bash
pikaur -S difi
```
#### Manual (Linux / Windows) #### Manual (Linux / Windows)
- Download the binary from Releases and add it to your `$PATH`. - Download the binary from Releases and add it to your `$PATH`.
@ -86,27 +71,7 @@ difi
## Integrations ## Integrations
#### vim-fugitive #### Neovim
- **The "Unix philosophy" approach:** Uses the industry-standard Git wrapper to provide a robust, side-by-side editing experience.
- **Side-by-Side Editing:** Instantly opens a vertical split (:Gvdiffsplit!) against the index.
- **Merge Conflicts:** Automatically detects conflicts and opens a 3-way merge view for resolution.
- **Config**: Add the line below to if using **lazy.nvim**.
```lua
{
"tpope/vim-fugitive",
cmd = { "Gvdiffsplit", "Git" }, -- Add this line
}
```
<p align="left">
<a href="https://github.com/tpope/vim-fugitive.git">
<img src="https://img.shields.io/badge/Supports-vim--fugitive-4d4d4d?style=for-the-badge&logo=vim&logoColor=white" alt="Supports vim-fugitive" />
</a>
</p>
#### difi.nvim
Get the ultimate review experience with **[difi.nvim](https://github.com/oug-t/difi.nvim)**. Get the ultimate review experience with **[difi.nvim](https://github.com/oug-t/difi.nvim)**.
@ -121,24 +86,6 @@ Get the ultimate review experience with **[difi.nvim](https://github.com/oug-t/d
</a> </a>
</p> </p>
<p align="right">(<a href="#readme-top">back to top</a>)</p>
## Git Integration
To use `difi` as a native git command (e.g., `git difi`), add it as an alias in your global git config:
```bash
git config --global alias.difi '!difi'
```
Now you can run it directly from git:
```bash
git difi
```
<p align="right">(<a href="#readme-top">back to top</a>)</p>
## Contributing ## Contributing
```bash ```bash
@ -148,7 +95,6 @@ go run cmd/difi/main.go
``` ```
Contributions are especially welcome in: Contributions are especially welcome in:
- diff.nvim rendering edge cases - diff.nvim rendering edge cases
- UI polish and accessibility - UI polish and accessibility
- Windows support - Windows support
@ -173,3 +119,9 @@ Contributions are especially welcome in:
<p align="center"> Made with ❤️ by <a href="https://github.com/oug-t">oug-t</a> </p> <p align="center"> Made with ❤️ by <a href="https://github.com/oug-t">oug-t</a> </p>

View File

@ -4,7 +4,6 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"os/exec"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/oug-t/difi/internal/config" "github.com/oug-t/difi/internal/config"
@ -15,18 +14,15 @@ var version = "dev"
func main() { func main() {
showVersion := flag.Bool("version", false, "Show version") showVersion := flag.Bool("version", false, "Show version")
plain := flag.Bool("plain", false, "Print a plain, non-interactive summary and exit")
flat := flag.Bool("flat", false, "Start in flat file list mode")
flag.Usage = func() { flag.Usage = func() {
w := os.Stderr fmt.Fprintf(os.Stderr, "Usage: difi [flags] [target-branch]\n")
fmt.Fprintln(w, "Usage: difi [flags] [target-branch]") fmt.Fprintf(os.Stderr, "\nFlags:\n")
fmt.Fprintln(w, "\nFlags:")
flag.PrintDefaults() flag.PrintDefaults()
fmt.Fprintln(w, "\nExamples:") fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintln(w, " difi # Diff against default") fmt.Fprintf(os.Stderr, " difi # Diff against main\n")
fmt.Fprintln(w, " difi develop # Diff against develop") fmt.Fprintf(os.Stderr, " difi develop # Diff against develop\n")
fmt.Fprintln(w, " difi HEAD~1 # Diff against last commit") fmt.Fprintf(os.Stderr, " difi HEAD~1 # Diff against last commit\n")
} }
flag.Parse() flag.Parse()
@ -36,24 +32,12 @@ func main() {
os.Exit(0) os.Exit(0)
} }
target := "HEAD" target := "main"
if flag.NArg() > 0 { if flag.NArg() > 0 {
target = flag.Arg(0) target = flag.Arg(0)
} }
if *plain {
// Uses --name-status for a concise, machine-readable summary suitable for CI
cmd := exec.Command("git", "diff", "--name-status", fmt.Sprintf("%s...HEAD", target))
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
os.Exit(1)
}
os.Exit(0)
}
cfg := config.Load() cfg := config.Load()
cfg.Flat = *flat
p := tea.NewProgram(ui.NewModel(cfg, target), tea.WithAltScreen()) p := tea.NewProgram(ui.NewModel(cfg, target), tea.WithAltScreen())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {

3
go.mod
View File

@ -6,13 +6,14 @@ require (
github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.10.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // 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/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect

4
go.sum
View File

@ -53,3 +53,7 @@ golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -2,7 +2,6 @@ package config
type Config struct { type Config struct {
UI UIConfig UI UIConfig
Flat bool
} }
type UIConfig struct { type UIConfig struct {

View File

@ -7,7 +7,6 @@ import (
"strings" "strings"
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/lipgloss"
) )
// FileTree holds the state of the entire file graph. // FileTree holds the state of the entire file graph.
@ -33,7 +32,6 @@ type TreeItem struct {
Depth int Depth int
Expanded bool Expanded bool
Icon string Icon string
Flat bool
} }
// Implement list.Item interface // Implement list.Item interface
@ -106,40 +104,6 @@ func (t *FileTree) Items() []list.Item {
return items return items
} }
// FlatItems returns all leaf (file) nodes as a flat list with full paths.
func (t *FileTree) FlatItems() []list.Item {
var items []list.Item
collectLeaves(t.Root, &items)
return items
}
// collectLeaves recursively collects all file (non-directory) nodes.
func collectLeaves(node *Node, items *[]list.Item) {
children := make([]*Node, 0, len(node.Children))
for _, child := range node.Children {
children = append(children, child)
}
sort.Slice(children, func(i, j int) bool {
return strings.ToLower(children[i].FullPath) < strings.ToLower(children[j].FullPath)
})
for _, child := range children {
if child.IsDir {
collectLeaves(child, items)
} else {
*items = append(*items, TreeItem{
Name: child.Name,
FullPath: child.FullPath,
IsDir: false,
Depth: 0,
Icon: getIcon(child.Name, false),
Flat: true,
})
}
}
}
// flatten recursively builds the list, respecting expansion state. // flatten recursively builds the list, respecting expansion state.
func flatten(node *Node, items *[]list.Item) { func flatten(node *Node, items *[]list.Item) {
// Collect children to sort // Collect children to sort
@ -225,22 +189,3 @@ func getIcon(name string, isDir bool) string {
return "" return ""
} }
} }
// ShortenPath truncates a path from the left to fit within maxWidth display columns.
// "internal/tree/tree.go" → "…nal/tree/tree.go"
func ShortenPath(path string, maxWidth int) string {
if lipgloss.Width(path) <= maxWidth {
return path
}
ellipsis := "…"
avail := maxWidth - lipgloss.Width(ellipsis)
if avail <= 0 {
return ellipsis
}
// Trim runes from the left until the remainder fits
runes := []rune(path)
for len(runes) > 0 && lipgloss.Width(string(runes)) > avail {
runes = runes[1:]
}
return ellipsis + string(runes)
}

View File

@ -7,7 +7,6 @@ import (
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/oug-t/difi/internal/tree" "github.com/oug-t/difi/internal/tree"
) )
@ -25,15 +24,7 @@ func (d TreeDelegate) Render(w io.Writer, m list.Model, index int, item list.Ite
return return
} }
var title string title := i.Title()
if i.Flat {
iconWidth := lipgloss.Width(i.Icon) + 1 // icon + space
path := tree.ShortenPath(i.FullPath, m.Width()-2-iconWidth)
title = fmt.Sprintf("%s %s", i.Icon, path)
} else {
title = i.Title()
}
title = ansi.Truncate(title, m.Width()-2, "…")
if index == m.Index() { if index == m.Index() {
style := lipgloss.NewStyle(). style := lipgloss.NewStyle().

View File

@ -3,8 +3,6 @@ package ui
import ( import (
"fmt" "fmt"
"math" "math"
"os"
"os/exec"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@ -13,7 +11,6 @@ import (
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/oug-t/difi/internal/config" "github.com/oug-t/difi/internal/config"
"github.com/oug-t/difi/internal/git" "github.com/oug-t/difi/internal/git"
@ -46,9 +43,6 @@ type Model struct {
statsAdded int statsAdded int
statsDeleted int statsDeleted int
currentFileAdded int
currentFileDeleted int
diffContent string diffContent string
diffLines []string diffLines []string
diffCursor int diffCursor int
@ -58,8 +52,6 @@ type Model struct {
focus Focus focus Focus
showHelp bool showHelp bool
flatMode bool
treeHidden bool
width, height int width, height int
} }
@ -70,12 +62,7 @@ func NewModel(cfg config.Config, targetBranch string) Model {
files, _ := git.ListChangedFiles(targetBranch) files, _ := git.ListChangedFiles(targetBranch)
t := tree.New(files) t := tree.New(files)
var items []list.Item items := t.Items()
if cfg.Flat {
items = t.FlatItems()
} else {
items = t.Items()
}
delegate := TreeDelegate{Focused: true} delegate := TreeDelegate{Focused: true}
l := list.New(items, delegate, 0, 0) l := list.New(items, delegate, 0, 0)
@ -97,7 +84,6 @@ func NewModel(cfg config.Config, targetBranch string) Model {
targetBranch: targetBranch, targetBranch: targetBranch,
repoName: git.GetRepoName(), repoName: git.GetRepoName(),
showHelp: false, showHelp: false,
flatMode: cfg.Flat,
inputBuffer: "", inputBuffer: "",
pendingZ: false, pendingZ: false,
} }
@ -203,9 +189,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "tab": case "tab":
if m.treeHidden {
return m, nil
}
if m.focus == FocusTree { if m.focus == FocusTree {
if item, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && item.IsDir { if item, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && item.IsDir {
return m, nil return m, nil
@ -228,14 +211,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.inputBuffer = "" m.inputBuffer = ""
case "h", "[", "ctrl+h", "left": case "h", "[", "ctrl+h", "left":
if !m.treeHidden {
m.focus = FocusTree m.focus = FocusTree
m.updateTreeFocus() m.updateTreeFocus()
}
m.inputBuffer = "" m.inputBuffer = ""
case "enter": case "enter":
if m.focus == FocusTree && !m.flatMode { if m.focus == FocusTree {
if i, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && i.IsDir { if i, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && i.IsDir {
m.treeState.ToggleExpand(i.FullPath) m.treeState.ToggleExpand(i.FullPath)
m.fileList.SetItems(m.treeState.Items()) m.fileList.SetItems(m.treeState.Items())
@ -243,14 +224,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
} }
if m.selectedPath != "" { if m.selectedPath != "" {
line := 0 if i, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && !i.IsDir {
if m.focus == FocusDiff { // proceed
line = git.CalculateFileLine(m.diffContent, m.diffCursor)
} else { } else {
line = git.CalculateFileLine(m.diffContent, 0) return m, nil
} }
return m, openFugitive(m.selectedPath, line)
} }
fallthrough
case "e": case "e":
if m.selectedPath != "" { if m.selectedPath != "" {
@ -265,52 +245,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
line = git.CalculateFileLine(m.diffContent, 0) line = git.CalculateFileLine(m.diffContent, 0)
} }
m.inputBuffer = "" m.inputBuffer = ""
return m, openFugitive(m.selectedPath, line) return m, git.OpenEditorCmd(m.selectedPath, line, m.targetBranch)
} }
case "f":
m.flatMode = !m.flatMode
prevPath := m.selectedPath
if m.flatMode {
m.fileList.SetItems(m.treeState.FlatItems())
} else {
m.fileList.SetItems(m.treeState.Items())
}
// Restore selection to the same file if possible
restored := false
for idx, item := range m.fileList.Items() {
if ti, ok := item.(tree.TreeItem); ok && ti.FullPath == prevPath {
m.fileList.Select(idx)
restored = true
break
}
}
// In flat mode, auto-select the first file if nothing was restored
if m.flatMode && !restored {
if first, ok := m.fileList.Items()[0].(tree.TreeItem); ok {
m.fileList.Select(0)
m.selectedPath = first.FullPath
m.diffCursor = 0
m.diffViewport.GotoTop()
m.inputBuffer = ""
return m, git.DiffCmd(m.targetBranch, m.selectedPath)
}
}
m.inputBuffer = ""
return m, nil
case "t":
m.treeHidden = !m.treeHidden
if m.treeHidden {
m.focus = FocusDiff
} else {
m.focus = FocusTree
}
m.updateTreeFocus()
m.updateSizes()
m.inputBuffer = ""
return m, nil
case "z": case "z":
if m.focus == FocusDiff { if m.focus == FocusDiff {
m.pendingZ = true m.pendingZ = true
@ -364,7 +301,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.inputBuffer = "" m.inputBuffer = ""
case "j", "down", "ctrl+n": case "j", "down":
keyHandled = true keyHandled = true
count := m.getRepeatCount() count := m.getRepeatCount()
for i := 0; i < count; i++ { for i := 0; i < count; i++ {
@ -381,7 +318,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.inputBuffer = "" m.inputBuffer = ""
case "k", "up", "ctrl+p": case "k", "up":
keyHandled = true keyHandled = true
count := m.getRepeatCount() count := m.getRepeatCount()
for i := 0; i < count; i++ { for i := 0; i < count; i++ {
@ -421,41 +358,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case git.DiffMsg: case git.DiffMsg:
fullLines := strings.Split(msg.Content, "\n") m.diffContent = msg.Content
m.diffLines = strings.Split(msg.Content, "\n")
var cleanLines []string m.diffViewport.SetContent(msg.Content)
var added, deleted int
foundHunk := false
for _, line := range fullLines {
cleanLine := stripAnsi(line)
if strings.HasPrefix(cleanLine, "@@") {
foundHunk = true
}
if !foundHunk {
continue
}
cleanLines = append(cleanLines, line)
// Check "+++" to avoid counting file header
if strings.HasPrefix(cleanLine, "+") && !strings.HasPrefix(cleanLine, "+++") {
added++
} else if strings.HasPrefix(cleanLine, "-") && !strings.HasPrefix(cleanLine, "---") {
deleted++
}
}
m.diffLines = cleanLines
m.currentFileAdded = added
m.currentFileDeleted = deleted
newContent := strings.Join(cleanLines, "\n")
m.diffContent = newContent
m.diffViewport.SetContent(newContent)
m.diffViewport.GotoTop()
case git.EditorFinishedMsg: case git.EditorFinishedMsg:
return m, git.DiffCmd(m.targetBranch, m.selectedPath) return m, git.DiffCmd(m.targetBranch, m.selectedPath)
@ -464,17 +369,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
func openFugitive(path string, line int) tea.Cmd {
lineArg := fmt.Sprintf("+%d", line)
c := exec.Command("nvim", lineArg, "-c", "Gvdiffsplit!", path)
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
return tea.ExecProcess(c, func(err error) tea.Msg {
return git.EditorFinishedMsg{}
})
}
func (m *Model) centerDiffCursor() { func (m *Model) centerDiffCursor() {
halfScreen := m.diffViewport.Height / 2 halfScreen := m.diffViewport.Height / 2
targetOffset := m.diffCursor - halfScreen targetOffset := m.diffCursor - halfScreen
@ -488,7 +382,7 @@ func (m *Model) updateSizes() {
// 1 line Top Bar + 1 line Bottom Bar = 2 reserved // 1 line Top Bar + 1 line Bottom Bar = 2 reserved
reservedHeight := 2 reservedHeight := 2
if m.showHelp { if m.showHelp {
reservedHeight += 7 reservedHeight += 6
} }
contentHeight := m.height - reservedHeight contentHeight := m.height - reservedHeight
@ -496,25 +390,20 @@ func (m *Model) updateSizes() {
contentHeight = 1 contentHeight = 1
} }
treeWidth := int(float64(m.width) * 0.20)
if treeWidth < 20 {
treeWidth = 20
}
// Subtract border height (2) from contentHeight // Subtract border height (2) from contentHeight
listHeight := contentHeight - 2 listHeight := contentHeight - 2
if listHeight < 1 { if listHeight < 1 {
listHeight = 1 listHeight = 1
} }
if m.treeHidden {
m.fileList.SetSize(0, listHeight)
m.diffViewport.Width = m.width - 2 // border (2)
m.diffViewport.Height = listHeight
} else {
treeWidth := int(float64(m.width) * 0.20)
if treeWidth < 20 {
treeWidth = 20
}
m.fileList.SetSize(treeWidth, listHeight) m.fileList.SetSize(treeWidth, listHeight)
m.diffViewport.Width = m.width - treeWidth - 4 // border (2) + padding (2) from tree pane
m.diffViewport.Width = m.width - treeWidth - 2
m.diffViewport.Height = listHeight m.diffViewport.Height = listHeight
}
} }
func (m *Model) updateTreeFocus() { func (m *Model) updateTreeFocus() {
@ -532,7 +421,7 @@ func (m Model) View() string {
var mainContent string var mainContent string
contentHeight := m.height - 2 contentHeight := m.height - 2
if m.showHelp { if m.showHelp {
contentHeight -= 7 contentHeight -= 6
} }
if contentHeight < 0 { if contentHeight < 0 {
contentHeight = 0 contentHeight = 0
@ -544,6 +433,8 @@ func (m Model) View() string {
treeStyle := PaneStyle treeStyle := PaneStyle
if m.focus == FocusTree { if m.focus == FocusTree {
treeStyle = FocusedPaneStyle treeStyle = FocusedPaneStyle
} else {
treeStyle = PaneStyle
} }
treeView := treeStyle.Copy(). treeView := treeStyle.Copy().
@ -552,84 +443,27 @@ func (m Model) View() string {
Render(m.fileList.View()) Render(m.fileList.View())
var rightPaneView string var rightPaneView string
selectedItem, ok := m.fileList.SelectedItem().(tree.TreeItem)
selectedItem, ok := m.fileList.SelectedItem().(tree.TreeItem)
if ok && selectedItem.IsDir { if ok && selectedItem.IsDir {
rightPaneView = m.renderEmptyState(m.diffViewport.Width, m.diffViewport.Height, "Directory: "+selectedItem.Name) rightPaneView = m.renderEmptyState(m.diffViewport.Width, m.diffViewport.Height, "Directory: "+selectedItem.Name)
} else { } else {
var renderedDiff strings.Builder var renderedDiff strings.Builder
// Reserve 2 line for the file header
viewportHeight := m.diffViewport.Height - 2
start := m.diffViewport.YOffset start := m.diffViewport.YOffset
end := start + viewportHeight end := start + m.diffViewport.Height
if end > len(m.diffLines) { if end > len(m.diffLines) {
end = len(m.diffLines) end = len(m.diffLines)
} }
// Calculate stats
added, deleted := 0, 0
for _, line := range m.diffLines {
clean := stripAnsi(line)
if strings.HasPrefix(clean, "+") && !strings.HasPrefix(clean, "+++") {
added++
} else if strings.HasPrefix(clean, "-") && !strings.HasPrefix(clean, "---") {
deleted++
}
}
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Border(lipgloss.NormalBorder(), false, false, true, false).
BorderForeground(lipgloss.Color("236")).
Width(m.diffViewport.Width).
Padding(0, 1)
headerText := fmt.Sprintf("%s (+%d / -%d)", m.selectedPath, added, deleted)
header := headerStyle.Render(headerText)
maxLineWidth := m.diffViewport.Width - 7
if maxLineWidth < 1 {
maxLineWidth = 1
}
for i := start; i < end; i++ { for i := start; i < end; i++ {
rawLine := m.diffLines[i] line := m.diffLines[i]
cleanLine := stripAnsi(rawLine)
line := ansi.Truncate(rawLine, maxLineWidth, "")
// Skip Git metadata noise
if strings.HasPrefix(cleanLine, "diff --git") ||
strings.HasPrefix(cleanLine, "index ") ||
strings.HasPrefix(cleanLine, "new file mode") ||
strings.HasPrefix(cleanLine, "old mode") ||
strings.HasPrefix(cleanLine, "--- /dev/") ||
strings.HasPrefix(cleanLine, "+++ b/") {
continue
}
// Handle Hunk Headers
if strings.HasPrefix(cleanLine, "@@") {
content := cleanLine
if idx := strings.LastIndex(content, "@@"); idx != -1 {
content = content[idx+2:]
}
content = strings.TrimSpace(content)
if content == "" {
content = "..."
}
divider := lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Render(" " + content)
renderedDiff.WriteString(" " + divider + "\n")
continue
}
var numStr string var numStr string
mode := "relative" mode := "relative"
if mode != "hidden" { if mode == "hidden" {
numStr = ""
} else {
isCursor := (i == m.diffCursor) isCursor := (i == m.diffCursor)
if isCursor && mode == "hybrid" { if isCursor && mode == "hybrid" {
realLine := git.CalculateFileLine(m.diffContent, m.diffCursor) realLine := git.CalculateFileLine(m.diffContent, m.diffCursor)
@ -650,6 +484,7 @@ func (m Model) View() string {
} }
if m.focus == FocusDiff && i == m.diffCursor { if m.focus == FocusDiff && i == m.diffCursor {
cleanLine := stripAnsi(line)
line = DiffSelectionStyle.Render(" " + cleanLine) line = DiffSelectionStyle.Render(" " + cleanLine)
} else { } else {
line = " " + line line = " " + line
@ -660,20 +495,14 @@ func (m Model) View() string {
diffContentStr := strings.TrimRight(renderedDiff.String(), "\n") diffContentStr := strings.TrimRight(renderedDiff.String(), "\n")
diffView := DiffStyle.Copy(). rightPaneView = DiffStyle.Copy().
Width(m.diffViewport.Width). Width(m.diffViewport.Width).
Height(viewportHeight). Height(m.diffViewport.Height).
Render(diffContentStr) Render(diffContentStr)
rightPaneView = lipgloss.JoinVertical(lipgloss.Top, header, diffView)
} }
if m.treeHidden {
mainContent = rightPaneView
} else {
mainContent = lipgloss.JoinHorizontal(lipgloss.Top, treeView, rightPaneView) mainContent = lipgloss.JoinHorizontal(lipgloss.Top, treeView, rightPaneView)
} }
}
var bottomBar string var bottomBar string
if m.showHelp { if m.showHelp {
@ -723,7 +552,7 @@ func (m Model) renderTopBar() string {
} }
func (m Model) viewStatusBar() string { func (m Model) viewStatusBar() string {
shortcuts := StatusKeyStyle.Render("? Help q Quit Tab Switch f Flat t Tree") shortcuts := StatusKeyStyle.Render("? Help q Quit Tab Switch")
return StatusBarStyle.Width(m.width).Render(shortcuts) return StatusBarStyle.Width(m.width).Render(shortcuts)
} }
@ -743,8 +572,6 @@ func (m Model) renderHelpDrawer() string {
col4 := lipgloss.JoinVertical(lipgloss.Left, col4 := lipgloss.JoinVertical(lipgloss.Left,
HelpTextStyle.Render("H/M/L Move Cursor"), HelpTextStyle.Render("H/M/L Move Cursor"),
HelpTextStyle.Render("e Edit File"), HelpTextStyle.Render("e Edit File"),
HelpTextStyle.Render("f Flat Tree"),
HelpTextStyle.Render("t Toggle Tree"),
) )
return HelpDrawerStyle.Copy(). return HelpDrawerStyle.Copy().
@ -827,6 +654,8 @@ func (m Model) renderEmptyState(w, h int, statusMsg string) string {
guides, guides,
) )
// Use lipgloss.Place to center the content in the available space
// This automatically handles vertical and horizontal centering.
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, content) return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, content)
} }