Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d974a36c93 | |||
| dd4544b2d0 | |||
| a8303b51fa | |||
|
|
ba956170e9 | ||
|
|
2adb36d497 | ||
|
|
0e975c1ae9 | ||
|
|
5d68bc8f02 | ||
|
|
a6d7375cf8 | ||
|
|
9b776679ca | ||
|
|
95e8d3a833 | ||
|
|
da342748ee | ||
|
|
3f54bc6550 | ||
|
|
bcaf1599d5 | ||
|
|
cec95779ae | ||
|
|
f60216f7c3 | ||
|
|
bcbf831b70 | ||
|
|
45df5cdc86 | ||
|
|
b27057f6ee | ||
|
|
d67be9d25b | ||
|
|
762f1b6784 | ||
|
|
ce80530858 | ||
|
|
731fb3b5b7 | ||
|
|
b52f2d106f | ||
|
|
acd8532fd6 | ||
|
|
4dbfae39c2 | ||
|
|
fc0fb2ba36 | ||
|
|
d427999d3e | ||
|
|
41e1ea0731 | ||
|
|
0c6ae682f0 | ||
|
|
5e1252a3c8 | ||
|
|
c1db02c04e | ||
|
|
401107df4d | ||
|
|
8cad917626 | ||
|
|
b14181e88a | ||
|
|
c654424094 | ||
|
|
0478c03f04 | ||
|
|
9648bf2d03 | ||
|
|
556b8984d3 | ||
|
|
93bc60e59b |
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
name: 🐛 Bug Report
|
||||||
|
about: Create a report to help us improve difi
|
||||||
|
title: "[BUG] "
|
||||||
|
labels: bug
|
||||||
|
assignees: ""
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
|
1. Go to '...'
|
||||||
|
2. Press keys '...'
|
||||||
|
3. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Environment (please complete the following information):**
|
||||||
|
|
||||||
|
- OS: [e.g. macOS / Linux / Windows]
|
||||||
|
- Terminal Emulator: [e.g. iTerm2, Alacritty, tmux]
|
||||||
|
- Difi Version: [e.g. v0.1.0 or commit hash]
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
16
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
16
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
---
|
||||||
|
name: 🚀 Feature Request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: "[FEAT] "
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex: I'm always frustrated when...
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
15
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
15
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
## Description
|
||||||
|
Please include a summary of the change and which issue is fixed.
|
||||||
|
|
||||||
|
Fixes # (issue)
|
||||||
|
|
||||||
|
## Type of change
|
||||||
|
- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
|
||||||
|
- [ ] 🚀 New feature (non-breaking change which adds functionality)
|
||||||
|
- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||||
|
- [ ] 📚 Documentation update
|
||||||
|
|
||||||
|
## Checklist:
|
||||||
|
- [ ] My code follows the style guidelines of this project (`go fmt ./...`)
|
||||||
|
- [ ] I have performed a self-review of my own code
|
||||||
|
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -20,7 +20,7 @@ jobs:
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: stable
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v6
|
||||||
|
|
|
||||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# build artifacts
|
||||||
|
/dist/
|
||||||
|
/bin/
|
||||||
|
/coverage/
|
||||||
|
|
||||||
|
# Go test binaries and coverage
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
@ -7,6 +7,10 @@ before:
|
||||||
builds:
|
builds:
|
||||||
- env:
|
- env:
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
|
flags:
|
||||||
|
- -trimpath
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X main.version={{ .Version }}
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- windows
|
- windows
|
||||||
|
|
|
||||||
2
LICENSE
2
LICENSE
|
|
@ -1,6 +1,6 @@
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 Tommy Guo
|
Copyright (c) 2026 Xiyuan Guo
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
|
||||||
106
README.md
106
README.md
|
|
@ -1,3 +1,5 @@
|
||||||
|
<a id="readme-top"></a>
|
||||||
|
|
||||||
<h1 align="center"><code>difi</code></h1>
|
<h1 align="center"><code>difi</code></h1>
|
||||||
<p align="center"><em>Review and refine Git diffs before you push</em></p>
|
<p align="center"><em>Review and refine Git diffs before you push</em></p>
|
||||||
|
|
||||||
|
|
@ -8,42 +10,55 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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>
|
</p>
|
||||||
|
|
||||||
<img width="2560" height="1440" alt="image" src="https://github.com/user-attachments/assets/fbea297a-b99d-4e98-b369-2925a7651a13" />
|
|
||||||
|
|
||||||
## Why difi?
|
## Why difi?
|
||||||
|
|
||||||
- ⚡️ **Instant startup** — Built in Go, no background daemon.
|
**git diff** shows changes. **difi** helps you _review_ them.
|
||||||
- 🎨 **Structured review** — Tree view + side-by-side diffs.
|
|
||||||
- 🧠 **Editor-aware** — Jump to the exact line in `nvim` / `vim`.
|
|
||||||
- ⌨️ **Keyboard-first** — Designed for `h j k l`, no mouse.
|
|
||||||
|
|
||||||
## Why not `git diff`?
|
- ⚡️ **Instant** — Built in Go. Launches immediately with no daemon or indexing.
|
||||||
|
- 🎨 **Structured** — A clean file tree and focused diffs for fast mental parsing.
|
||||||
|
- 🧠 **Editor-Aware** — Jump straight to the exact line in `nvim`/`vim` to fix issues.
|
||||||
|
- ⌨️ **Keyboard-First** — Navigate everything with `h j k l`. No mouse required.
|
||||||
|
|
||||||
- `git diff` is powerful, but it’s optimized for output — not review.
|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||||
- difi is designed for the *moment before you push or open a PR*:
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Homebrew (macOS & Linux)
|
#### Homebrew (macOS & Linux)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew tap oug-t/difi
|
brew tap oug-t/difi
|
||||||
brew install difi
|
brew install difi
|
||||||
```
|
```
|
||||||
|
|
||||||
### Go Install
|
#### Go Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go install github.com/oug-t/difi/cmd/difi@latest
|
go install github.com/oug-t/difi/cmd/difi@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual (Linux / Windows)
|
#### AUR (Arch Linux)
|
||||||
|
|
||||||
|
**Binary (pre-built):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pikaur -S difi-bin
|
||||||
|
```
|
||||||
|
|
||||||
|
**Build from source:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pikaur -S difi
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Manual (Linux / Windows)
|
||||||
|
|
||||||
- Download the binary from Releases and add it to your `$PATH`.
|
- Download the binary from Releases and add it to your `$PATH`.
|
||||||
|
|
||||||
|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
- Run difi in any Git repository.
|
- Run difi in any Git repository.
|
||||||
|
|
@ -54,6 +69,8 @@ cd my-project
|
||||||
difi
|
difi
|
||||||
```
|
```
|
||||||
|
|
||||||
|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|
|
@ -64,6 +81,62 @@ difi
|
||||||
| `e` / `Enter` | Edit file (opens editor at selected line) |
|
| `e` / `Enter` | Edit file (opens editor at selected line) |
|
||||||
| `?` | Toggle help drawer |
|
| `?` | Toggle help drawer |
|
||||||
| `q` | Quit |
|
| `q` | Quit |
|
||||||
|
|
||||||
|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||||
|
|
||||||
|
## Integrations
|
||||||
|
|
||||||
|
#### vim-fugitive
|
||||||
|
|
||||||
|
- **The "Unix philosophy" approach:** Uses the industry-standard Git wrapper to provide a robust, side-by-side editing experience.
|
||||||
|
- **Side-by-Side Editing:** Instantly opens a vertical split (:Gvdiffsplit!) against the index.
|
||||||
|
- **Merge Conflicts:** Automatically detects conflicts and opens a 3-way merge view for resolution.
|
||||||
|
- **Config**: Add the line below to if using **lazy.nvim**.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
{
|
||||||
|
"tpope/vim-fugitive",
|
||||||
|
cmd = { "Gvdiffsplit", "Git" }, -- Add this line
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="left">
|
||||||
|
<a href="https://github.com/tpope/vim-fugitive.git">
|
||||||
|
<img src="https://img.shields.io/badge/Supports-vim--fugitive-4d4d4d?style=for-the-badge&logo=vim&logoColor=white" alt="Supports vim-fugitive" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
#### difi.nvim
|
||||||
|
|
||||||
|
Get the ultimate review experience with **[difi.nvim](https://github.com/oug-t/difi.nvim)**.
|
||||||
|
|
||||||
|
- **Auto-Open:** Instantly jumps to the file and line when you press `e` in the CLI.
|
||||||
|
- **Visual Diff:** Renders diffs inline with familiar green/red highlights—just like reviewing a PR on GitHub.
|
||||||
|
- **Interactive Review:** Restore a "deleted" line by simply removing the `-` marker. Discard an added line by deleting it entirely.
|
||||||
|
- **Context Aware:** Automatically syncs with your `difi` session target.
|
||||||
|
|
||||||
|
<p align="left">
|
||||||
|
<a href="https://github.com/oug-t/difi.nvim">
|
||||||
|
<img src="https://img.shields.io/badge/Get_difi.nvim-57A143?style=for-the-badge&logo=neovim&logoColor=white" alt="Get difi.nvim" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||||
|
|
||||||
|
## Git Integration
|
||||||
|
|
||||||
|
To use `difi` as a native git command (e.g., `git difi`), add it as an alias in your global git config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config --global alias.difi '!difi'
|
||||||
|
```
|
||||||
|
|
||||||
|
Now you can run it directly from git:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git difi
|
||||||
|
```
|
||||||
|
|
||||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
@ -75,9 +148,11 @@ go run cmd/difi/main.go
|
||||||
```
|
```
|
||||||
|
|
||||||
Contributions are especially welcome in:
|
Contributions are especially welcome in:
|
||||||
- diff rendering edge cases
|
|
||||||
|
- diff.nvim rendering edge cases
|
||||||
- UI polish and accessibility
|
- UI polish and accessibility
|
||||||
- Windows support
|
- Windows support
|
||||||
|
|
||||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
@ -90,6 +165,7 @@ Contributions are especially welcome in:
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -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>
|
<p align="center"> Made with ❤️ by <a href="https://github.com/oug-t">oug-t</a> </p>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/oug-t/difi/internal/config"
|
"github.com/oug-t/difi/internal/config"
|
||||||
|
|
@ -14,15 +15,18 @@ var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
showVersion := flag.Bool("version", false, "Show version")
|
showVersion := flag.Bool("version", false, "Show version")
|
||||||
|
plain := flag.Bool("plain", false, "Print a plain, non-interactive summary and exit")
|
||||||
|
flat := flag.Bool("flat", false, "Start in flat file list mode")
|
||||||
|
|
||||||
flag.Usage = func() {
|
flag.Usage = func() {
|
||||||
fmt.Fprintf(os.Stderr, "Usage: difi [flags] [target-branch]\n")
|
w := os.Stderr
|
||||||
fmt.Fprintf(os.Stderr, "\nFlags:\n")
|
fmt.Fprintln(w, "Usage: difi [flags] [target-branch]")
|
||||||
|
fmt.Fprintln(w, "\nFlags:")
|
||||||
flag.PrintDefaults()
|
flag.PrintDefaults()
|
||||||
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
fmt.Fprintln(w, "\nExamples:")
|
||||||
fmt.Fprintf(os.Stderr, " difi # Diff against main\n")
|
fmt.Fprintln(w, " difi # Diff against default")
|
||||||
fmt.Fprintf(os.Stderr, " difi develop # Diff against develop\n")
|
fmt.Fprintln(w, " difi develop # Diff against develop")
|
||||||
fmt.Fprintf(os.Stderr, " difi HEAD~1 # Diff against last commit\n")
|
fmt.Fprintln(w, " difi HEAD~1 # Diff against last commit")
|
||||||
}
|
}
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
@ -32,12 +36,24 @@ func main() {
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
target := "main"
|
target := "HEAD"
|
||||||
if flag.NArg() > 0 {
|
if flag.NArg() > 0 {
|
||||||
target = flag.Arg(0)
|
target = flag.Arg(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *plain {
|
||||||
|
// Uses --name-status for a concise, machine-readable summary suitable for CI
|
||||||
|
cmd := exec.Command("git", "diff", "--name-status", fmt.Sprintf("%s...HEAD", target))
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
cfg.Flat = *flat
|
||||||
|
|
||||||
p := tea.NewProgram(ui.NewModel(cfg, target), tea.WithAltScreen())
|
p := tea.NewProgram(ui.NewModel(cfg, target), tea.WithAltScreen())
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
|
|
|
||||||
3
go.mod
3
go.mod
|
|
@ -6,14 +6,13 @@ require (
|
||||||
github.com/charmbracelet/bubbles v0.21.0
|
github.com/charmbracelet/bubbles v0.21.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
github.com/charmbracelet/x/ansi v0.10.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
|
|
||||||
4
go.sum
4
go.sum
|
|
@ -53,7 +53,3 @@ golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
UI UIConfig
|
UI UIConfig
|
||||||
|
Flat bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type UIConfig struct {
|
type UIConfig struct {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
editor := os.Getenv("EDITOR")
|
||||||
if editor == "" {
|
if editor == "" {
|
||||||
if _, err := exec.LookPath("nvim"); err == nil {
|
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 := exec.Command(editor, args...)
|
||||||
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
|
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 tea.ExecProcess(c, func(err error) tea.Msg {
|
||||||
return EditorFinishedMsg{Err: err}
|
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 {
|
func CalculateFileLine(diffContent string, visualLineIndex int) int {
|
||||||
lines := strings.Split(diffContent, "\n")
|
lines := strings.Split(diffContent, "\n")
|
||||||
if visualLineIndex >= len(lines) {
|
if visualLineIndex >= len(lines) {
|
||||||
|
|
|
||||||
|
|
@ -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
246
internal/tree/tree.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,8 @@ import (
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"github.com/charmbracelet/bubbles/list"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
"github.com/oug-t/difi/internal/tree"
|
"github.com/oug-t/difi/internal/tree"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -16,25 +18,36 @@ type TreeDelegate struct {
|
||||||
func (d TreeDelegate) Height() int { return 1 }
|
func (d TreeDelegate) Height() int { return 1 }
|
||||||
func (d TreeDelegate) Spacing() int { return 0 }
|
func (d TreeDelegate) Spacing() int { return 0 }
|
||||||
func (d TreeDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
|
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) {
|
func (d TreeDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
|
||||||
i, ok := item.(tree.TreeItem)
|
i, ok := item.(tree.TreeItem)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
title := i.Title()
|
var title string
|
||||||
|
if i.Flat {
|
||||||
// If this item is selected
|
iconWidth := lipgloss.Width(i.Icon) + 1 // icon + space
|
||||||
if index == m.Index() {
|
path := tree.ShortenPath(i.FullPath, m.Width()-2-iconWidth)
|
||||||
if d.Focused {
|
title = fmt.Sprintf("%s %s", i.Icon, path)
|
||||||
// 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))
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Normal Item (No icons added, just the text)
|
title = i.Title()
|
||||||
fmt.Fprint(w, ItemStyle.Render(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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package ui
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -11,6 +13,7 @@ import (
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
|
||||||
"github.com/oug-t/difi/internal/config"
|
"github.com/oug-t/difi/internal/config"
|
||||||
"github.com/oug-t/difi/internal/git"
|
"github.com/oug-t/difi/internal/git"
|
||||||
|
|
@ -24,8 +27,14 @@ const (
|
||||||
FocusDiff
|
FocusDiff
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type StatsMsg struct {
|
||||||
|
Added int
|
||||||
|
Deleted int
|
||||||
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
fileTree list.Model
|
fileList list.Model
|
||||||
|
treeState *tree.FileTree
|
||||||
treeDelegate TreeDelegate
|
treeDelegate TreeDelegate
|
||||||
diffViewport viewport.Model
|
diffViewport viewport.Model
|
||||||
|
|
||||||
|
|
@ -34,6 +43,12 @@ type Model struct {
|
||||||
targetBranch string
|
targetBranch string
|
||||||
repoName string
|
repoName string
|
||||||
|
|
||||||
|
statsAdded int
|
||||||
|
statsDeleted int
|
||||||
|
|
||||||
|
currentFileAdded int
|
||||||
|
currentFileDeleted int
|
||||||
|
|
||||||
diffContent string
|
diffContent string
|
||||||
diffLines []string
|
diffLines []string
|
||||||
diffCursor int
|
diffCursor int
|
||||||
|
|
@ -41,8 +56,10 @@ type Model struct {
|
||||||
inputBuffer string
|
inputBuffer string
|
||||||
pendingZ bool
|
pendingZ bool
|
||||||
|
|
||||||
focus Focus
|
focus Focus
|
||||||
showHelp bool
|
showHelp bool
|
||||||
|
flatMode bool
|
||||||
|
treeHidden bool
|
||||||
|
|
||||||
width, height int
|
width, height int
|
||||||
}
|
}
|
||||||
|
|
@ -51,7 +68,14 @@ func NewModel(cfg config.Config, targetBranch string) Model {
|
||||||
InitStyles(cfg)
|
InitStyles(cfg)
|
||||||
|
|
||||||
files, _ := git.ListChangedFiles(targetBranch)
|
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}
|
delegate := TreeDelegate{Focused: true}
|
||||||
l := list.New(items, delegate, 0, 0)
|
l := list.New(items, delegate, 0, 0)
|
||||||
|
|
@ -64,7 +88,8 @@ func NewModel(cfg config.Config, targetBranch string) Model {
|
||||||
l.DisableQuitKeybindings()
|
l.DisableQuitKeybindings()
|
||||||
|
|
||||||
m := Model{
|
m := Model{
|
||||||
fileTree: l,
|
fileList: l,
|
||||||
|
treeState: t,
|
||||||
treeDelegate: delegate,
|
treeDelegate: delegate,
|
||||||
diffViewport: viewport.New(0, 0),
|
diffViewport: viewport.New(0, 0),
|
||||||
focus: FocusTree,
|
focus: FocusTree,
|
||||||
|
|
@ -72,23 +97,41 @@ func NewModel(cfg config.Config, targetBranch string) Model {
|
||||||
targetBranch: targetBranch,
|
targetBranch: targetBranch,
|
||||||
repoName: git.GetRepoName(),
|
repoName: git.GetRepoName(),
|
||||||
showHelp: false,
|
showHelp: false,
|
||||||
|
flatMode: cfg.Flat,
|
||||||
inputBuffer: "",
|
inputBuffer: "",
|
||||||
pendingZ: false,
|
pendingZ: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(items) > 0 {
|
if len(items) > 0 {
|
||||||
if first, ok := items[0].(tree.TreeItem); ok {
|
if first, ok := items[0].(tree.TreeItem); ok {
|
||||||
m.selectedPath = first.FullPath
|
if !first.IsDir {
|
||||||
|
m.selectedPath = first.FullPath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
if m.selectedPath != "" {
|
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 {
|
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.height = msg.Height
|
||||||
m.updateSizes()
|
m.updateSizes()
|
||||||
|
|
||||||
|
case StatsMsg:
|
||||||
|
m.statsAdded = msg.Added
|
||||||
|
m.statsDeleted = msg.Deleted
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
if msg.String() == "q" || msg.String() == "ctrl+c" {
|
if msg.String() == "q" || msg.String() == "ctrl+c" {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(m.fileTree.Items()) == 0 {
|
if len(m.fileList.Items()) == 0 {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle z-prefix commands (zz, zt, zb)
|
|
||||||
if m.pendingZ {
|
if m.pendingZ {
|
||||||
m.pendingZ = false
|
m.pendingZ = false
|
||||||
if m.focus == FocusDiff {
|
if m.focus == FocusDiff {
|
||||||
|
|
@ -157,7 +203,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "tab":
|
case "tab":
|
||||||
|
if m.treeHidden {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
if m.focus == FocusTree {
|
if m.focus == FocusTree {
|
||||||
|
if item, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && item.IsDir {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
m.focus = FocusDiff
|
m.focus = FocusDiff
|
||||||
} else {
|
} else {
|
||||||
m.focus = FocusTree
|
m.focus = FocusTree
|
||||||
|
|
@ -166,16 +218,30 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
m.inputBuffer = ""
|
m.inputBuffer = ""
|
||||||
|
|
||||||
case "l", "]", "ctrl+l", "right":
|
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.focus = FocusDiff
|
||||||
m.updateTreeFocus()
|
m.updateTreeFocus()
|
||||||
m.inputBuffer = ""
|
m.inputBuffer = ""
|
||||||
|
|
||||||
case "h", "[", "ctrl+h", "left":
|
case "h", "[", "ctrl+h", "left":
|
||||||
m.focus = FocusTree
|
if !m.treeHidden {
|
||||||
m.updateTreeFocus()
|
m.focus = FocusTree
|
||||||
|
m.updateTreeFocus()
|
||||||
|
}
|
||||||
m.inputBuffer = ""
|
m.inputBuffer = ""
|
||||||
|
|
||||||
case "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 != "" {
|
if m.selectedPath != "" {
|
||||||
line := 0
|
line := 0
|
||||||
if m.focus == FocusDiff {
|
if m.focus == FocusDiff {
|
||||||
|
|
@ -183,18 +249,74 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
} else {
|
} else {
|
||||||
line = git.CalculateFileLine(m.diffContent, 0)
|
line = git.CalculateFileLine(m.diffContent, 0)
|
||||||
}
|
}
|
||||||
m.inputBuffer = ""
|
return m, openFugitive(m.selectedPath, line)
|
||||||
return m, git.OpenEditorCmd(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":
|
case "z":
|
||||||
if m.focus == FocusDiff {
|
if m.focus == FocusDiff {
|
||||||
m.pendingZ = true
|
m.pendingZ = true
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cursor screen positioning (H, M, L)
|
|
||||||
case "H":
|
case "H":
|
||||||
if m.focus == FocusDiff {
|
if m.focus == FocusDiff {
|
||||||
m.diffCursor = m.diffViewport.YOffset
|
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":
|
case "ctrl+d":
|
||||||
if m.focus == FocusDiff {
|
if m.focus == FocusDiff {
|
||||||
halfScreen := m.diffViewport.Height / 2
|
halfScreen := m.diffViewport.Height / 2
|
||||||
|
|
@ -243,38 +364,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
m.inputBuffer = ""
|
m.inputBuffer = ""
|
||||||
|
|
||||||
case "j", "down":
|
case "j", "down", "ctrl+n":
|
||||||
keyHandled = true
|
keyHandled = true
|
||||||
count := m.getRepeatCount()
|
count := m.getRepeatCount()
|
||||||
for i := 0; i < count; i++ {
|
for i := 0; i < count; i++ {
|
||||||
if m.focus == FocusDiff {
|
if m.focus == FocusDiff {
|
||||||
if m.diffCursor < len(m.diffLines)-1 {
|
if m.diffCursor < len(m.diffLines)-1 {
|
||||||
m.diffCursor++
|
m.diffCursor++
|
||||||
// Scroll if hitting bottom edge
|
|
||||||
if m.diffCursor >= m.diffViewport.YOffset+m.diffViewport.Height {
|
if m.diffCursor >= m.diffViewport.YOffset+m.diffViewport.Height {
|
||||||
m.diffViewport.LineDown(1)
|
m.diffViewport.LineDown(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
m.fileTree.CursorDown()
|
m.fileList.CursorDown()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.inputBuffer = ""
|
m.inputBuffer = ""
|
||||||
|
|
||||||
case "k", "up":
|
case "k", "up", "ctrl+p":
|
||||||
keyHandled = true
|
keyHandled = true
|
||||||
count := m.getRepeatCount()
|
count := m.getRepeatCount()
|
||||||
for i := 0; i < count; i++ {
|
for i := 0; i < count; i++ {
|
||||||
if m.focus == FocusDiff {
|
if m.focus == FocusDiff {
|
||||||
if m.diffCursor > 0 {
|
if m.diffCursor > 0 {
|
||||||
m.diffCursor--
|
m.diffCursor--
|
||||||
// Scroll if hitting top edge
|
|
||||||
if m.diffCursor < m.diffViewport.YOffset {
|
if m.diffCursor < m.diffViewport.YOffset {
|
||||||
m.diffViewport.LineUp(1)
|
m.diffViewport.LineUp(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
m.fileTree.CursorUp()
|
m.fileList.CursorUp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.inputBuffer = ""
|
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 {
|
if !keyHandled {
|
||||||
m.fileTree, cmd = m.fileTree.Update(msg)
|
m.fileList, cmd = m.fileList.Update(msg)
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
if item, ok := m.fileTree.SelectedItem().(tree.TreeItem); ok && !item.IsDir {
|
if item, ok := m.fileList.SelectedItem().(tree.TreeItem); ok {
|
||||||
if item.FullPath != m.selectedPath {
|
if !item.IsDir && item.FullPath != m.selectedPath {
|
||||||
m.selectedPath = item.FullPath
|
m.selectedPath = item.FullPath
|
||||||
m.diffCursor = 0
|
m.diffCursor = 0
|
||||||
m.diffViewport.GotoTop()
|
m.diffViewport.GotoTop()
|
||||||
|
|
@ -302,9 +421,41 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case git.DiffMsg:
|
case git.DiffMsg:
|
||||||
m.diffContent = msg.Content
|
fullLines := strings.Split(msg.Content, "\n")
|
||||||
m.diffLines = strings.Split(msg.Content, "\n")
|
|
||||||
m.diffViewport.SetContent(msg.Content)
|
var cleanLines []string
|
||||||
|
var added, deleted int
|
||||||
|
foundHunk := false
|
||||||
|
|
||||||
|
for _, line := range fullLines {
|
||||||
|
cleanLine := stripAnsi(line)
|
||||||
|
|
||||||
|
if strings.HasPrefix(cleanLine, "@@") {
|
||||||
|
foundHunk = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundHunk {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanLines = append(cleanLines, line)
|
||||||
|
|
||||||
|
// Check "+++" to avoid counting file header
|
||||||
|
if strings.HasPrefix(cleanLine, "+") && !strings.HasPrefix(cleanLine, "+++") {
|
||||||
|
added++
|
||||||
|
} else if strings.HasPrefix(cleanLine, "-") && !strings.HasPrefix(cleanLine, "---") {
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.diffLines = cleanLines
|
||||||
|
m.currentFileAdded = added
|
||||||
|
m.currentFileDeleted = deleted
|
||||||
|
|
||||||
|
newContent := strings.Join(cleanLines, "\n")
|
||||||
|
m.diffContent = newContent
|
||||||
|
m.diffViewport.SetContent(newContent)
|
||||||
|
m.diffViewport.GotoTop()
|
||||||
|
|
||||||
case git.EditorFinishedMsg:
|
case git.EditorFinishedMsg:
|
||||||
return m, git.DiffCmd(m.targetBranch, m.selectedPath)
|
return m, git.DiffCmd(m.targetBranch, m.selectedPath)
|
||||||
|
|
@ -313,6 +464,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func openFugitive(path string, line int) tea.Cmd {
|
||||||
|
lineArg := fmt.Sprintf("+%d", line)
|
||||||
|
c := exec.Command("nvim", lineArg, "-c", "Gvdiffsplit!", path)
|
||||||
|
c.Stdin = os.Stdin
|
||||||
|
c.Stdout = os.Stdout
|
||||||
|
c.Stderr = os.Stderr
|
||||||
|
return tea.ExecProcess(c, func(err error) tea.Msg {
|
||||||
|
return git.EditorFinishedMsg{}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) centerDiffCursor() {
|
func (m *Model) centerDiffCursor() {
|
||||||
halfScreen := m.diffViewport.Height / 2
|
halfScreen := m.diffViewport.Height / 2
|
||||||
targetOffset := m.diffCursor - halfScreen
|
targetOffset := m.diffCursor - halfScreen
|
||||||
|
|
@ -323,9 +485,10 @@ func (m *Model) centerDiffCursor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) updateSizes() {
|
func (m *Model) updateSizes() {
|
||||||
reservedHeight := 1
|
// 1 line Top Bar + 1 line Bottom Bar = 2 reserved
|
||||||
|
reservedHeight := 2
|
||||||
if m.showHelp {
|
if m.showHelp {
|
||||||
reservedHeight += 6
|
reservedHeight += 7
|
||||||
}
|
}
|
||||||
|
|
||||||
contentHeight := m.height - reservedHeight
|
contentHeight := m.height - reservedHeight
|
||||||
|
|
@ -333,19 +496,30 @@ func (m *Model) updateSizes() {
|
||||||
contentHeight = 1
|
contentHeight = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
treeWidth := int(float64(m.width) * 0.20)
|
// Subtract border height (2) from contentHeight
|
||||||
if treeWidth < 20 {
|
listHeight := contentHeight - 2
|
||||||
treeWidth = 20
|
if listHeight < 1 {
|
||||||
|
listHeight = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
m.fileTree.SetSize(treeWidth, contentHeight)
|
if m.treeHidden {
|
||||||
m.diffViewport.Width = m.width - treeWidth - 2
|
m.fileList.SetSize(0, listHeight)
|
||||||
m.diffViewport.Height = contentHeight
|
m.diffViewport.Width = m.width - 2 // border (2)
|
||||||
|
m.diffViewport.Height = listHeight
|
||||||
|
} else {
|
||||||
|
treeWidth := int(float64(m.width) * 0.20)
|
||||||
|
if treeWidth < 20 {
|
||||||
|
treeWidth = 20
|
||||||
|
}
|
||||||
|
m.fileList.SetSize(treeWidth, listHeight)
|
||||||
|
m.diffViewport.Width = m.width - treeWidth - 4 // border (2) + padding (2) from tree pane
|
||||||
|
m.diffViewport.Height = listHeight
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) updateTreeFocus() {
|
func (m *Model) updateTreeFocus() {
|
||||||
m.treeDelegate.Focused = (m.focus == FocusTree)
|
m.treeDelegate.Focused = (m.focus == FocusTree)
|
||||||
m.fileTree.SetDelegate(m.treeDelegate)
|
m.fileList.SetDelegate(m.treeDelegate)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) View() string {
|
func (m Model) View() string {
|
||||||
|
|
@ -353,183 +527,298 @@ func (m Model) View() string {
|
||||||
return "Loading..."
|
return "Loading..."
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(m.fileTree.Items()) == 0 {
|
topBar := m.renderTopBar()
|
||||||
return m.viewEmptyState()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Panes
|
var mainContent string
|
||||||
treeStyle := PaneStyle
|
contentHeight := m.height - 2
|
||||||
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
|
|
||||||
if m.showHelp {
|
if m.showHelp {
|
||||||
col1 := lipgloss.JoinVertical(lipgloss.Left,
|
contentHeight -= 7
|
||||||
HelpTextStyle.Render("↑/k Move Up"),
|
}
|
||||||
HelpTextStyle.Render("↓/j Move Down"),
|
if contentHeight < 0 {
|
||||||
)
|
contentHeight = 0
|
||||||
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
|
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")
|
logo := EmptyLogoStyle.Render("difi")
|
||||||
desc := EmptyDescStyle.Render("A calm, focused way to review Git diffs.")
|
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)
|
status := EmptyStatusStyle.Render(statusMsg)
|
||||||
|
|
||||||
usageHeader := EmptyHeaderStyle.Render("Usage Patterns")
|
usageHeader := EmptyHeaderStyle.Render("Usage Patterns")
|
||||||
|
|
||||||
cmd1 := lipgloss.NewStyle().Foreground(ColorText).Render("difi")
|
cmd1 := lipgloss.NewStyle().Foreground(ColorText).Render("difi")
|
||||||
desc1 := EmptyCodeStyle.Render("Diff against main")
|
desc1 := EmptyCodeStyle.Render("Diff against main")
|
||||||
|
cmd2 := lipgloss.NewStyle().Foreground(ColorText).Render("difi dev")
|
||||||
cmd2 := lipgloss.NewStyle().Foreground(ColorText).Render("difi develop")
|
desc2 := EmptyCodeStyle.Render("Diff against branch")
|
||||||
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,
|
usageBlock := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
usageHeader,
|
usageHeader,
|
||||||
lipgloss.JoinHorizontal(lipgloss.Left, cmd1, desc1),
|
lipgloss.JoinHorizontal(lipgloss.Left, cmd1, " ", desc1),
|
||||||
lipgloss.JoinHorizontal(lipgloss.Left, cmd2, desc2),
|
lipgloss.JoinHorizontal(lipgloss.Left, cmd2, " ", desc2),
|
||||||
lipgloss.JoinHorizontal(lipgloss.Left, cmd3, desc3),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
navHeader := EmptyHeaderStyle.Render("Navigation")
|
navHeader := EmptyHeaderStyle.Render("Navigation")
|
||||||
|
|
||||||
key1 := lipgloss.NewStyle().Foreground(ColorText).Render("Tab")
|
key1 := lipgloss.NewStyle().Foreground(ColorText).Render("Tab")
|
||||||
|
key2 := lipgloss.NewStyle().Foreground(ColorText).Render("j/k")
|
||||||
keyDesc1 := EmptyCodeStyle.Render("Switch panels")
|
keyDesc1 := EmptyCodeStyle.Render("Switch panels")
|
||||||
|
|
||||||
key2 := lipgloss.NewStyle().Foreground(ColorText).Render("j / k")
|
|
||||||
keyDesc2 := EmptyCodeStyle.Render("Move cursor")
|
keyDesc2 := EmptyCodeStyle.Render("Move cursor")
|
||||||
|
|
||||||
key3 := lipgloss.NewStyle().Foreground(ColorText).Render("zz/zt")
|
|
||||||
keyDesc3 := EmptyCodeStyle.Render("Center/Top")
|
|
||||||
|
|
||||||
navBlock := lipgloss.JoinVertical(lipgloss.Left,
|
navBlock := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
navHeader,
|
navHeader,
|
||||||
lipgloss.JoinHorizontal(lipgloss.Left, key1, keyDesc1),
|
lipgloss.JoinHorizontal(lipgloss.Left, key1, " ", keyDesc1),
|
||||||
lipgloss.JoinHorizontal(lipgloss.Left, key2, keyDesc2),
|
lipgloss.JoinHorizontal(lipgloss.Left, key2, " ", keyDesc2),
|
||||||
lipgloss.JoinHorizontal(lipgloss.Left, key3, keyDesc3),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
guides := lipgloss.JoinHorizontal(lipgloss.Top,
|
nvimHeader := EmptyHeaderStyle.Render("Neovim Integration")
|
||||||
usageBlock,
|
nvim1 := lipgloss.NewStyle().Foreground(ColorText).Render("oug-t/difi.nvim")
|
||||||
lipgloss.NewStyle().Width(8).Render(""),
|
nvimDesc1 := EmptyCodeStyle.Render("Install plugin")
|
||||||
navBlock,
|
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,
|
content := lipgloss.JoinVertical(lipgloss.Center,
|
||||||
logo,
|
logo,
|
||||||
desc,
|
desc,
|
||||||
|
|
@ -538,16 +827,7 @@ func (m Model) viewEmptyState() string {
|
||||||
guides,
|
guides,
|
||||||
)
|
)
|
||||||
|
|
||||||
var verticalPad string
|
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, content)
|
||||||
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 {
|
func stripAnsi(str string) string {
|
||||||
|
|
|
||||||
|
|
@ -1,118 +1,74 @@
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import "github.com/charmbracelet/lipgloss"
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
"github.com/oug-t/difi/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Config
|
// -- NORD PALETTE --
|
||||||
CurrentConfig config.Config
|
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
|
// -- PANE STYLES --
|
||||||
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
|
|
||||||
PaneStyle = lipgloss.NewStyle().
|
PaneStyle = lipgloss.NewStyle().
|
||||||
Border(lipgloss.NormalBorder(), false, true, false, false).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(ColorBorder)
|
BorderForeground(nord3). // Goal 1: Nord3 Separator
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
FocusedPaneStyle = PaneStyle.Copy().
|
FocusedPaneStyle = PaneStyle.Copy().
|
||||||
BorderForeground(ColorFocus)
|
BorderForeground(nord9)
|
||||||
|
|
||||||
DiffStyle = lipgloss.NewStyle().Padding(0, 0)
|
// -- TOP BAR STYLES (Goal 2) --
|
||||||
ItemStyle = lipgloss.NewStyle().PaddingLeft(2)
|
TopBarStyle = lipgloss.NewStyle().
|
||||||
|
Background(nord0).
|
||||||
|
Foreground(nord4).
|
||||||
|
Height(1)
|
||||||
|
|
||||||
// List styles
|
TopInfoStyle = lipgloss.NewStyle().
|
||||||
SelectedItemStyle = lipgloss.NewStyle().
|
Bold(true).
|
||||||
PaddingLeft(1).
|
Padding(0, 1)
|
||||||
Background(ColorCursorBg).
|
|
||||||
Foreground(ColorText).
|
|
||||||
Bold(true).
|
|
||||||
Width(1000)
|
|
||||||
|
|
||||||
SelectedBlockStyle = lipgloss.NewStyle().
|
TopStatsAddedStyle = lipgloss.NewStyle().
|
||||||
Background(ColorCursorBg).
|
Foreground(nord14).
|
||||||
Foreground(ColorText).
|
|
||||||
Bold(true).
|
|
||||||
PaddingLeft(1)
|
PaddingLeft(1)
|
||||||
|
|
||||||
// Icon styles
|
TopStatsDeletedStyle = lipgloss.NewStyle().
|
||||||
FolderIconStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F7B96E", Dark: "#E5C07B"})
|
Foreground(nord11).
|
||||||
FileIconStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#969696", Dark: "#ABB2BF"})
|
PaddingLeft(1).
|
||||||
|
PaddingRight(1)
|
||||||
|
|
||||||
// Diff view styles
|
// -- TREE STYLES --
|
||||||
LineNumberStyle = lipgloss.NewStyle().
|
DirectoryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("99"))
|
||||||
Foreground(ColorSubtle).
|
FileStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
|
||||||
PaddingRight(1).
|
|
||||||
Width(4)
|
|
||||||
|
|
||||||
DiffSelectionStyle = lipgloss.NewStyle().
|
// -- DIFF VIEW STYLES --
|
||||||
Background(ColorCursorBg).
|
DiffStyle = lipgloss.NewStyle().Padding(0, 0)
|
||||||
Width(1000)
|
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
|
// -- EMPTY STATE STYLES --
|
||||||
ColorBarBg = lipgloss.AdaptiveColor{Light: "#F2F2F2", Dark: "#1F1F1F"}
|
EmptyLogoStyle = lipgloss.NewStyle().Foreground(nord9).Bold(true).MarginBottom(1)
|
||||||
ColorBarFg = lipgloss.AdaptiveColor{Light: "#6E6E6E", Dark: "#9E9E9E"}
|
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
|
// -- HELPER STYLES --
|
||||||
StatusBarStyle = lipgloss.NewStyle().
|
HelpDrawerStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), true, false, false, false).BorderForeground(nord3).Padding(1, 2)
|
||||||
Foreground(ColorBarFg).
|
HelpTextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).MarginRight(2)
|
||||||
Background(ColorBarBg).
|
|
||||||
Padding(0, 1)
|
|
||||||
|
|
||||||
StatusKeyStyle = lipgloss.NewStyle().
|
// -- BOTTOM STATUS BAR STYLES --
|
||||||
Foreground(ColorText).
|
StatusBarStyle = lipgloss.NewStyle().Background(nord0).Foreground(nord4).Height(1)
|
||||||
Background(ColorBarBg).
|
StatusKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Padding(0, 1)
|
||||||
Bold(true).
|
StatusRepoStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7aa2f7")).Padding(0, 1)
|
||||||
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().
|
ColorText = lipgloss.Color("252")
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func InitStyles(cfg config.Config) {
|
func InitStyles(cfg interface{}) {}
|
||||||
CurrentConfig = cfg
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user