Compare commits

...

31 Commits
v0.0.4 ... main

Author SHA1 Message Date
d974a36c93 feat(ui): add emacs-style ctrl-n/ctrl-p aliases for down/up 2026-02-14 09:58:09 -08:00
dd4544b2d0 feat(ui): use t to toggle tree on/off 2026-02-14 09:56:42 -08:00
a8303b51fa feat(ui): add flat tree mode via f or -flat 2026-02-14 09:39:43 -08:00
Tommy Guo
ba956170e9
Merge pull request #23 from oug-t/fix/long-line
fix: reserve 2 lines for view
2026-02-12 17:54:09 -05:00
Tommy Guo
2adb36d497 fix: reserve 2 lines for view 2026-02-12 17:49:12 -05:00
Tommy Guo
0e975c1ae9
Update README.md 2026-02-08 13:10:42 -05:00
Tommy Guo
5d68bc8f02
Merge pull request #21 from oug-t/feat/ui
feat(ui): refine diff view with clean headers
2026-02-08 12:34:50 -05:00
Tommy Guo
a6d7375cf8 feat(ui): refine diff view with clean headers 2026-02-08 12:34:02 -05:00
Tommy Guo
9b776679ca feat: add --plain flag for CI/headless support 2026-02-07 18:27:48 -05:00
Tommy Guo
95e8d3a833
Merge pull request #20 from chenrui333/fix-goreleaser
fix: Embed version in GoReleaser builds and update go files
2026-02-07 12:26:39 -05:00
Rui Chen
da342748ee
chore: add .gitignore
Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-02-07 10:19:32 -05:00
Rui Chen
3f54bc6550
chore: update go.mod/go.sum
Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-02-07 10:19:13 -05:00
Rui Chen
bcaf1599d5
fix: Embed version in GoReleaser builds
- add trimpath and ldflags to inject main.version

- use go-version-file in release workflow

Signed-off-by: Rui Chen <rui@chenrui.dev>
2026-02-07 10:18:43 -05:00
Tommy Guo
cec95779ae
Merge pull request #19 from oug-t/feat/plain
add: --plain non-interactive mode
2026-02-06 19:27:18 -05:00
Tommy Guo
f60216f7c3 add: --plain non-interactive mode 2026-02-06 19:26:38 -05:00
Tommy Guo
bcbf831b70
Update README.md 2026-02-06 17:45:06 -05:00
Tommy Guo
45df5cdc86
Merge pull request #18 from oug-t/feat/fugitive-support
feat: add integration with vim-fugitive
2026-02-06 17:06:57 -05:00
Tommy Guo
b27057f6ee Merge main into feat/fugitive-support 2026-02-06 17:06:41 -05:00
Tommy Guo
d67be9d25b feat: add integration with vim-fugitive 2026-02-06 17:01:06 -05:00
Tommy Guo
762f1b6784
Merge pull request #16 from jetm/add-aur-install-instruction
docs: Add AUR installation instructions for Arch Linux
2026-02-04 17:14:55 -05:00
Javier Tia
ce80530858
docs: Add AUR installation instructions for Arch Linux
The README currently lacks installation instructions for Arch Linux
users, who represent a significant portion of the Linux community.
This forces Arch users to either build manually from source or use
the generic Go install method, both of which bypass the benefits of
package management (dependency tracking, easy updates, and system
integration).

Add AUR installation instructions offering both pre-built binary and
source-based options. This aligns with the existing package manager
approach used for macOS (Homebrew) and provides Arch users with a
native installation path that integrates with their system's package
management workflow.

