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 - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: stable go-version-file: go.mod
- name: Run GoReleaser - name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6 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: 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 Tommy Guo Copyright (c) 2026 Xiyuan 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

108
README.md
View File

@ -1,4 +1,5 @@
<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>
@ -8,39 +9,56 @@
<img src="https://img.shields.io/github/license/oug-t/difi?style=for-the-badge&color=2e3440" /> <img src="https://img.shields.io/github/license/oug-t/difi?style=for-the-badge&color=2e3440" />
</p> </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? ## Why difi?
- ⚡️ **Instant startup** — Built in Go, no background daemon. **git diff** shows changes. **difi** helps you _review_ them.
- 🎨 **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.
## 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. <p align="right">(<a href="#readme-top">back to top</a>)</p>
- difi is designed for the *moment before you push or open a PR*:
## Installation ## Installation
### Homebrew (macOS & Linux) #### Homebrew (macOS & Linux)
```bash ```bash
brew tap oug-t/difi brew tap oug-t/difi
brew install difi brew install difi
``` ```
### Go Install #### Go Install
```bash ```bash
go install github.com/oug-t/difi/cmd/difi@latest 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`. - Download the binary from Releases and add it to your `$PATH`.
<p align="right">(<a href="#readme-top">back to top</a>)</p>
## Workflow ## Workflow
- Run difi in any Git repository. - Run difi in any Git repository.
@ -51,6 +69,8 @@ cd my-project
difi difi
``` ```
<p align="right">(<a href="#readme-top">back to top</a>)</p>
## Controls ## Controls
| Key | Action | | Key | Action |
@ -61,6 +81,62 @@ difi
| `e` / `Enter` | Edit file (opens editor at selected line) | | `e` / `Enter` | Edit file (opens editor at selected line) |
| `?` | Toggle help drawer | | `?` | Toggle help drawer |
| `q` | Quit | | `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> <p align="right">(<a href="#readme-top">back to top</a>)</p>
## Contributing ## Contributing
@ -72,9 +148,11 @@ go run cmd/difi/main.go
``` ```
Contributions are especially welcome in: Contributions are especially welcome in:
- diff rendering edge cases
- diff.nvim rendering edge cases
- UI polish and accessibility - UI polish and accessibility
- Windows support - Windows support
<p align="right">(<a href="#readme-top">back to top</a>)</p> <p align="right">(<a href="#readme-top">back to top</a>)</p>
## Star History ## Star History
@ -87,6 +165,7 @@ Contributions are especially welcome in:
</picture> </picture>
</a> </a>
</div> </div>
<p align="right">(<a href="#readme-top">back to top</a>)</p> <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> <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" "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"
@ -14,15 +15,18 @@ 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() {
fmt.Fprintf(os.Stderr, "Usage: difi [flags] [target-branch]\n") w := os.Stderr
fmt.Fprintf(os.Stderr, "\nFlags:\n") fmt.Fprintln(w, "Usage: difi [flags] [target-branch]")
fmt.Fprintln(w, "\nFlags:")
flag.PrintDefaults() flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nExamples:\n") fmt.Fprintln(w, "\nExamples:")
fmt.Fprintf(os.Stderr, " difi # Diff against main\n") fmt.Fprintln(w, " difi # Diff against default")
fmt.Fprintf(os.Stderr, " difi develop # Diff against develop\n") fmt.Fprintln(w, " difi develop # Diff against develop")
fmt.Fprintf(os.Stderr, " difi HEAD~1 # Diff against last commit\n") fmt.Fprintln(w, " difi HEAD~1 # Diff against last commit")
} }
flag.Parse() flag.Parse()
@ -32,12 +36,24 @@ func main() {
os.Exit(0) os.Exit(0)
} }
target := "main" target := "HEAD"
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,14 +6,13 @@ 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
gopkg.in/yaml.v3 v3.0.1 github.com/charmbracelet/x/ansi v0.10.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,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/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

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

View File

