Skip to content

Commit 7732397

Browse files
committed
feat: display filtree in files panel
Signed-off-by: Ayush <mail@ayuch.dev>
1 parent bd70e19 commit 7732397

7 files changed

Lines changed: 289 additions & 36 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ toolchain go1.24.5
77
require (
88
github.com/charmbracelet/bubbles v0.21.0
99
github.com/charmbracelet/lipgloss v1.1.0
10+
github.com/fsnotify/fsnotify v1.9.0
1011
github.com/lrstanley/bubblezone v1.0.0
1112
)
1213

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ
2020
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
2121
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
2222
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
23+
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
24+
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
2325
github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA=
2426
github.com/lrstanley/bubblezone v1.0.0/go.mod h1:kcTekA8HE/0Ll2bWzqHlhA2c513KDNLW7uDfDP4Mly8=
2527
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=

internal/git/repo.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,12 @@ func (g *GitCommands) GetRepoInfo() (repoName string, branchName string, err err
2525

2626
return repoName, branchName, nil
2727
}
28+
29+
func (g *GitCommands) GetGitRepoPath() (repoPath string, err error) {
30+
repoPathBytes, err := ExecCommand("git", "rev-parse", "--git-dir").Output()
31+
if err != nil {
32+
return "", err
33+
}
34+
repoPath = strings.TrimSpace(string(repoPathBytes))
35+
return repoPath, nil
36+
}

internal/tui/filetree.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package tui
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
"sort"
7+
"strings"
8+
)
9+
10+
// Node represents a file or directory within the file tree structure.
11+
type Node struct {
12+
name string
13+
status string // Git status prefix (e.g., "M", "??"), only for file nodes.
14+
children []*Node
15+
}
16+
17+
// BuildTree parses the output of `git status --porcelain` to construct a file
18+
// tree. It processes each line, builds a hierarchical structure of nodes,
19+
// sorts them, and compacts single-child directories for a cleaner display.
20+
func BuildTree(gitStatus string) *Node {
21+
root := &Node{name: "."}
22+
23+
lines := strings.Split(strings.TrimSpace(gitStatus), "\n")
24+
if len(lines) == 1 && lines[0] == "" {
25+
return root // No changes, return the root.
26+
}
27+
28+
for _, line := range lines {
29+
if len(line) < 4 {
30+
continue
31+
}
32+
status := strings.TrimSpace(line[:2])
33+
path := line[3:]
34+
35+
parts := strings.Split(path, string(filepath.Separator))
36+
currentNode := root
37+
for i, part := range parts {
38+
// Traverse the tree, creating nodes as necessary.
39+
childNode := currentNode.findChild(part)
40+
if childNode == nil {
41+
childNode = &Node{name: part}
42+
currentNode.children = append(currentNode.children, childNode)
43+
}
44+
currentNode = childNode
45+
46+
// The last part of the path is the file, so set its status.
47+
if i == len(parts)-1 {
48+
currentNode.status = status
49+
}
50+
}
51+
}
52+
53+
root.sort()
54+
root.compact()
55+
56+
return root
57+
}
58+
59+
// findChild searches for an immediate child node by name.
60+
func (n *Node) findChild(name string) *Node {
61+
for _, child := range n.children {
62+
if child.name == name {
63+
return child
64+
}
65+
}
66+
return nil
67+
}
68+
69+
// sort recursively sorts the children of a node. Directories are listed first,
70+
// then files, with both groups sorted alphabetically.
71+
func (n *Node) sort() {
72+
if n.children == nil {
73+
return
74+
}
75+
sort.SliceStable(n.children, func(i, j int) bool {
76+
isDirI := len(n.children[i].children) > 0
77+
isDirJ := len(n.children[j].children) > 0
78+
if isDirI != isDirJ {
79+
return isDirI // Directories first.
80+
}
81+
return n.children[i].name < n.children[j].name // Then sort alphabetically.
82+
})
83+
84+
for _, child := range n.children {
85+
child.sort()
86+
}
87+
}
88+
89+
// compact recursively merges directories that contain only a single sub-directory.
90+
// For example, a path like "src/main/go" becomes a single node.
91+
func (n *Node) compact() {
92+
if n.children == nil {
93+
return
94+
}
95+
96+
// First, compact all children in a post-order traversal.
97+
for _, child := range n.children {
98+
child.compact()
99+
}
100+
101+
// Merge this node with its child if it's a single-directory container.
102+
for len(n.children) == 1 && len(n.children[0].children) > 0 {
103+
child := n.children[0]
104+
n.name = filepath.Join(n.name, child.name)
105+
n.children = child.children
106+
}
107+
}
108+
109+
// Render traverses the tree and returns a slice of strings for display.
110+
func (n *Node) Render() []string {
111+
return n.renderRecursive("")
112+
}
113+
114+
// renderRecursive performs a depth-first traversal to generate the visual
115+
// representation of the tree, using box-drawing characters to show hierarchy.
116+
func (n *Node) renderRecursive(prefix string) []string {
117+
var lines []string
118+
for i, child := range n.children {
119+
// Use different connectors for the last child in a list.
120+
connector := "├─"
121+
newPrefix := "│ "
122+
if i == len(n.children)-1 {
123+
connector = "└─"
124+
newPrefix = " "
125+
}
126+
127+
if len(child.children) > 0 {
128+
// It's a directory.
129+
lines = append(lines, fmt.Sprintf("%s%s▼ %s", prefix, connector, child.name))
130+
lines = append(lines, child.renderRecursive(prefix+newPrefix)...)
131+
} else {
132+
// It's a file.
133+
lines = append(lines, fmt.Sprintf("%s%s %s %s", prefix, connector, child.status, child.name))
134+
}
135+
}
136+
return lines
137+
}