Signed-off-by: Javier Tia <floss@jetm.me>
2026-02-04 15:37:40 -06:00
Tommy Guo
731fb3b5b7
Merge pull request #15 from Lufftre/fix/truncate-long-lines 2026-02-04 08:33:25 -05:00
Ludvig Noring
b52f2d106f Fix: Truncate long lines 2026-02-04 10:31:54 +01:00
Tommy Guo
acd8532fd6
Merge pull request #14 from oug-t/feat/contributing
docs: add issue and PR templates
2026-02-03 12:49:26 -05:00
Tommy Guo
4dbfae39c2 docs: add issue and PR templates 2026-02-03 12:48:34 -05:00
Tommy Guo
fc0fb2ba36
Enhance README with Git integration and formatting
Updated README.md to include new sections for Git integration and improved formatting.
2026-02-03 11:02:25 -05:00
Tommy Guo
d427999d3e
Fix formatting issues in README.md 2026-02-03 10:25:57 -05:00
Tommy Guo
41e1ea0731 Merge branch 'main' of https://github.com/oug-t/difi 2026-02-01 23:50:06 -05:00
Tommy Guo
0c6ae682f0 chore: default against HEAD 2026-02-01 23:48:53 -05:00
Tommy Guo
5e1252a3c8
Update copyright owner in LICENSE file 2026-02-01 21:13:57 -05:00
Tommy Guo
c1db02c04e
Refactor README for clarity and new features
Updated README to improve formatting and add integrations section.
2026-02-01 12:59:54 -05:00
15 changed files with 474 additions and 75 deletions

29
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,29 @@
---
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

@ -0,0 +1,16 @@
---
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.

15
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,15 @@
## 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
uses: actions/setup-go@v5
with:
go-version: stable
go-version-file: go.mod
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6

14
.gitignore vendored Normal file
View File

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

View File

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

View File

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

108
README.md
View File

@ -1,4 +1,5 @@
<a id="readme-top"></a>
<h1 align="center"><code>difi</code></h1>
<p align="center"><em>Review and refine Git diffs before you push</em></p>
@ -8,39 +9,56 @@
<img src="https://img.shields.io/github/license/oug-t/difi?style=for-the-badge&color=2e3440" />
</p>
<img width="1024" height="576" alt="image" src="https://github.com/user-attachments/assets/ae68aebd-46ed-49d3-90c1-ec0ba7a727d5" />
<p align="center">
<img src= "https://github.com/user-attachments/assets/3695cfd2-148c-463d-9630-547d152adde0" alt="difi_demo" />
</p>
## Why difi?
- ⚡️ **Instant startup** — Built in Go, no background daemon.
- 🎨 **Structured review** — Tree view + side-by-side diffs.
- 🧠 **Editor-aware** — Jump to the exact line in `nvim` / `vim`.
- ⌨️ **Keyboard-first** — Designed for `h j k l`, no mouse.
**git diff** shows changes. **difi** helps you _review_ them.
## Why not `git diff`?
- ⚡️ **Instant** — Built in Go. Launches immediately with no daemon or indexing.
- 🎨 **Structured** — A clean file tree and focused diffs for fast mental parsing.
- 🧠 **Editor-Aware** — Jump straight to the exact line in `nvim`/`vim` to fix issues.
- ⌨️ **Keyboard-First** — Navigate everything with `h j k l`. No mouse required.
- `git diff` is powerful, but its optimized for output — not review.
- difi is designed for the *moment before you push or open a PR*:
<p align="right">(<a href="#readme-top">back to top</a>)</p>
## Installation
### Homebrew (macOS & Linux)
#### Homebrew (macOS & Linux)
```bash
brew tap oug-t/difi
brew install difi
```
### Go Install
#### Go Install
```bash
go install github.com/oug-t/difi/cmd/difi@latest
```
### Manual (Linux / Windows)
#### AUR (Arch Linux)
**Binary (pre-built):**
```bash
pikaur -S difi-bin
```
**Build from source:**
```bash
pikaur -S difi
```
#### Manual (Linux / Windows)
- Download the binary from Releases and add it to your `$PATH`.
<p align="right">(<a href="#readme-top">back to top</a>)</p>
## Workflow
- Run difi in any Git repository.
@ -51,6 +69,8 @@ cd my-project
difi
```
<p align="right">(<a href="#readme-top">back to top</a>)</p>
## Controls
| Key | Action |
@ -61,6 +81,62 @@ difi
| `e` / `Enter` | Edit file (opens editor at selected line) |
| `?` | Toggle help drawer |
| `q` | Quit |
<p align="right">(<a href="#readme-top">back to top</a>)</p>
## Integrations
#### vim-fugitive
- **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)**.
- **Auto-Open:** Instantly jumps to the file and line when you press `e` in the CLI.
- **Visual Diff:** Renders diffs inline with familiar green/red highlights—just like reviewing a PR on GitHub.
- **Interactive Review:** Restore a "deleted" line by simply removing the `-` marker. Discard an added line by deleting it entirely.
- **Context Aware:** Automatically syncs with your `difi` session target.
<p align="left">
<a href="https://github.com/oug-t/difi.nvim">
<img src="https://img.shields.io/badge/Get_difi.nvim-57A143?style=for-the-badge&logo=neovim&logoColor=white" alt="Get difi.nvim" />
</a>
</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
@ -72,9 +148,11 @@ go run cmd/difi/main.go
```
Contributions are especially welcome in:
- diff rendering edge cases
- diff.nvim rendering edge cases
- UI polish and accessibility
- Windows support
<p align="right">(<a href="#readme-top">back to top</a>)</p>
## Star History
@ -87,6 +165,7 @@ Contributions are especially welcome in:
</picture>
</a>
</div>
<p align="right">(<a href="#readme-top">back to top</a>)</p>
---
@ -94,8 +173,3 @@ Contributions are especially welcome in:
<p align="center"> Made with ❤️ by <a href="https://github.com/oug-t">oug-t</a> </p>