@ -7,6 +7,7 @@ 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.
@ -32,6 +33,7 @@ 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
@ -104,6 +106,40 @@ 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
@ -189,3 +225,22 @@ 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,6 +7,7 @@ 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"
) )
@ -24,7 +25,15 @@ func (d TreeDelegate) Render(w io.Writer, m list.Model, index int, item list.Ite
return 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() { if index == m.Index() {
style := lipgloss.NewStyle(). style := lipgloss.NewStyle().

View File

@ -3,6 +3,8 @@ package ui
import ( import (
"fmt" "fmt"
"math" "math"
"os"
"os/exec"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@ -11,6 +13,7 @@ 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"
@ -43,6 +46,9 @@ 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
@ -50,8 +56,10 @@ type Model struct {
inputBuffer string inputBuffer string
pendingZ bool pendingZ bool
focus Focus focus Focus
showHelp bool showHelp bool
flatMode bool
treeHidden bool
width, height int width, height int
} }
@ -62,7 +70,12 @@ func NewModel(cfg config.Config, targetBranch string) Model {
files, _ := git.ListChangedFiles(targetBranch) files, _ := git.ListChangedFiles(targetBranch)
t := tree.New(files) 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} delegate := TreeDelegate{Focused: true}
l := list.New(items, delegate, 0, 0) l := list.New(items, delegate, 0, 0)
@ -84,6 +97,7 @@ 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,
} }
@ -189,6 +203,9 @@ 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
@ -211,12 +228,14 @@ 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":
m.focus = FocusTree if !m.treeHidden {
m.updateTreeFocus() m.focus = FocusTree
m.updateTreeFocus()
}
m.inputBuffer = "" m.inputBuffer = ""
case "enter": case "enter":
if m.focus == FocusTree { if m.focus == FocusTree && !m.flatMode {
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())
@ -224,13 +243,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
} }
if m.selectedPath != "" { if m.selectedPath != "" {
if i, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && !i.IsDir { line := 0
// proceed if m.focus == FocusDiff {
line = git.CalculateFileLine(m.diffContent, m.diffCursor)
} else { } else {
return m, nil line = git.CalculateFileLine(m.diffContent, 0)
} }
return m, openFugitive(m.selectedPath, line)
} }
fallthrough
case "e": case "e":
if m.selectedPath != "" { if m.selectedPath != "" {
@ -245,9 +265,52 @@ 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, 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": case "z":
if m.focus == FocusDiff { if m.focus == FocusDiff {
m.pendingZ = true m.pendingZ = true
@ -301,7 +364,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.inputBuffer = "" m.inputBuffer = ""
case "j", "down": case "j", "down", "ctrl+n":
keyHandled = true keyHandled = true
count := m.getRepeatCount() count := m.getRepeatCount()
for i := 0; i < count; i++ { for i := 0; i < count; i++ {
@ -318,7 +381,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.inputBuffer = "" m.inputBuffer = ""
case "k", "up": case "k", "up", "ctrl+p":
keyHandled = true keyHandled = true
count := m.getRepeatCount() count := m.getRepeatCount()
for i := 0; i < count; i++ { 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) { switch msg := msg.(type) {
case git.DiffMsg: case git.DiffMsg:
m.diffContent = msg.Content fullLines := strings.Split(msg.Content, "\n")
m.diffLines = strings.Split(msg.Content, "\n")
m.diffViewport.SetContent(msg.Content) 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: case git.EditorFinishedMsg:
return m, git.DiffCmd(m.targetBranch, m.selectedPath) 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...) 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
@ -382,7 +488,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 += 6 reservedHeight += 7
} }
contentHeight := m.height - reservedHeight contentHeight := m.height - reservedHeight
@ -390,20 +496,25 @@ 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
} }
m.fileList.SetSize(treeWidth, listHeight)
m.diffViewport.Width = m.width - treeWidth - 2 if m.treeHidden {
m.diffViewport.Height = listHeight 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() { func (m *Model) updateTreeFocus() {
@ -421,7 +532,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 -= 6 contentHeight -= 7
} }
if contentHeight < 0 { if contentHeight < 0 {
contentHeight = 0 contentHeight = 0
@ -433,8 +544,6 @@ 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().
@ -443,27 +552,84 @@ 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 + m.diffViewport.Height end := start + viewportHeight
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++ {
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 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)
@ -484,7 +650,6 @@ 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
@ -495,13 +660,19 @@ func (m Model) View() string {
diffContentStr := strings.TrimRight(renderedDiff.String(), "\n") diffContentStr := strings.TrimRight(renderedDiff.String(), "\n")
rightPaneView = DiffStyle.Copy(). diffView := DiffStyle.Copy().
Width(m.diffViewport.Width). Width(m.diffViewport.Width).
Height(m.diffViewport.Height). Height(viewportHeight).
Render(diffContentStr) 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 var bottomBar string
@ -552,7 +723,7 @@ func (m Model) renderTopBar() string {
} }
func (m Model) viewStatusBar() 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) return StatusBarStyle.Width(m.width).Render(shortcuts)
} }
@ -572,6 +743,8 @@ 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().
@ -654,8 +827,6 @@ 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)
} }