internal/tui/model_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,22 @@ func TestModel_MouseFocus(t *testing.T) {
226226
}
227227
}
228228

229+
func TestModel_Update_FileWatcher(t *testing.T) {
230+
m := initialModel()
231+
// Use the blank identifier _ to ignore the returned model
232+
_, cmd := m.Update(fileWatcherMsg{})
233+
234+
if cmd == nil {
235+
t.Fatal("expected a command to be returned")
236+
}
237+
238+
cmds := cmd().(tea.BatchMsg)
239+
// Cast totalPanels to an int for comparison
240+
if len(cmds) != int(totalPanels) {
241+
t.Errorf("expected %d commands, got %d", totalPanels, len(cmds))
242+
}
243+
}
244+
229245
// newTestModel creates a new model with default dimensions and a calculated layout.
230246
func newTestModel() testModel {
231247
m := initialModel()

internal/tui/tui.go

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
package tui
22

33
import (
4+
"log"
5+
"path/filepath"
6+
"strings"
7+
"time"
8+
49
tea "github.com/charmbracelet/bubbletea"
10+
"github.com/fsnotify/fsnotify"
11+
"github.com/gitxtui/gitx/internal/git"
512
)
613

714
// App is the main application struct.
@@ -11,14 +18,79 @@ type App struct {
1118

1219
// NewApp initializes a new TUI application.
1320
func NewApp() *App {
14-
model := initialModel()
15-
// Use WithAltScreen to have a dedicated screen for the TUI.
16-
program := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion())
17-
return &App{program: program}
21+
m := initialModel()
22+
return &App{
23+
program: tea.NewProgram(
24+
m,
25+
tea.WithoutCatchPanics(),
26+
tea.WithAltScreen(),
27+
tea.WithMouseAllMotion(),
28+
),
29+
}
1830
}
1931

20-
// Run starts the TUI application.
32+
// Run starts the TUI application and the file watcher.
2133
func (a *App) Run() error {
34+
go a.watchGitDir()
35+
// program.Run() returns the final model and an error. We only need the error.
2236
_, err := a.program.Run()
2337
return err
2438
}
39+
40+
// watchGitDir starts a file watcher on the .git directory and sends a message on change.
41+
func (a *App) watchGitDir() {
42+
watcher, err := fsnotify.NewWatcher()
43+
if err != nil {
44+
log.Printf("error creating file watcher: %v", err)
45+
return
46+
}
47+
defer watcher.Close()
48+
49+
gc := git.NewGitCommands()
50+
gitDir, err := gc.GetGitRepoPath()
51+
if err != nil {
52+
return
53+
}
54+
55+
repoRoot := filepath.Dir(gitDir)
56+
57+
watchPaths := []string{
58+
repoRoot,
59+
gitDir,
60+
filepath.Join(gitDir, "HEAD"),
61+
filepath.Join(gitDir, "index"),
62+
filepath.Join(gitDir, "refs"),
63+
}
64+
65+
for _, path := range watchPaths {
66+
if err := watcher.Add(path); err != nil {
67+
// ignore errors for paths that might not exist yet
68+
}
69+
}
70+
71+
ticker := time.NewTicker(500 * time.Millisecond)
72+
defer ticker.Stop()
73+
var needsUpdate bool
74+
75+
for {
76+
select {
77+
case event, ok := <-watcher.Events:
78+
if !ok {
79+
return
80+
}
81+
if !strings.Contains(event.Name, ".git") || strings.HasSuffix(event.Name, "HEAD") || strings.HasSuffix(event.Name, "index") {
82+
needsUpdate = true
83+
}
84+
case err, ok := <-watcher.Errors:
85+
if !ok {
86+
return
87+
}
88+
log.Printf("file watcher error: %v", err)
89+
case <-ticker.C:
90+
if needsUpdate {
91+
a.program.Send(fileWatcherMsg{})
92+
needsUpdate = false
93+
}
94+
}
95+
}
96+
}

0 commit comments

Comments
 (0)