View File

@ -4,6 +4,7 @@ import (
"flag"
"fmt"
"os"
"os/exec"
tea "github.com/charmbracelet/bubbletea"
"github.com/oug-t/difi/internal/config"
@ -14,15 +15,18 @@ var version = "dev"
func main() {
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() {
fmt.Fprintf(os.Stderr, "Usage: difi [flags] [target-branch]\n")
fmt.Fprintf(os.Stderr, "\nFlags:\n")
w := os.Stderr
fmt.Fprintln(w, "Usage: difi [flags] [target-branch]")
fmt.Fprintln(w, "\nFlags:")
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " difi # Diff against main\n")
fmt.Fprintf(os.Stderr, " difi develop # Diff against develop\n")
fmt.Fprintf(os.Stderr, " difi HEAD~1 # Diff against last commit\n")
fmt.Fprintln(w, "\nExamples:")
fmt.Fprintln(w, " difi # Diff against default")
fmt.Fprintln(w, " difi develop # Diff against develop")
fmt.Fprintln(w, " difi HEAD~1 # Diff against last commit")
}
flag.Parse()
@ -32,12 +36,24 @@ func main() {
os.Exit(0)
}
target := "main"
target := "HEAD"
if flag.NArg() > 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.Flat = *flat
p := tea.NewProgram(ui.NewModel(cfg, target), tea.WithAltScreen())
if _, err := p.Run(); err != nil {

3
go.mod
View File

@ -6,14 +6,13 @@ require (
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
gopkg.in/yaml.v3 v3.0.1
github.com/charmbracelet/x/ansi v0.10.1
)
require (
github.com/atotto/clipboard v0.1.4 // 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/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

4
go.sum
View File

@ -53,7 +53,3 @@ golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
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/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

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

View File

@ -7,6 +7,7 @@ import (
"strings"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/lipgloss"
)
// FileTree holds the state of the entire file graph.
@ -32,6 +33,7 @@ type TreeItem struct {
Depth int
Expanded bool
Icon string
Flat bool
}
// Implement list.Item interface
@ -104,6 +106,40 @@ func (t *FileTree) Items() []list.Item {
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.
func flatten(node *Node, items *[]list.Item) {
// Collect children to sort
@ -189,3 +225,22 @@ func getIcon(name string, isDir bool) string {
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,6 +7,7 @@ import (
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/oug-t/difi/internal/tree"
)
@ -24,7 +25,15 @@ func (d TreeDelegate) Render(w io.Writer, m list.Model, index int, item list.Ite
return
}
title := i.Title()
var title string
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() {
style := lipgloss.NewStyle().

View File

@ -3,6 +3,8 @@ package ui
import (
"fmt"
"math"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
@ -11,6 +13,7 @@ import (
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/oug-t/difi/internal/config"
"github.com/oug-t/difi/internal/git"
@ -43,6 +46,9 @@ type Model struct {
statsAdded int
statsDeleted int
currentFileAdded int
currentFileDeleted int
diffContent string
diffLines []string
diffCursor int
@ -50,8 +56,10 @@ type Model struct {
inputBuffer string
pendingZ bool
focus Focus
showHelp bool
focus Focus
showHelp bool
flatMode bool
treeHidden bool
width, height int
}
@ -62,7 +70,12 @@ func NewModel(cfg config.Config, targetBranch string) Model {
files, _ := git.ListChangedFiles(targetBranch)
t := tree.New(files)
items := t.Items()
var items []list.Item
if cfg.Flat {
items = t.FlatItems()
} else {
items = t.Items()
}
delegate := TreeDelegate{Focused: true}
l := list.New(items, delegate, 0, 0)
@ -84,6 +97,7 @@ func NewModel(cfg config.Config, targetBranch string) Model {
targetBranch: targetBranch,
repoName: git.GetRepoName(),
showHelp: false,
flatMode: cfg.Flat,
inputBuffer: "",
pendingZ: false,
}
@ -189,6 +203,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "tab":
if m.treeHidden {
return m, nil
}
if m.focus == FocusTree {
if item, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && item.IsDir {
return m, nil
@ -211,12 +228,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.inputBuffer = ""
case "h", "[", "ctrl+h", "left":
m.focus = FocusTree
m.updateTreeFocus()
if !m.treeHidden {
m.focus = FocusTree
m.updateTreeFocus()
}
m.inputBuffer = ""
case "enter":
if m.focus == FocusTree {
if m.focus == FocusTree && !m.flatMode {
if i, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && i.IsDir {
m.treeState.ToggleExpand(i.FullPath)
m.fileList.SetItems(m.treeState.Items())
@ -224,13 +243,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
if m.selectedPath != "" {
if i, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && !i.IsDir {
// proceed
line := 0
if m.focus == FocusDiff {
line = git.CalculateFileLine(m.diffContent, m.diffCursor)
} else {
return m, nil
line = git.CalculateFileLine(m.diffContent, 0)
}
return m, openFugitive(m.selectedPath, line)
}
fallthrough
case "e":
if m.selectedPath != "" {
@ -245,9 +265,52 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
line = git.CalculateFileLine(m.diffContent, 0)
}
m.inputBuffer = ""
return m, git.OpenEditorCmd(m.selectedPath, line, m.targetBranch)
return m, openFugitive(m.selectedPath, line)
}
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":
if m.focus == FocusDiff {
m.pendingZ = true
@ -301,7 +364,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.inputBuffer = ""
case "j", "down":
case "j", "down", "ctrl+n":
keyHandled = true
count := m.getRepeatCount()
for i := 0; i < count; i++ {
@ -318,7 +381,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.inputBuffer = ""
case "k", "up":
case "k", "up", "ctrl+p":
keyHandled = true
count := m.getRepeatCount()
for i := 0; i < count; i++ {
@ -358,9 +421,41 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case git.DiffMsg:
m.diffContent = msg.Content
m.diffLines = strings.Split(msg.Content, "\n")
m.diffViewport.SetContent(msg.Content)
fullLines := strings.Split(msg.Content, "\n")
var cleanLines []string
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:
return m, git.DiffCmd(m.targetBranch, m.selectedPath)
@ -369,6 +464,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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() {
halfScreen := m.diffViewport.Height / 2
targetOffset := m.diffCursor - halfScreen
@ -382,7 +488,7 @@ func (m *Model) updateSizes() {
// 1 line Top Bar + 1 line Bottom Bar = 2 reserved
reservedHeight := 2
if m.showHelp {
reservedHeight += 6
reservedHeight += 7
}
contentHeight := m.height - reservedHeight
@ -390,20 +496,25 @@ func (m *Model) updateSizes() {
contentHeight = 1
}
treeWidth := int(float64(m.width) * 0.20)
if treeWidth < 20 {
treeWidth = 20
}
// Subtract border height (2) from contentHeight
listHeight := contentHeight - 2
if listHeight < 1 {
listHeight = 1
}
m.fileList.SetSize(treeWidth, listHeight)
m.diffViewport.Width = m.width - treeWidth - 2
m.diffViewport.Height = listHeight
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.diffViewport.Width = m.width - treeWidth - 4 // border (2) + padding (2) from tree pane
m.diffViewport.Height = listHeight
}
}
func (m *Model) updateTreeFocus() {
@ -421,7 +532,7 @@ func (m Model) View() string {
var mainContent string
contentHeight := m.height - 2
if m.showHelp {
contentHeight -= 6
contentHeight -= 7
}
if contentHeight < 0 {
contentHeight = 0
@ -433,8 +544,6 @@ func (m Model) View() string {
treeStyle := PaneStyle
if m.focus == FocusTree {
treeStyle = FocusedPaneStyle
} else {
treeStyle = PaneStyle
}
treeView := treeStyle.Copy().
@ -443,27 +552,84 @@ func (m Model) View() string {
Render(m.fileList.View())
var rightPaneView string
selectedItem, ok := m.fileList.SelectedItem().(tree.TreeItem)
if ok && selectedItem.IsDir {
rightPaneView = m.renderEmptyState(m.diffViewport.Width, m.diffViewport.Height, "Directory: "+selectedItem.Name)
} else {
var renderedDiff strings.Builder
// Reserve 2 line for the file header
viewportHeight := m.diffViewport.Height - 2
start := m.diffViewport.YOffset
end := start + m.diffViewport.Height
end := start + viewportHeight
if 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++ {
line := m.diffLines[i]
rawLine := 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
mode := "relative"
if mode == "hidden" {
numStr = ""
} else {
if mode != "hidden" {
isCursor := (i == m.diffCursor)
if isCursor && mode == "hybrid" {
realLine := git.CalculateFileLine(m.diffContent, m.diffCursor)
@ -484,7 +650,6 @@ func (m Model) View() string {
}
if m.focus == FocusDiff && i == m.diffCursor {
cleanLine := stripAnsi(line)
line = DiffSelectionStyle.Render(" " + cleanLine)
} else {
line = " " + line
@ -495,13 +660,19 @@ func (m Model) View() string {
diffContentStr := strings.TrimRight(renderedDiff.String(), "\n")
rightPaneView = DiffStyle.Copy().
diffView := DiffStyle.Copy().
Width(m.diffViewport.Width).
Height(m.diffViewport.Height).
Height(viewportHeight).
Render(diffContentStr)
rightPaneView = lipgloss.JoinVertical(lipgloss.Top, header, diffView)
}
mainContent = lipgloss.JoinHorizontal(lipgloss.Top, treeView, rightPaneView)
if m.treeHidden {
mainContent = rightPaneView
} else {
mainContent = lipgloss.JoinHorizontal(lipgloss.Top, treeView, rightPaneView)
}
}
var bottomBar string
@ -552,7 +723,7 @@ func (m Model) renderTopBar() string {
}
func (m Model) viewStatusBar() string {
shortcuts := StatusKeyStyle.Render("? Help q Quit Tab Switch")
shortcuts := StatusKeyStyle.Render("? Help q Quit Tab Switch f Flat t Tree")
return StatusBarStyle.Width(m.width).Render(shortcuts)
}
@ -572,6 +743,8 @@ func (m Model) renderHelpDrawer() string {
col4 := lipgloss.JoinVertical(lipgloss.Left,
HelpTextStyle.Render("H/M/L Move Cursor"),
HelpTextStyle.Render("e Edit File"),
HelpTextStyle.Render("f Flat Tree"),
HelpTextStyle.Render("t Toggle Tree"),
)
return HelpDrawerStyle.Copy().
@ -654,8 +827,6 @@ func (m Model) renderEmptyState(w, h int, statusMsg string) string {
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)
}