Compare commits

...

39 Commits
v0.0.2 ... 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
Tommy Guo
401107df4d fix: empty-state 2026-02-01 00:33:04 -05:00
Tommy Guo
8cad917626
Merge pull request #12 from oug-t/chore/file-tree
chore: refine file-tree
2026-02-01 00:27:00 -05:00
Tommy Guo
b14181e88a chore: refine file-tree 2026-02-01 00:26:43 -05:00
Tommy Guo
c654424094
Fix formatting and enhance clarity in README.md
Updated README.md to fix formatting issues and improve content clarity.
2026-01-31 19:16:11 -05:00
Tommy Guo
0478c03f04
Merge pull request #11 from oug-t/feat/difi.nvim
feat: add difi.nvim support
2026-01-31 14:01:43 -05:00
Tommy Guo
9648bf2d03 feat: add difi.nvim support 2026-01-31 14:01:19 -05:00
Tommy Guo
556b8984d3
Fix HTML entities in README.md 2026-01-31 00:28:45 -05:00
Tommy Guo
93bc60e59b
Update README.md with new content 2026-01-31 00:03:38 -05:00
18 changed files with 1035 additions and 467 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

106
README.md
View File

@ -1,3 +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,42 +10,55 @@
</p>
<p align="center">
<strong>A calm, focused way to review Git diffs.</strong>
<img src= "https://github.com/user-attachments/assets/3695cfd2-148c-463d-9630-547d152adde0" alt="difi_demo" />
</p>
<img width="2560" height="1440" alt="image" src="https://github.com/user-attachments/assets/fbea297a-b99d-4e98-b369-2925a7651a13" />
## 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.
@ -54,6 +69,8 @@ cd my-project
difi
```
<p align="right">(<a href="#readme-top">back to top</a>)</p>
## Controls
| Key | Action |
@ -64,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
@ -75,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
@ -90,6 +165,7 @@ Contributions are especially welcome in:
</picture>
</a>
</div>
<p align="right">(<a href="#readme-top">back to top</a>)</p>
---
@ -97,5 +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

@ -55,7 +55,7 @@ func DiffCmd(targetBranch, path string) tea.Cmd {
}
}
func OpenEditorCmd(path string, lineNumber int) tea.Cmd {
func OpenEditorCmd(path string, lineNumber int, targetBranch string) tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
if _, err := exec.LookPath("nvim"); err == nil {
@ -73,11 +73,46 @@ func OpenEditorCmd(path string, lineNumber int) tea.Cmd {
c := exec.Command(editor, args...)
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
c.Env = append(os.Environ(), fmt.Sprintf("DIFI_TARGET=%s", targetBranch))
return tea.ExecProcess(c, func(err error) tea.Msg {
return EditorFinishedMsg{Err: err}
})
}
func DiffStats(targetBranch string) (added int, deleted int, err error) {
cmd := exec.Command("git", "diff", "--numstat", targetBranch)
out, err := cmd.Output()
if err != nil {
return 0, 0, fmt.Errorf("git diff stats error: %w", err)
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for _, line := range lines {
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
if parts[0] != "-" {
if n, err := strconv.Atoi(parts[0]); err == nil {
added += n
}
}
if parts[1] != "-" {
if n, err := strconv.Atoi(parts[1]); err == nil {
deleted += n
}
}
}
return added, deleted, nil
}
func CalculateFileLine(diffContent string, visualLineIndex int) int {
lines := strings.Split(diffContent, "\n")
if visualLineIndex >= len(lines) {

View File

@ -1,126 +0,0 @@
package tree
import (
"fmt"
"path/filepath"
"sort"
"strings"
"github.com/charmbracelet/bubbles/list"
)
// TreeItem represents a file or folder
type TreeItem struct {
Path string
FullPath string
IsDir bool
Depth int
}
func (i TreeItem) FilterValue() string { return i.FullPath }
func (i TreeItem) Description() string { return "" }
func (i TreeItem) Title() string {
indent := strings.Repeat(" ", i.Depth)
icon := getIcon(i.Path, i.IsDir)
return fmt.Sprintf("%s%s %s", indent, icon, i.Path)
}
func Build(paths []string) []list.Item {
root := &node{
children: make(map[string]*node),
isDir: true,
}
for _, path := range paths {
parts := strings.Split(path, "/")
current := root
for i, part := range parts {
if _, exists := current.children[part]; !exists {
isDir := i < len(parts)-1
fullPath := strings.Join(parts[:i+1], "/")
current.children[part] = &node{
name: part,
fullPath: fullPath,
children: make(map[string]*node),
isDir: isDir,
}
}
current = current.children[part]
}
}
var items []list.Item
flatten(root, 0, &items)
return items
}
type node struct {
name string
fullPath string
children map[string]*node
isDir bool
}
// flatten recursively converts the tree into a linear list, sorting children by type and name.
func flatten(n *node, depth int, items *[]list.Item) {
keys := make([]string, 0, len(n.children))
for k := range n.children {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
a, b := n.children[keys[i]], n.children[keys[j]]
if a.isDir && !b.isDir {
return true
}
if !a.isDir && b.isDir {
return false
}
return a.name < b.name
})
for _, k := range keys {
child := n.children[k]
*items = append(*items, TreeItem{
Path: child.name,
FullPath: child.fullPath,
IsDir: child.isDir,
Depth: depth,
})
if child.isDir {
flatten(child, depth+1, items)
}
}
}
func getIcon(name string, isDir bool) string {
if isDir {
return " "
}
ext := filepath.Ext(name)
switch strings.ToLower(ext) {
case ".go":
return " "
case ".js", ".ts", ".tsx":
return " "
case ".svelte":
return " "
case ".md":
return " "
case ".json":
return " "
case ".yml", ".yaml":
return " "
case ".html":
return " "
case ".css":
return " "
case ".git":
return " "
case ".dockerfile":
return " "
default:
return " "
}
}

246
internal/tree/tree.go Normal file
View File

@ -0,0 +1,246 @@
package tree
import (
"fmt"
"path/filepath"
"sort"
"strings"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/lipgloss"
)
// FileTree holds the state of the entire file graph.
type FileTree struct {
Root *Node
}
// Node represents a file or directory in the tree.
type Node struct {
Name string
FullPath string
IsDir bool
Children map[string]*Node
Expanded bool
Depth int
}
// TreeItem represents a file or folder for the Bubble Tea list.
type TreeItem struct {
Name string
FullPath string
IsDir bool
Depth int
Expanded bool
Icon string
Flat bool
}
// Implement list.Item interface
func (i TreeItem) FilterValue() string { return i.Name }
func (i TreeItem) Description() string { return "" }
func (i TreeItem) Title() string {
indent := strings.Repeat(" ", i.Depth)
disclosure := " "
if i.IsDir {
if i.Expanded {
disclosure = "▾"
} else {
disclosure = "▸"
}
}
// Icon spacing handled in formatting
return fmt.Sprintf("%s%s %s %s", indent, disclosure, i.Icon, i.Name)
}
// New creates a new FileTree from a list of changed file paths.
func New(paths []string) *FileTree {
root := &Node{
Name: "root",
IsDir: true,
Children: make(map[string]*Node),
Expanded: true, // Root always expanded
Depth: -1, // Root is hidden
}
for _, path := range paths {
addPath(root, path)
}
return &FileTree{Root: root}
}
// addPath inserts a path into the tree, creating directory nodes as needed.
func addPath(root *Node, path string) {
cleanPath := filepath.ToSlash(filepath.Clean(path))
parts := strings.Split(cleanPath, "/")
current := root
for i, name := range parts {
if _, exists := current.Children[name]; !exists {
isFile := i == len(parts)-1
nodePath := name
if current.FullPath != "" {
nodePath = current.FullPath + "/" + name
}
// Directories default to expanded for visibility, or collapsed if preferred
// GitHub usually auto-expands to show changed files. Here we auto-expand.
current.Children[name] = &Node{
Name: name,
FullPath: nodePath,
IsDir: !isFile,
Children: make(map[string]*Node),
Expanded: true,
Depth: current.Depth + 1,
}
}
current = current.Children[name]
}
}
// Items returns the flattened, visible list items based on expansion state.
func (t *FileTree) Items() []list.Item {
var items []list.Item
flatten(t.Root, &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.
func flatten(node *Node, items *[]list.Item) {
// Collect children to sort
children := make([]*Node, 0, len(node.Children))
for _, child := range node.Children {
children = append(children, child)
}
// Sort: Directories first, then alphabetical
sort.Slice(children, func(i, j int) bool {
if children[i].IsDir != children[j].IsDir {
return children[i].IsDir
}
return strings.ToLower(children[i].Name) < strings.ToLower(children[j].Name)
})
for _, child := range children {
*items = append(*items, TreeItem{
Name: child.Name,
FullPath: child.FullPath,
IsDir: child.IsDir,
Depth: child.Depth,
Expanded: child.Expanded,
Icon: getIcon(child.Name, child.IsDir),
})
// Only traverse children if expanded
if child.IsDir && child.Expanded {
flatten(child, items)
}
}
}
// ToggleExpand toggles the expansion state of a specific node.
func (t *FileTree) ToggleExpand(fullPath string) {
node := findNode(t.Root, fullPath)
if node != nil && node.IsDir {
node.Expanded = !node.Expanded
}
}
func findNode(node *Node, fullPath string) *Node {
if node.FullPath == fullPath {
return node
}
// Simple traversal. For very large trees, a map cache in FileTree might be faster.
for _, child := range node.Children {
if strings.HasPrefix(fullPath, child.FullPath) {
if child.FullPath == fullPath {
return child
}
if found := findNode(child, fullPath); found != nil {
return found
}
}
}
return nil
}
func getIcon(name string, isDir bool) string {
if isDir {
return ""
}
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".go":
return ""
case ".js", ".ts", ".tsx":
return ""
case ".css", ".scss":
return ""
case ".html":
return ""
case ".json", ".yaml", ".yml", ".toml":
return ""
case ".md":
return ""
case ".png", ".jpg", ".jpeg", ".svg":
return ""
case ".gitignore", ".gitmodules":
return ""
default:
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

@ -6,6 +6,8 @@ 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"
)
@ -16,25 +18,36 @@ type TreeDelegate struct {
func (d TreeDelegate) Height() int { return 1 }
func (d TreeDelegate) Spacing() int { return 0 }
func (d TreeDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
func (d TreeDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
i, ok := item.(tree.TreeItem)
if !ok {
return
}
title := i.Title()
// If this item is selected
if index == m.Index() {
if d.Focused {
// Render the whole line (including indent) with the selection background
fmt.Fprint(w, SelectedBlockStyle.Render(title))
} else {
// Dimmed selection if focus is on the other panel
fmt.Fprint(w, SelectedBlockStyle.Copy().Foreground(ColorSubtle).Render(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 {
// Normal Item (No icons added, just the text)
fmt.Fprint(w, ItemStyle.Render(title))
title = i.Title()
}
title = ansi.Truncate(title, m.Width()-2, "…")
if index == m.Index() {
style := lipgloss.NewStyle().
Background(lipgloss.Color("237")). // Dark gray background
Foreground(lipgloss.Color("255")). // White text
Bold(true)
if !d.Focused {
style = style.Foreground(lipgloss.Color("245"))
}
fmt.Fprint(w, style.Render(title))
} else {
style := lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
fmt.Fprint(w, style.Render(title))
}
}

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"
@ -24,8 +27,14 @@ const (
FocusDiff
)
type StatsMsg struct {
Added int
Deleted int
}
type Model struct {
fileTree list.Model
fileList list.Model
treeState *tree.FileTree
treeDelegate TreeDelegate
diffViewport viewport.Model
@ -34,6 +43,12 @@ type Model struct {
targetBranch string
repoName string
statsAdded int
statsDeleted int
currentFileAdded int
currentFileDeleted int
diffContent string
diffLines []string
diffCursor int
@ -41,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
}
@ -51,7 +68,14 @@ func NewModel(cfg config.Config, targetBranch string) Model {
InitStyles(cfg)
files, _ := git.ListChangedFiles(targetBranch)
items := tree.Build(files)
t := tree.New(files)
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)
@ -64,7 +88,8 @@ func NewModel(cfg config.Config, targetBranch string) Model {
l.DisableQuitKeybindings()
m := Model{
fileTree: l,
fileList: l,
treeState: t,
treeDelegate: delegate,
diffViewport: viewport.New(0, 0),
focus: FocusTree,
@ -72,23 +97,41 @@ func NewModel(cfg config.Config, targetBranch string) Model {
targetBranch: targetBranch,
repoName: git.GetRepoName(),
showHelp: false,
flatMode: cfg.Flat,
inputBuffer: "",
pendingZ: false,
}
if len(items) > 0 {
if first, ok := items[0].(tree.TreeItem); ok {
m.selectedPath = first.FullPath
if !first.IsDir {
m.selectedPath = first.FullPath
}
}
}
return m
}
func (m Model) Init() tea.Cmd {
var cmds []tea.Cmd
if m.selectedPath != "" {
return git.DiffCmd(m.targetBranch, m.selectedPath)
cmds = append(cmds, git.DiffCmd(m.targetBranch, m.selectedPath))
}
cmds = append(cmds, fetchStatsCmd(m.targetBranch))
return tea.Batch(cmds...)
}
func fetchStatsCmd(target string) tea.Cmd {
return func() tea.Msg {
added, deleted, err := git.DiffStats(target)
if err != nil {
return nil
}
return StatsMsg{Added: added, Deleted: deleted}
}
return nil
}
func (m *Model) getRepeatCount() int {
@ -115,16 +158,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height
m.updateSizes()
case StatsMsg:
m.statsAdded = msg.Added
m.statsDeleted = msg.Deleted
case tea.KeyMsg:
if msg.String() == "q" || msg.String() == "ctrl+c" {
return m, tea.Quit
}
if len(m.fileTree.Items()) == 0 {
if len(m.fileList.Items()) == 0 {
return m, nil
}
// Handle z-prefix commands (zz, zt, zb)
if m.pendingZ {
m.pendingZ = false
if m.focus == FocusDiff {
@ -157,7 +203,13 @@ 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
}
m.focus = FocusDiff
} else {
m.focus = FocusTree
@ -166,16 +218,30 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.inputBuffer = ""
case "l", "]", "ctrl+l", "right":
if m.focus == FocusTree {
if item, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && item.IsDir {
return m, nil
}
}
m.focus = FocusDiff
m.updateTreeFocus()
m.inputBuffer = ""
case "h", "[", "ctrl+h", "left":
m.focus = FocusTree
m.updateTreeFocus()
if !m.treeHidden {
m.focus = FocusTree
m.updateTreeFocus()
}
m.inputBuffer = ""
case "e", "enter":
case "enter":
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())
return m, nil
}
}
if m.selectedPath != "" {
line := 0
if m.focus == FocusDiff {
@ -183,18 +249,74 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
line = git.CalculateFileLine(m.diffContent, 0)
}
m.inputBuffer = ""
return m, git.OpenEditorCmd(m.selectedPath, line)
return m, openFugitive(m.selectedPath, line)
}
// Viewport trigger
case "e":
if m.selectedPath != "" {
if i, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && i.IsDir {
return m, nil
}
line := 0
if m.focus == FocusDiff {
line = git.CalculateFileLine(m.diffContent, m.diffCursor)
} else {
line = git.CalculateFileLine(m.diffContent, 0)
}
m.inputBuffer = ""
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
return m, nil
}
// Cursor screen positioning (H, M, L)
case "H":
if m.focus == FocusDiff {
m.diffCursor = m.diffViewport.YOffset
@ -220,7 +342,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
// Page navigation
case "ctrl+d":
if m.focus == FocusDiff {
halfScreen := m.diffViewport.Height / 2
@ -243,38 +364,36 @@ 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++ {
if m.focus == FocusDiff {
if m.diffCursor < len(m.diffLines)-1 {
m.diffCursor++
// Scroll if hitting bottom edge
if m.diffCursor >= m.diffViewport.YOffset+m.diffViewport.Height {
m.diffViewport.LineDown(1)
}
}
} else {
m.fileTree.CursorDown()
m.fileList.CursorDown()
}
}
m.inputBuffer = ""
case "k", "up":
case "k", "up", "ctrl+p":
keyHandled = true
count := m.getRepeatCount()
for i := 0; i < count; i++ {
if m.focus == FocusDiff {
if m.diffCursor > 0 {
m.diffCursor--
// Scroll if hitting top edge
if m.diffCursor < m.diffViewport.YOffset {
m.diffViewport.LineUp(1)
}
}
} else {
m.fileTree.CursorUp()
m.fileList.CursorUp()
}
}
m.inputBuffer = ""
@ -284,14 +403,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
if len(m.fileTree.Items()) > 0 && m.focus == FocusTree {
if len(m.fileList.Items()) > 0 && m.focus == FocusTree {
if !keyHandled {
m.fileTree, cmd = m.fileTree.Update(msg)
m.fileList, cmd = m.fileList.Update(msg)
cmds = append(cmds, cmd)
}
if item, ok := m.fileTree.SelectedItem().(tree.TreeItem); ok && !item.IsDir {
if item.FullPath != m.selectedPath {
if item, ok := m.fileList.SelectedItem().(tree.TreeItem); ok {
if !item.IsDir && item.FullPath != m.selectedPath {
m.selectedPath = item.FullPath
m.diffCursor = 0
m.diffViewport.GotoTop()
@ -302,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)
@ -313,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
@ -323,9 +485,10 @@ func (m *Model) centerDiffCursor() {
}
func (m *Model) updateSizes() {
reservedHeight := 1
// 1 line Top Bar + 1 line Bottom Bar = 2 reserved
reservedHeight := 2
if m.showHelp {
reservedHeight += 6
reservedHeight += 7
}
contentHeight := m.height - reservedHeight
@ -333,19 +496,30 @@ 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.fileTree.SetSize(treeWidth, contentHeight)
m.diffViewport.Width = m.width - treeWidth - 2
m.diffViewport.Height = contentHeight
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() {
m.treeDelegate.Focused = (m.focus == FocusTree)
m.fileTree.SetDelegate(m.treeDelegate)
m.fileList.SetDelegate(m.treeDelegate)
}
func (m Model) View() string {
@ -353,183 +527,298 @@ func (m Model) View() string {
return "Loading..."
}
if len(m.fileTree.Items()) == 0 {
return m.viewEmptyState()
}
topBar := m.renderTopBar()
// Panes
treeStyle := PaneStyle
if m.focus == FocusTree {
treeStyle = FocusedPaneStyle
} else {
treeStyle = PaneStyle
}
treeView := treeStyle.Copy().
Width(m.fileTree.Width()).
Height(m.fileTree.Height()).
Render(m.fileTree.View())
var renderedDiff strings.Builder
start := m.diffViewport.YOffset
end := start + m.diffViewport.Height
if end > len(m.diffLines) {
end = len(m.diffLines)
}
for i := start; i < end; i++ {
line := m.diffLines[i]
var numStr string
mode := CurrentConfig.UI.LineNumbers
if mode == "hidden" {
numStr = ""
} else {
isCursor := (i == m.diffCursor)
if isCursor && mode == "hybrid" {
realLine := git.CalculateFileLine(m.diffContent, m.diffCursor)
numStr = fmt.Sprintf("%d", realLine)
} else if isCursor && mode == "relative" {
numStr = "0"
} else if mode == "absolute" {
numStr = fmt.Sprintf("%d", i+1)
} else {
dist := int(math.Abs(float64(i - m.diffCursor)))
numStr = fmt.Sprintf("%d", dist)
}
}
lineNumRendered := ""
if numStr != "" {
lineNumRendered = LineNumberStyle.Render(numStr)
}
if m.focus == FocusDiff && i == m.diffCursor {
cleanLine := stripAnsi(line)
line = DiffSelectionStyle.Render(" " + cleanLine)
} else {
line = " " + line
}
renderedDiff.WriteString(lineNumRendered + line + "\n")
}
diffView := DiffStyle.Copy().
Width(m.diffViewport.Width).
Height(m.diffViewport.Height).
Render(renderedDiff.String())
mainPanes := lipgloss.JoinHorizontal(lipgloss.Top, treeView, diffView)
// Bottom area
repoSection := StatusKeyStyle.Render(" " + m.repoName)
divider := StatusDividerStyle.Render("│")
statusText := fmt.Sprintf(" %s ↔ %s", m.currentBranch, m.targetBranch)
if m.inputBuffer != "" {
statusText += fmt.Sprintf(" [Cmd: %s]", m.inputBuffer)
}
branchSection := StatusBarStyle.Render(statusText)
leftStatus := lipgloss.JoinHorizontal(lipgloss.Center, repoSection, divider, branchSection)
rightStatus := StatusBarStyle.Render("? Help")
statusBar := StatusBarStyle.Copy().
Width(m.width).
Render(lipgloss.JoinHorizontal(lipgloss.Top,
leftStatus,
lipgloss.PlaceHorizontal(m.width-lipgloss.Width(leftStatus)-lipgloss.Width(rightStatus), lipgloss.Right, rightStatus),
))
var finalView string
var mainContent string
contentHeight := m.height - 2
if m.showHelp {
col1 := lipgloss.JoinVertical(lipgloss.Left,
HelpTextStyle.Render("↑/k Move Up"),
HelpTextStyle.Render("↓/j Move Down"),
)
col2 := lipgloss.JoinVertical(lipgloss.Left,
HelpTextStyle.Render("←/h Left Panel"),
HelpTextStyle.Render("→/l Right Panel"),
)
col3 := lipgloss.JoinVertical(lipgloss.Left,
HelpTextStyle.Render("C-d/u Page Dn/Up"),
HelpTextStyle.Render("zz/zt Scroll View"),
)
col4 := lipgloss.JoinVertical(lipgloss.Left,
HelpTextStyle.Render("H/M/L Move Cursor"),
HelpTextStyle.Render("e Edit File"),
)
helpDrawer := HelpDrawerStyle.Copy().
Width(m.width).
Render(lipgloss.JoinHorizontal(lipgloss.Top,
col1,
lipgloss.NewStyle().Width(4).Render(""),
col2,
lipgloss.NewStyle().Width(4).Render(""),
col3,
lipgloss.NewStyle().Width(4).Render(""),
col4,
))
finalView = lipgloss.JoinVertical(lipgloss.Top, mainPanes, helpDrawer, statusBar)
} else {
finalView = lipgloss.JoinVertical(lipgloss.Top, mainPanes, statusBar)
contentHeight -= 7
}
if contentHeight < 0 {
contentHeight = 0
}
return finalView
if len(m.fileList.Items()) == 0 {
mainContent = m.renderEmptyState(m.width, contentHeight, "No changes found against "+m.targetBranch)
} else {
treeStyle := PaneStyle
if m.focus == FocusTree {
treeStyle = FocusedPaneStyle
}
treeView := treeStyle.Copy().
Width(m.fileList.Width()).
Height(m.fileList.Height()).
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 + 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++ {
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" {
isCursor := (i == m.diffCursor)
if isCursor && mode == "hybrid" {
realLine := git.CalculateFileLine(m.diffContent, m.diffCursor)
numStr = fmt.Sprintf("%d", realLine)
} else if isCursor && mode == "relative" {
numStr = "0"
} else if mode == "absolute" {
numStr = fmt.Sprintf("%d", i+1)
} else {
dist := int(math.Abs(float64(i - m.diffCursor)))
numStr = fmt.Sprintf("%d", dist)
}
}
lineNumRendered := ""
if numStr != "" {
lineNumRendered = LineNumberStyle.Render(numStr)
}
if m.focus == FocusDiff && i == m.diffCursor {
line = DiffSelectionStyle.Render(" " + cleanLine)
} else {
line = " " + line
}
renderedDiff.WriteString(lineNumRendered + line + "\n")
}
diffContentStr := strings.TrimRight(renderedDiff.String(), "\n")
diffView := DiffStyle.Copy().
Width(m.diffViewport.Width).
Height(viewportHeight).
Render(diffContentStr)
rightPaneView = lipgloss.JoinVertical(lipgloss.Top, header, diffView)
}
if m.treeHidden {
mainContent = rightPaneView
} else {
mainContent = lipgloss.JoinHorizontal(lipgloss.Top, treeView, rightPaneView)
}
}
var bottomBar string
if m.showHelp {
bottomBar = m.renderHelpDrawer()
} else {
bottomBar = m.viewStatusBar()
}
return lipgloss.JoinVertical(lipgloss.Top, topBar, mainContent, bottomBar)
}
func (m Model) viewEmptyState() string {
func (m Model) renderTopBar() string {
repo := fmt.Sprintf(" %s", m.repoName)
branches := fmt.Sprintf(" %s ➜ %s", m.currentBranch, m.targetBranch)
info := fmt.Sprintf("%s %s", repo, branches)
leftSide := TopInfoStyle.Render(info)
middle := ""
if m.selectedPath != "" {
middle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(m.selectedPath)
}
rightSide := ""
if m.statsAdded > 0 || m.statsDeleted > 0 {
added := TopStatsAddedStyle.Render(fmt.Sprintf("+%d", m.statsAdded))
deleted := TopStatsDeletedStyle.Render(fmt.Sprintf("-%d", m.statsDeleted))
rightSide = lipgloss.JoinHorizontal(lipgloss.Center, added, deleted)
}
availWidth := m.width - lipgloss.Width(leftSide) - lipgloss.Width(rightSide)
if availWidth < 0 {
availWidth = 0
}
midWidth := lipgloss.Width(middle)
var centerBlock string
if midWidth > availWidth {
centerBlock = strings.Repeat(" ", availWidth)
} else {
padL := (availWidth - midWidth) / 2
padR := availWidth - midWidth - padL
centerBlock = strings.Repeat(" ", padL) + middle + strings.Repeat(" ", padR)
}
finalBar := lipgloss.JoinHorizontal(lipgloss.Top, leftSide, centerBlock, rightSide)
return TopBarStyle.Width(m.width).Render(finalBar)
}
func (m Model) viewStatusBar() string {
shortcuts := StatusKeyStyle.Render("? Help q Quit Tab Switch f Flat t Tree")
return StatusBarStyle.Width(m.width).Render(shortcuts)
}
func (m Model) renderHelpDrawer() string {
col1 := lipgloss.JoinVertical(lipgloss.Left,
HelpTextStyle.Render("↑/k Move Up"),
HelpTextStyle.Render("↓/j Move Down"),
)
col2 := lipgloss.JoinVertical(lipgloss.Left,
HelpTextStyle.Render("←/h Left Panel"),
HelpTextStyle.Render("→/l Right Panel"),
)
col3 := lipgloss.JoinVertical(lipgloss.Left,
HelpTextStyle.Render("C-d/u Page Dn/Up"),
HelpTextStyle.Render("zz/zt Scroll View"),
)
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().
Width(m.width).
Render(lipgloss.JoinHorizontal(lipgloss.Top,
col1,
lipgloss.NewStyle().Width(4).Render(""),
col2,
lipgloss.NewStyle().Width(4).Render(""),
col3,
lipgloss.NewStyle().Width(4).Render(""),
col4,
))
}
func (m Model) renderEmptyState(w, h int, statusMsg string) string {
logo := EmptyLogoStyle.Render("difi")
desc := EmptyDescStyle.Render("A calm, focused way to review Git diffs.")
statusMsg := fmt.Sprintf("✓ No changes found against '%s'", m.targetBranch)
status := EmptyStatusStyle.Render(statusMsg)
usageHeader := EmptyHeaderStyle.Render("Usage Patterns")
cmd1 := lipgloss.NewStyle().Foreground(ColorText).Render("difi")
desc1 := EmptyCodeStyle.Render("Diff against main")
cmd2 := lipgloss.NewStyle().Foreground(ColorText).Render("difi develop")
desc2 := EmptyCodeStyle.Render("Diff against target branch")
cmd3 := lipgloss.NewStyle().Foreground(ColorText).Render("difi HEAD~1")
desc3 := EmptyCodeStyle.Render("Diff against previous commit")
cmd2 := lipgloss.NewStyle().Foreground(ColorText).Render("difi dev")
desc2 := EmptyCodeStyle.Render("Diff against branch")
usageBlock := lipgloss.JoinVertical(lipgloss.Left,
usageHeader,
lipgloss.JoinHorizontal(lipgloss.Left, cmd1, desc1),
lipgloss.JoinHorizontal(lipgloss.Left, cmd2, desc2),
lipgloss.JoinHorizontal(lipgloss.Left, cmd3, desc3),
lipgloss.JoinHorizontal(lipgloss.Left, cmd1, " ", desc1),
lipgloss.JoinHorizontal(lipgloss.Left, cmd2, " ", desc2),
)
navHeader := EmptyHeaderStyle.Render("Navigation")
key1 := lipgloss.NewStyle().Foreground(ColorText).Render("Tab")
key2 := lipgloss.NewStyle().Foreground(ColorText).Render("j/k")
keyDesc1 := EmptyCodeStyle.Render("Switch panels")
key2 := lipgloss.NewStyle().Foreground(ColorText).Render("j / k")
keyDesc2 := EmptyCodeStyle.Render("Move cursor")
key3 := lipgloss.NewStyle().Foreground(ColorText).Render("zz/zt")
keyDesc3 := EmptyCodeStyle.Render("Center/Top")
navBlock := lipgloss.JoinVertical(lipgloss.Left,
navHeader,
lipgloss.JoinHorizontal(lipgloss.Left, key1, keyDesc1),
lipgloss.JoinHorizontal(lipgloss.Left, key2, keyDesc2),
lipgloss.JoinHorizontal(lipgloss.Left, key3, keyDesc3),
lipgloss.JoinHorizontal(lipgloss.Left, key1, " ", keyDesc1),
lipgloss.JoinHorizontal(lipgloss.Left, key2, " ", keyDesc2),
)
guides := lipgloss.JoinHorizontal(lipgloss.Top,
usageBlock,
lipgloss.NewStyle().Width(8).Render(""),
navBlock,
nvimHeader := EmptyHeaderStyle.Render("Neovim Integration")
nvim1 := lipgloss.NewStyle().Foreground(ColorText).Render("oug-t/difi.nvim")
nvimDesc1 := EmptyCodeStyle.Render("Install plugin")
nvim2 := lipgloss.NewStyle().Foreground(ColorText).Render("Press 'e'")
nvimDesc2 := EmptyCodeStyle.Render("Edit with context")
nvimBlock := lipgloss.JoinVertical(lipgloss.Left,
nvimHeader,
lipgloss.JoinHorizontal(lipgloss.Left, nvim1, " ", nvimDesc1),
lipgloss.JoinHorizontal(lipgloss.Left, nvim2, " ", nvimDesc2),
)
var guides string
if w > 80 {
guides = lipgloss.JoinHorizontal(lipgloss.Top,
usageBlock,
lipgloss.NewStyle().Width(6).Render(""),
navBlock,
lipgloss.NewStyle().Width(6).Render(""),
nvimBlock,
)
} else {
topRow := lipgloss.JoinHorizontal(lipgloss.Top, usageBlock, lipgloss.NewStyle().Width(4).Render(""), navBlock)
guides = lipgloss.JoinVertical(lipgloss.Left,
topRow,
lipgloss.NewStyle().Height(1).Render(""),
nvimBlock,
)
}
content := lipgloss.JoinVertical(lipgloss.Center,
logo,
desc,
@ -538,16 +827,7 @@ func (m Model) viewEmptyState() string {
guides,
)
var verticalPad string
if m.height > lipgloss.Height(content) {
lines := (m.height - lipgloss.Height(content)) / 2
verticalPad = strings.Repeat("\n", lines)
}
return lipgloss.JoinVertical(lipgloss.Top,
verticalPad,
lipgloss.PlaceHorizontal(m.width, lipgloss.Center, content),
)
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, content)
}
func stripAnsi(str string) string {

View File

@ -1,118 +1,74 @@
package ui
import (
"github.com/charmbracelet/lipgloss"
"github.com/oug-t/difi/internal/config"
)
import "github.com/charmbracelet/lipgloss"
var (
// Config
CurrentConfig config.Config
// -- NORD PALETTE --
nord0 = lipgloss.Color("#2E3440") // Dark background
nord3 = lipgloss.Color("#4C566A") // Separators / Dimmed
nord4 = lipgloss.Color("#D8DEE9") // Main Text
nord11 = lipgloss.Color("#BF616A") // Red (Deleted)
nord14 = lipgloss.Color("#A3BE8C") // Green (Added)
nord9 = lipgloss.Color("#81A1C1") // Blue (Focus)
// Theme colors
ColorBorder = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
ColorFocus = lipgloss.AdaptiveColor{Light: "#000000", Dark: "#E5E5E5"}
ColorText = lipgloss.AdaptiveColor{Light: "#1F1F1F", Dark: "#FFFFFF"}
ColorSubtle = lipgloss.AdaptiveColor{Light: "#A8A8A8", Dark: "#D0D0D0"}
ColorCursorBg = lipgloss.AdaptiveColor{Light: "#E5E5E5", Dark: "#3E3E3E"}
ColorAccent = lipgloss.AdaptiveColor{Light: "#00ADD8", Dark: "#00ADD8"} // Go blue
// Pane styles
// -- PANE STYLES --
PaneStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, true, false, false).
BorderForeground(ColorBorder)
Border(lipgloss.RoundedBorder()).
BorderForeground(nord3). // Goal 1: Nord3 Separator
Padding(0, 1)
FocusedPaneStyle = PaneStyle.Copy().
BorderForeground(ColorFocus)
BorderForeground(nord9)
DiffStyle = lipgloss.NewStyle().Padding(0, 0)
ItemStyle = lipgloss.NewStyle().PaddingLeft(2)
// -- TOP BAR STYLES (Goal 2) --
TopBarStyle = lipgloss.NewStyle().
Background(nord0).
Foreground(nord4).
Height(1)
// List styles
SelectedItemStyle = lipgloss.NewStyle().
PaddingLeft(1).
Background(ColorCursorBg).
Foreground(ColorText).
Bold(true).
Width(1000)
TopInfoStyle = lipgloss.NewStyle().
Bold(true).
Padding(0, 1)
SelectedBlockStyle = lipgloss.NewStyle().
Background(ColorCursorBg).
Foreground(ColorText).
Bold(true).
TopStatsAddedStyle = lipgloss.NewStyle().
Foreground(nord14).
PaddingLeft(1)
// Icon styles
FolderIconStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F7B96E", Dark: "#E5C07B"})
FileIconStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#969696", Dark: "#ABB2BF"})
TopStatsDeletedStyle = lipgloss.NewStyle().
Foreground(nord11).
PaddingLeft(1).
PaddingRight(1)
// Diff view styles
LineNumberStyle = lipgloss.NewStyle().
Foreground(ColorSubtle).
PaddingRight(1).
Width(4)
// -- TREE STYLES --
DirectoryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("99"))
FileStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
DiffSelectionStyle = lipgloss.NewStyle().
Background(ColorCursorBg).
Width(1000)
// -- DIFF VIEW STYLES --
DiffStyle = lipgloss.NewStyle().Padding(0, 0)
DiffSelectionStyle = lipgloss.NewStyle().Background(lipgloss.Color("237")).Foreground(lipgloss.Color("255"))
LineNumberStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Width(4).Align(lipgloss.Right).MarginRight(1)
// Status bar colors
ColorBarBg = lipgloss.AdaptiveColor{Light: "#F2F2F2", Dark: "#1F1F1F"}
ColorBarFg = lipgloss.AdaptiveColor{Light: "#6E6E6E", Dark: "#9E9E9E"}
// -- EMPTY STATE STYLES --
EmptyLogoStyle = lipgloss.NewStyle().Foreground(nord9).Bold(true).MarginBottom(1)
EmptyDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).MarginBottom(1)
EmptyStatusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).MarginBottom(2)
EmptyHeaderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Bold(true).MarginBottom(1)
EmptyCodeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
// Status bar styles
StatusBarStyle = lipgloss.NewStyle().
Foreground(ColorBarFg).
Background(ColorBarBg).
Padding(0, 1)
// -- HELPER STYLES --
HelpDrawerStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), true, false, false, false).BorderForeground(nord3).Padding(1, 2)
HelpTextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).MarginRight(2)
StatusKeyStyle = lipgloss.NewStyle().
Foreground(ColorText).
Background(ColorBarBg).
Bold(true).
Padding(0, 1)
// -- BOTTOM STATUS BAR STYLES --
StatusBarStyle = lipgloss.NewStyle().Background(nord0).Foreground(nord4).Height(1)
StatusKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Padding(0, 1)
StatusRepoStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7aa2f7")).Padding(0, 1)
StatusBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#bb9af7")).Padding(0, 1)
StatusAddedStyle = lipgloss.NewStyle().Foreground(nord14).Padding(0, 1)
StatusDeletedStyle = lipgloss.NewStyle().Foreground(nord11).Padding(0, 1)
StatusDividerStyle = lipgloss.NewStyle().Foreground(nord3).Padding(0, 1)
StatusDividerStyle = lipgloss.NewStyle().
Foreground(ColorSubtle).
Background(ColorBarBg).
Padding(0, 0)
// Help styles
HelpTextStyle = lipgloss.NewStyle().
Foreground(ColorSubtle).
Padding(0, 1)
HelpDrawerStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true, false, false, false).
BorderForeground(ColorBorder).
Padding(1, 2)
// Empty/landing styles
EmptyLogoStyle = lipgloss.NewStyle().
Foreground(ColorAccent).
Bold(true).
PaddingBottom(1)
EmptyDescStyle = lipgloss.NewStyle().
Foreground(ColorSubtle).
PaddingBottom(2)
EmptyStatusStyle = lipgloss.NewStyle().
Foreground(ColorText).
Background(ColorCursorBg).
Padding(0, 2).
MarginBottom(2)
EmptyCodeStyle = lipgloss.NewStyle().
Foreground(ColorSubtle).
MarginLeft(2)
EmptyHeaderStyle = lipgloss.NewStyle().
Foreground(ColorText).
Bold(true).
MarginBottom(1)
ColorText = lipgloss.Color("252")
)
func InitStyles(cfg config.Config) {
CurrentConfig = cfg
}
func InitStyles(cfg interface{}) {}