Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d974a36c93 | |||
| dd4544b2d0 | |||
| a8303b51fa | |||
|
|
ba956170e9 | ||
|
|
2adb36d497 | ||
|
|
0e975c1ae9 | ||
|
|
5d68bc8f02 | ||
|
|
a6d7375cf8 | ||
|
|
9b776679ca | ||
|
|
95e8d3a833 | ||
|
|
da342748ee | ||
|
|
3f54bc6550 | ||
|
|
bcaf1599d5 | ||
|
|
cec95779ae | ||
|
|
f60216f7c3 | ||
|
|
bcbf831b70 | ||
|
|
45df5cdc86 | ||
|
|
b27057f6ee | ||
|
|
d67be9d25b | ||
|
|
762f1b6784 | ||
|
|
ce80530858 | ||
|
|
731fb3b5b7 | ||
|
|
b52f2d106f | ||
|
|
acd8532fd6 | ||
|
|
4dbfae39c2 | ||
|
|
fc0fb2ba36 | ||
|
|
d427999d3e | ||
|
|
41e1ea0731 | ||
|
|
0c6ae682f0 | ||
|
|
5e1252a3c8 |
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
||||
16
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
16
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
15
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -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
14
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# build artifacts
|
||||
/dist/
|
||||
/bin/
|
||||
/coverage/
|
||||
|
||||
# Go test binaries and coverage
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# logs
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
|
@ -7,6 +7,10 @@ before:
|
|||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w -X main.version={{ .Version }}
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
|
|
|
|||
2
LICENSE
2
LICENSE
|
|
@ -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
|
||||
|
|
|
|||
66
README.md
66
README.md
|
|
@ -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>
|
||||
|
||||
|
|
@ -9,12 +10,12 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://github.com/user-attachments/assets/70c177cb-9ad8-4e53-8837-f5e7b3f22fa0" alt="difi" />
|
||||
<img src= "https://github.com/user-attachments/assets/3695cfd2-148c-463d-9630-547d152adde0" alt="difi_demo" />
|
||||
</p>
|
||||
|
||||
## 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.
|
||||
- 🎨 **Structured** — A clean file tree and focused diffs for fast mental parsing.
|
||||
|
|
@ -38,6 +39,20 @@ brew install difi
|
|||
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)
|
||||
|
||||
- Download the binary from Releases and add it to your `$PATH`.
|
||||
|
|
@ -71,7 +86,27 @@ difi
|
|||
|
||||
## Integrations
|
||||
|
||||
#### Neovim
|
||||
#### 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)**.
|
||||
|
||||
|
|
@ -86,6 +121,24 @@ Get the ultimate review experience with **[difi.nvim](https://github.com/oug-t/d
|
|||
</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
|
||||
|
||||
```bash
|
||||
|
|
@ -95,6 +148,7 @@ go run cmd/difi/main.go
|
|||
```
|
||||
|
||||
Contributions are especially welcome in:
|
||||
|
||||
- diff.nvim rendering edge cases
|
||||
- UI polish and accessibility
|
||||
- Windows support
|
||||
|
|
@ -119,9 +173,3 @@ Contributions are especially welcome in:
|
|||
<p align="center"> Made with ❤️ by <a href="https://github.com/oug-t">oug-t</a> </p>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
3
go.mod
|
|
@ -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
4
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
package config
|
||||
|
||||
type Config struct {
|
||||
UI UIConfig
|
||||
UI UIConfig
|
||||
Flat bool
|
||||
}
|
||||
|
||||
type UIConfig struct {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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().
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user