Compare commits

..

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

18 changed files with 459 additions and 1047 deletions

View File

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

View File

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

View File

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

View File

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

14
.gitignore vendored
View File

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

View File

@ -7,10 +7,6 @@ before:
builds:
- 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 Xiyuan Guo
Copyright (c) 2026 Tommy 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

136
README.md
View File

@ -1,8 +1,4 @@
<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>
<h1 align="center">difi</h1>
<p align="center">
<img src="https://img.shields.io/badge/Go-00ADD8?style=for-the-badge&logo=go&logoColor=white" />
<img src="https://img.shields.io/badge/Bubble_Tea-E2386F?style=for-the-badge&logo=tea&logoColor=white" />
@ -10,54 +6,41 @@
</p>
<p align="center">
<img src= "https://github.com/user-attachments/assets/3695cfd2-148c-463d-9630-547d152adde0" alt="difi_demo" />
<strong>A calm, focused way to review Git diffs.</strong><br />
Review code with clarity. Polish before you push.
</p>
<p align="center">
<img src="https://via.placeholder.com/800x450.png?text=Showcase+Your+UI+Here" alt="difi demo" width="100%" />
</p>
## Why difi?
**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.
- 🧠 **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.
<p align="right">(<a href="#readme-top">back to top</a>)</p>
- ⚡️ **Blazing Fast** — Built in Go. Starts instantly.
- 🎨 **Semantic UI** — Split-pane layout with syntax highlighting and Nerd Font icons.
- 🧠 **Context Aware** — Opens your editor (nvim/vim) at the exact line you are reviewing.
- ⌨️ **Vim Native** — Navigate with `h j k l`. Zero mouse required.
## Installation
#### Homebrew (macOS & Linux)
### Homebrew (macOS & Linux)
```bash
brew tap oug-t/difi
brew install difi
```
#### Go Install
## Installation
### Go Install
```bash
go install github.com/oug-t/difi/cmd/difi@latest
```
#### AUR (Arch Linux)
### Manual (Linux / Windows)
**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>
- Download the binary from Releases and add it to your $PATH.
## Workflow
@ -69,8 +52,6 @@ cd my-project
difi
```
<p align="right">(<a href="#readme-top">back to top</a>)</p>
## Controls
| Key | Action |
@ -82,94 +63,17 @@ difi
| `?` | 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
- PRs are welcome!
- We use Bubble Tea for the TUI.
```bash
git clone https://github.com/oug-t/difi
cd difi
go run cmd/difi/main.go
```
Contributions are especially welcome in:
- 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
<a href="https://star-history.com/#oug-t/difi&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=oug-t/difi&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=oug-t/difi&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=oug-t/difi&type=Date" />
</picture>
</a>
</div>
<p align="right">(<a href="#readme-top">back to top</a>)</p>
---
<p align="center"> Made with ❤️ by <a href="https://github.com/oug-t">oug-t</a> </p>

View File

@ -4,57 +4,43 @@ import (
"flag"
"fmt"
"os"
"os/exec"
tea "github.com/charmbracelet/bubbletea"
"github.com/oug-t/difi/internal/config"
"github.com/oug-t/difi/internal/ui"
)
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")
// Define flags
help := flag.Bool("help", false, "Show help")
flag.Usage = func() {
w := os.Stderr
fmt.Fprintln(w, "Usage: difi [flags] [target-branch]")
fmt.Fprintln(w, "\nFlags:")
fmt.Fprintf(os.Stderr, "Usage: difi [flags] [target-branch]\n")
fmt.Fprintf(os.Stderr, "\nFlags:\n")
flag.PrintDefaults()
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")
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")
}
flag.Parse()
if *showVersion {
fmt.Printf("difi version %s\n", version)
if *help {
flag.Usage()
os.Exit(0)
}
target := "HEAD"
// Determine Target Branch
// If user provides an argument (e.g., "difi develop"), use it.
// Otherwise default to "main".
target := "main"
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)
}
// Load Config
cfg := config.Load()
cfg.Flat = *flat
// Pass target to the model
p := tea.NewProgram(ui.NewModel(cfg, target), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Printf("Error: %v\n", err)

3
go.mod
View File

@ -6,13 +6,14 @@ require (
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.10.1
gopkg.in/yaml.v3 v3.0.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,3 +53,7 @@ golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/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,8 +1,7 @@
package config
type Config struct {
UI UIConfig
Flat bool
UI UIConfig
}
type UIConfig struct {

View File

@ -55,7 +55,7 @@ func DiffCmd(targetBranch, path string) tea.Cmd {
}
}
func OpenEditorCmd(path string, lineNumber int, targetBranch string) tea.Cmd {
func OpenEditorCmd(path string, lineNumber int) tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
if _, err := exec.LookPath("nvim"); err == nil {
@ -73,46 +73,11 @@ func OpenEditorCmd(path string, lineNumber int, targetBranch string) 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) {

126
internal/tree/builder.go Normal file
View File

@ -0,0 +1,126 @@
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 " "
}
}

View File

@ -1,246 +0,0 @@
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,8 +6,6 @@ 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"
)
@ -18,36 +16,25 @@ 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
}
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, "…")
title := i.Title()
// If this item is selected
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"))
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))
}
fmt.Fprint(w, style.Render(title))
} else {
style := lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
fmt.Fprint(w, style.Render(title))
// Normal Item (No icons added, just the text)
fmt.Fprint(w, ItemStyle.Render(title))
}
}

View File

@ -3,8 +3,6 @@ package ui
import (
"fmt"
"math"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
@ -13,7 +11,6 @@ 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"
@ -27,14 +24,8 @@ const (
FocusDiff
)
type StatsMsg struct {
Added int
Deleted int
}
type Model struct {
fileList list.Model
treeState *tree.FileTree
fileTree list.Model
treeDelegate TreeDelegate
diffViewport viewport.Model
@ -43,12 +34,6 @@ type Model struct {
targetBranch string
repoName string
statsAdded int
statsDeleted int
currentFileAdded int
currentFileDeleted int
diffContent string
diffLines []string
diffCursor int
@ -56,10 +41,8 @@ type Model struct {
inputBuffer string
pendingZ bool
focus Focus
showHelp bool
flatMode bool
treeHidden bool
focus Focus
showHelp bool
width, height int
}
@ -68,14 +51,7 @@ func NewModel(cfg config.Config, targetBranch string) Model {
InitStyles(cfg)
files, _ := git.ListChangedFiles(targetBranch)
t := tree.New(files)
var items []list.Item
if cfg.Flat {
items = t.FlatItems()
} else {
items = t.Items()
}
items := tree.Build(files)
delegate := TreeDelegate{Focused: true}
l := list.New(items, delegate, 0, 0)
@ -88,8 +64,7 @@ func NewModel(cfg config.Config, targetBranch string) Model {
l.DisableQuitKeybindings()
m := Model{
fileList: l,
treeState: t,
fileTree: l,
treeDelegate: delegate,
diffViewport: viewport.New(0, 0),
focus: FocusTree,
@ -97,41 +72,23 @@ 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 {
if !first.IsDir {
m.selectedPath = first.FullPath
}
m.selectedPath = first.FullPath
}
}
return m
}
func (m Model) Init() tea.Cmd {
var cmds []tea.Cmd
if 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 git.DiffCmd(m.targetBranch, m.selectedPath)
}
return nil
}
func (m *Model) getRepeatCount() int {
@ -158,19 +115,16 @@ 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.fileList.Items()) == 0 {
if len(m.fileTree.Items()) == 0 {
return m, nil
}
// Handle z-prefix commands (zz, zt, zb)
if m.pendingZ {
m.pendingZ = false
if m.focus == FocusDiff {
@ -203,13 +157,7 @@ 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
@ -218,46 +166,17 @@ 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":
if !m.treeHidden {
m.focus = FocusTree
m.updateTreeFocus()
}
m.focus = FocusTree
m.updateTreeFocus()
m.inputBuffer = ""
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
}
}
case "e", "enter":
if m.selectedPath != "" {
line := 0
if m.focus == FocusDiff {
line = git.CalculateFileLine(m.diffContent, m.diffCursor)
} else {
line = git.CalculateFileLine(m.diffContent, 0)
}
return m, openFugitive(m.selectedPath, line)
}
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)
@ -265,58 +184,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
line = git.CalculateFileLine(m.diffContent, 0)
}
m.inputBuffer = ""
return m, openFugitive(m.selectedPath, line)
return m, git.OpenEditorCmd(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
// Viewport trigger
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
@ -342,6 +220,7 @@ 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
@ -364,36 +243,38 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.inputBuffer = ""
case "j", "down", "ctrl+n":
case "j", "down":
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.fileList.CursorDown()
m.fileTree.CursorDown()
}
}
m.inputBuffer = ""
case "k", "up", "ctrl+p":
case "k", "up":
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.fileList.CursorUp()
m.fileTree.CursorUp()
}
}
m.inputBuffer = ""
@ -403,14 +284,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
if len(m.fileList.Items()) > 0 && m.focus == FocusTree {
if len(m.fileTree.Items()) > 0 && m.focus == FocusTree {
if !keyHandled {
m.fileList, cmd = m.fileList.Update(msg)
m.fileTree, cmd = m.fileTree.Update(msg)
cmds = append(cmds, cmd)
}
if item, ok := m.fileList.SelectedItem().(tree.TreeItem); ok {
if !item.IsDir && item.FullPath != m.selectedPath {
if item, ok := m.fileTree.SelectedItem().(tree.TreeItem); ok && !item.IsDir {
if item.FullPath != m.selectedPath {
m.selectedPath = item.FullPath
m.diffCursor = 0
m.diffViewport.GotoTop()
@ -421,41 +302,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case git.DiffMsg:
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()
m.diffContent = msg.Content
m.diffLines = strings.Split(msg.Content, "\n")
m.diffViewport.SetContent(msg.Content)
case git.EditorFinishedMsg:
return m, git.DiffCmd(m.targetBranch, m.selectedPath)
@ -464,17 +313,6 @@ 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
@ -485,10 +323,9 @@ func (m *Model) centerDiffCursor() {
}
func (m *Model) updateSizes() {
// 1 line Top Bar + 1 line Bottom Bar = 2 reserved
reservedHeight := 2
reservedHeight := 1
if m.showHelp {
reservedHeight += 7
reservedHeight += 6
}
contentHeight := m.height - reservedHeight
@ -496,30 +333,19 @@ func (m *Model) updateSizes() {
contentHeight = 1
}
// Subtract border height (2) from contentHeight
listHeight := contentHeight - 2
if listHeight < 1 {
listHeight = 1
treeWidth := int(float64(m.width) * 0.20)
if treeWidth < 20 {
treeWidth = 20
}
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
}
m.fileTree.SetSize(treeWidth, contentHeight)
m.diffViewport.Width = m.width - treeWidth - 2
m.diffViewport.Height = contentHeight
}
func (m *Model) updateTreeFocus() {
m.treeDelegate.Focused = (m.focus == FocusTree)
m.fileList.SetDelegate(m.treeDelegate)
m.fileTree.SetDelegate(m.treeDelegate)
}
func (m Model) View() string {
@ -527,298 +353,183 @@ func (m Model) View() string {
return "Loading..."
}
topBar := m.renderTopBar()
var mainContent string
contentHeight := m.height - 2
if m.showHelp {
contentHeight -= 7
}
if contentHeight < 0 {
contentHeight = 0
if len(m.fileTree.Items()) == 0 {
return m.viewEmptyState()
}
if len(m.fileList.Items()) == 0 {
mainContent = m.renderEmptyState(m.width, contentHeight, "No changes found against "+m.targetBranch)
// Panes
treeStyle := PaneStyle
if m.focus == FocusTree {
treeStyle = FocusedPaneStyle
} else {
treeStyle := PaneStyle
if m.focus == FocusTree {
treeStyle = FocusedPaneStyle
}
treeStyle = PaneStyle
}
treeView := treeStyle.Copy().
Width(m.fileList.Width()).
Height(m.fileList.Height()).
Render(m.fileList.View())
treeView := treeStyle.Copy().
Width(m.fileTree.Width()).
Height(m.fileTree.Height()).
Render(m.fileTree.View())
var rightPaneView string
selectedItem, ok := m.fileList.SelectedItem().(tree.TreeItem)
var renderedDiff strings.Builder
start := m.diffViewport.YOffset
end := start + m.diffViewport.Height
if end > len(m.diffLines) {
end = len(m.diffLines)
}
if ok && selectedItem.IsDir {
rightPaneView = m.renderEmptyState(m.diffViewport.Width, m.diffViewport.Height, "Directory: "+selectedItem.Name)
for i := start; i < end; i++ {
line := m.diffLines[i]
var numStr string
mode := CurrentConfig.UI.LineNumbers
if mode == "hidden" {
numStr = ""
} 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)
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)
}
// 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
lineNumRendered := ""
if numStr != "" {
lineNumRendered = LineNumberStyle.Render(numStr)
}
if m.focus == FocusDiff && i == m.diffCursor {
cleanLine := stripAnsi(line)
line = DiffSelectionStyle.Render(" " + cleanLine)
} else {
mainContent = lipgloss.JoinHorizontal(lipgloss.Top, treeView, rightPaneView)
line = " " + line
}
renderedDiff.WriteString(lineNumRendered + line + "\n")
}
var bottomBar string
if m.showHelp {
bottomBar = m.renderHelpDrawer()
} else {
bottomBar = m.viewStatusBar()
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)
return lipgloss.JoinVertical(lipgloss.Top, topBar, mainContent, bottomBar)
}
leftStatus := lipgloss.JoinHorizontal(lipgloss.Center, repoSection, divider, branchSection)
rightStatus := StatusBarStyle.Render("? Help")
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().
statusBar := StatusBarStyle.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,
leftStatus,
lipgloss.PlaceHorizontal(m.width-lipgloss.Width(leftStatus)-lipgloss.Width(rightStatus), lipgloss.Right, rightStatus),
))
var finalView string
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)
}
return finalView
}
func (m Model) renderEmptyState(w, h int, statusMsg string) string {
func (m Model) viewEmptyState() 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 dev")
desc2 := EmptyCodeStyle.Render("Diff against branch")
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")
usageBlock := lipgloss.JoinVertical(lipgloss.Left,
usageHeader,
lipgloss.JoinHorizontal(lipgloss.Left, cmd1, " ", desc1),
lipgloss.JoinHorizontal(lipgloss.Left, cmd2, " ", desc2),
lipgloss.JoinHorizontal(lipgloss.Left, cmd1, desc1),
lipgloss.JoinHorizontal(lipgloss.Left, cmd2, desc2),
lipgloss.JoinHorizontal(lipgloss.Left, cmd3, desc3),
)
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, key1, keyDesc1),
lipgloss.JoinHorizontal(lipgloss.Left, key2, keyDesc2),
lipgloss.JoinHorizontal(lipgloss.Left, key3, keyDesc3),
)
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),
guides := lipgloss.JoinHorizontal(lipgloss.Top,
usageBlock,
lipgloss.NewStyle().Width(8).Render(""),
navBlock,
)
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,
@ -827,7 +538,16 @@ func (m Model) renderEmptyState(w, h int, statusMsg string) string {
guides,
)
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, content)
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),
)
}
func stripAnsi(str string) string {

View File

@ -1,74 +1,118 @@
package ui
import "github.com/charmbracelet/lipgloss"
import (
"github.com/charmbracelet/lipgloss"
"github.com/oug-t/difi/internal/config"
)
var (
// -- 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)
// Config
CurrentConfig config.Config
// -- PANE STYLES --
// Theme colors
ColorBorder = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
ColorFocus = lipgloss.AdaptiveColor{Light: "#000000", Dark: "#E5E5E5"}
ColorText = lipgloss.AdaptiveColor{Light: "#1F1F1F", Dark: "#F8F8F2"}
ColorSubtle = lipgloss.AdaptiveColor{Light: "#A8A8A8", Dark: "#626262"}
ColorCursorBg = lipgloss.AdaptiveColor{Light: "#E5E5E5", Dark: "#3E3E3E"}
ColorAccent = lipgloss.AdaptiveColor{Light: "#00ADD8", Dark: "#00ADD8"} // Go blue
// Pane styles
PaneStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(nord3). // Goal 1: Nord3 Separator
Padding(0, 1)
Border(lipgloss.NormalBorder(), false, true, false, false).
BorderForeground(ColorBorder)
FocusedPaneStyle = PaneStyle.Copy().
BorderForeground(nord9)
BorderForeground(ColorFocus)
// -- TOP BAR STYLES (Goal 2) --
TopBarStyle = lipgloss.NewStyle().
Background(nord0).
Foreground(nord4).
Height(1)
DiffStyle = lipgloss.NewStyle().Padding(0, 0)
ItemStyle = lipgloss.NewStyle().PaddingLeft(2)
TopInfoStyle = lipgloss.NewStyle().
// List styles
SelectedItemStyle = lipgloss.NewStyle().
PaddingLeft(1).
Background(ColorCursorBg).
Foreground(ColorText).
Bold(true).
Width(1000)
SelectedBlockStyle = lipgloss.NewStyle().
Background(ColorCursorBg).
Foreground(ColorText).
Bold(true).
PaddingLeft(1)
// Icon styles
FolderIconStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F7B96E", Dark: "#E5C07B"})
FileIconStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#969696", Dark: "#ABB2BF"})
// Diff view styles
LineNumberStyle = lipgloss.NewStyle().
Foreground(ColorSubtle).
PaddingRight(1).
Width(4)
DiffSelectionStyle = lipgloss.NewStyle().
Background(ColorCursorBg).
Width(1000)
// Status bar colors
ColorBarBg = lipgloss.AdaptiveColor{Light: "#F2F2F2", Dark: "#1F1F1F"}
ColorBarFg = lipgloss.AdaptiveColor{Light: "#6E6E6E", Dark: "#9E9E9E"}
// Status bar styles
StatusBarStyle = lipgloss.NewStyle().
Foreground(ColorBarFg).
Background(ColorBarBg).
Padding(0, 1)
StatusKeyStyle = lipgloss.NewStyle().
Foreground(ColorText).
Background(ColorBarBg).
Bold(true).
Padding(0, 1)
TopStatsAddedStyle = lipgloss.NewStyle().
Foreground(nord14).
PaddingLeft(1)
StatusDividerStyle = lipgloss.NewStyle().
Foreground(ColorSubtle).
Background(ColorBarBg).
Padding(0, 0)
TopStatsDeletedStyle = lipgloss.NewStyle().
Foreground(nord11).
PaddingLeft(1).
PaddingRight(1)
// Help styles
HelpTextStyle = lipgloss.NewStyle().
Foreground(ColorSubtle).
Padding(0, 1)
// -- TREE STYLES --
DirectoryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("99"))
FileStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
HelpDrawerStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true, false, false, false).
BorderForeground(ColorBorder).
Padding(1, 2)
// -- 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)
// Empty/landing styles
EmptyLogoStyle = lipgloss.NewStyle().
Foreground(ColorAccent).
Bold(true).
PaddingBottom(1)
// -- 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"))
EmptyDescStyle = lipgloss.NewStyle().
Foreground(ColorSubtle).
PaddingBottom(2)
// -- 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)
EmptyStatusStyle = lipgloss.NewStyle().
Foreground(ColorText).
Background(ColorCursorBg).
Padding(0, 2).
MarginBottom(2)
// -- 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)
EmptyCodeStyle = lipgloss.NewStyle().
Foreground(ColorSubtle).
MarginLeft(2)
ColorText = lipgloss.Color("252")
EmptyHeaderStyle = lipgloss.NewStyle().
Foreground(ColorText).
Bold(true).
MarginBottom(1)
)
func InitStyles(cfg interface{}) {}
func InitStyles(cfg config.Config) {
CurrentConfig = cfg
}