chore: refine file-tree
This commit is contained in:
parent
c654424094
commit
b14181e88a
|
|
@ -74,8 +74,6 @@ func OpenEditorCmd(path string, lineNumber int, targetBranch string) 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
|
||||||
|
|
||||||
// Pass the diff target branch to the editor via environment variable
|
|
||||||
// This enables plugins like difi.nvim to auto-configure the view
|
|
||||||
c.Env = append(os.Environ(), fmt.Sprintf("DIFI_TARGET=%s", targetBranch))
|
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 {
|
||||||
|
|
@ -83,6 +81,38 @@ func OpenEditorCmd(path string, lineNumber int, targetBranch string) tea.Cmd {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 " "
|
|
||||||
}
|
|
||||||
}
|
|
||||||
191
internal/tree/tree.go
Normal file
191
internal/tree/tree.go
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
package tree
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/list"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"github.com/charmbracelet/bubbles/list"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/oug-t/difi/internal/tree"
|
"github.com/oug-t/difi/internal/tree"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -16,6 +17,7 @@ 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 {
|
||||||
|
|
@ -24,17 +26,19 @@ func (d TreeDelegate) Render(w io.Writer, m list.Model, index int, item list.Ite
|
||||||
|
|
||||||
title := i.Title()
|
title := i.Title()
|
||||||
|
|
||||||
// If this item is selected
|
|
||||||
if index == m.Index() {
|
if index == m.Index() {
|
||||||
if d.Focused {
|
style := lipgloss.NewStyle().
|
||||||
// Render the whole line (including indent) with the selection background
|
Background(lipgloss.Color("237")). // Dark gray background
|
||||||
fmt.Fprint(w, SelectedBlockStyle.Render(title))
|
Foreground(lipgloss.Color("255")). // White text
|
||||||
} else {
|
Bold(true)
|
||||||
// Dimmed selection if focus is on the other panel
|
|
||||||
fmt.Fprint(w, SelectedBlockStyle.Copy().Foreground(ColorSubtle).Render(title))
|
if !d.Focused {
|
||||||
|
style = style.Foreground(lipgloss.Color("245"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Fprint(w, style.Render(title))
|
||||||
} else {
|
} else {
|
||||||
// Normal Item (No icons added, just the text)
|
style := lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
|
||||||
fmt.Fprint(w, ItemStyle.Render(title))
|
fmt.Fprint(w, style.Render(title))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,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 +40,9 @@ type Model struct {
|
||||||
targetBranch string
|
targetBranch string
|
||||||
repoName string
|
repoName string
|
||||||
|
|
||||||
|
statsAdded int
|
||||||
|
statsDeleted int
|
||||||
|
|
||||||
diffContent string
|
diffContent string
|
||||||
diffLines []string
|
diffLines []string
|
||||||
diffCursor int
|
diffCursor int
|
||||||
|
|
@ -51,7 +60,9 @@ 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)
|
||||||
|
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 +75,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,
|
||||||
|
|
@ -78,17 +90,34 @@ func NewModel(cfg config.Config, targetBranch string) Model {
|
||||||
|
|
||||||
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 +144,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 {
|
||||||
|
|
@ -158,6 +190,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "tab":
|
case "tab":
|
||||||
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,6 +201,11 @@ 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 = ""
|
||||||
|
|
@ -175,8 +215,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
m.updateTreeFocus()
|
m.updateTreeFocus()
|
||||||
m.inputBuffer = ""
|
m.inputBuffer = ""
|
||||||
|
|
||||||
case "e", "enter":
|
case "enter":
|
||||||
|
if m.focus == FocusTree {
|
||||||
|
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 != "" {
|
||||||
|
if i, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && !i.IsDir {
|
||||||
|
// proceed
|
||||||
|
} else {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fallthrough
|
||||||
|
|
||||||
|
case "e":
|
||||||
|
if m.selectedPath != "" {
|
||||||
|
if i, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && i.IsDir {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
line := 0
|
line := 0
|
||||||
if m.focus == FocusDiff {
|
if m.focus == FocusDiff {
|
||||||
line = git.CalculateFileLine(m.diffContent, m.diffCursor)
|
line = git.CalculateFileLine(m.diffContent, m.diffCursor)
|
||||||
|
|
@ -184,18 +245,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
line = git.CalculateFileLine(m.diffContent, 0)
|
line = git.CalculateFileLine(m.diffContent, 0)
|
||||||
}
|
}
|
||||||
m.inputBuffer = ""
|
m.inputBuffer = ""
|
||||||
// Integration Point: Pass targetBranch to the editor command
|
|
||||||
return m, git.OpenEditorCmd(m.selectedPath, line, m.targetBranch)
|
return m, git.OpenEditorCmd(m.selectedPath, line, m.targetBranch)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Viewport trigger
|
|
||||||
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
|
||||||
|
|
@ -221,7 +279,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
|
||||||
|
|
@ -251,13 +308,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
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 = ""
|
||||||
|
|
@ -269,13 +325,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
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 = ""
|
||||||
|
|
@ -285,14 +340,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()
|
||||||
|
|
@ -324,29 +379,41 @@ 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 += 6
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate main area height
|
||||||
contentHeight := m.height - reservedHeight
|
contentHeight := m.height - reservedHeight
|
||||||
if contentHeight < 1 {
|
if contentHeight < 1 {
|
||||||
contentHeight = 1
|
contentHeight = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate widths
|
||||||
treeWidth := int(float64(m.width) * 0.20)
|
treeWidth := int(float64(m.width) * 0.20)
|
||||||
if treeWidth < 20 {
|
if treeWidth < 20 {
|
||||||
treeWidth = 20
|
treeWidth = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
m.fileTree.SetSize(treeWidth, contentHeight)
|
// The Tree PaneStyle has a border (1 top, 1 bottom = 2 lines).
|
||||||
|
// We must subtract this from the content height for the inner list.
|
||||||
|
listHeight := contentHeight - 2
|
||||||
|
if listHeight < 1 {
|
||||||
|
listHeight = 1
|
||||||
|
}
|
||||||
|
m.fileList.SetSize(treeWidth, listHeight)
|
||||||
|
|
||||||
|
// We align the Diff Viewport height with the List height to ensure
|
||||||
|
// the bottom edges match visually and to prevent overflow.
|
||||||
m.diffViewport.Width = m.width - treeWidth - 2
|
m.diffViewport.Width = m.width - treeWidth - 2
|
||||||
m.diffViewport.Height = contentHeight
|
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 {
|
||||||
|
|
@ -354,183 +421,238 @@ 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 -= 6
|
||||||
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
|
||||||
|
} else {
|
||||||
|
treeStyle = PaneStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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 := "relative"
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim the trailing newline to prevent an extra empty line
|
||||||
|
// which pushes the layout height +1 and causes the top bar to scroll off.
|
||||||
|
diffContentStr := strings.TrimRight(renderedDiff.String(), "\n")
|
||||||
|
|
||||||
|
rightPaneView = DiffStyle.Copy().
|
||||||
|
Width(m.diffViewport.Width).
|
||||||
|
Height(m.diffViewport.Height).
|
||||||
|
Render(diffContentStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
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"),
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
|
@ -540,14 +662,14 @@ func (m Model) viewEmptyState() string {
|
||||||
)
|
)
|
||||||
|
|
||||||
var verticalPad string
|
var verticalPad string
|
||||||
if m.height > lipgloss.Height(content) {
|
if h > lipgloss.Height(content) {
|
||||||
lines := (m.height - lipgloss.Height(content)) / 2
|
lines := (h - lipgloss.Height(content)) / 2
|
||||||
verticalPad = strings.Repeat("\n", lines)
|
verticalPad = strings.Repeat("\n", lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Top,
|
return lipgloss.JoinVertical(lipgloss.Top,
|
||||||
verticalPad,
|
verticalPad,
|
||||||
lipgloss.PlaceHorizontal(m.width, lipgloss.Center, content),
|
lipgloss.PlaceHorizontal(w, lipgloss.Center, content),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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