From f0dd098617be701ead295c77890663d77d62edaa Mon Sep 17 00:00:00 2001 From: Pablo Speciale Date: Tue, 6 Jan 2026 15:09:41 +0100 Subject: [PATCH 01/10] feat: add mouse support - Click on files in the tree to select them - Scroll wheel works in both file tree and diff viewer panes - Drag the border between panes to resize sidebar --- pkg/ui/mainModel.go | 152 +++++++++++++++++++++++--- pkg/ui/panes/diffviewer/diffviewer.go | 11 ++ pkg/ui/panes/filetree/filetree.go | 49 +++++++++ 3 files changed, 194 insertions(+), 18 deletions(-) diff --git a/pkg/ui/mainModel.go b/pkg/ui/mainModel.go index 704f838..371be7e 100644 --- a/pkg/ui/mainModel.go +++ b/pkg/ui/mainModel.go @@ -35,22 +35,24 @@ const ( ) type mainModel struct { - input string - files []*gitdiff.File - cursor int - fileTree filetree.Model - diffViewer diffviewer.Model - width int - height int - isShowingFileTree bool - activePanel Panel - search textinput.Model - help help.Model - resultsVp viewport.Model - resultsCursor int - searching bool - filtered []string - config config.Config + input string + files []*gitdiff.File + cursor int + fileTree filetree.Model + diffViewer diffviewer.Model + width int + height int + isShowingFileTree bool + activePanel Panel + search textinput.Model + help help.Model + resultsVp viewport.Model + resultsCursor int + searching bool + filtered []string + config config.Config + draggingSidebar bool + customSidebarWidth int } func New(input string, cfg config.Config) mainModel { @@ -170,6 +172,9 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd = m.setCursor(0) cmds = append(cmds, cmd) + case tea.MouseMsg: + return m.handleMouse(msg) + case common.ErrMsg: fmt.Printf("Error: %v\n", msg.Err) log.Fatal(msg.Err) @@ -381,10 +386,12 @@ func (m mainModel) sidebarWidth() int { if m.searching { return m.config.UI.SearchTreeWidth } else if m.isShowingFileTree { + if m.customSidebarWidth > 0 { + return m.customSidebarWidth + } return m.config.UI.FileTreeWidth - } else { - return 0 } + return 0 } func (m mainModel) headerHeight() int { @@ -415,3 +422,112 @@ func (m *mainModel) setCursor(cursor int) tea.Cmd { m.fileTree = m.fileTree.SetCursor(m.cursor) return cmd } + +func (m mainModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + // Calculate boundaries + sidebarWidth := m.sidebarWidth() + contentStartY := headerHeight + contentEndY := m.height - footerHeight + + // Check if in content area (not header/footer) + if msg.Y < contentStartY || msg.Y >= contentEndY { + return m, nil + } + + // Handle based on action and position + switch msg.Action { + case tea.MouseActionPress: + if msg.Button == tea.MouseButtonLeft { + // Check for resize border (within 2px of sidebar edge) + if m.isShowingFileTree && abs(msg.X-sidebarWidth) <= 2 { + m.draggingSidebar = true + return m, nil + } + // Click in file tree + if m.isShowingFileTree && msg.X < sidebarWidth { + return m.handleFileTreeClick(msg) + } + } + + case tea.MouseActionRelease: + m.draggingSidebar = false + + case tea.MouseActionMotion: + if m.draggingSidebar { + return m.handleSidebarDrag(msg) + } + } + + // Handle scroll wheel + if msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown { + return m.handleScroll(msg) + } + + return m, nil +} + +func (m mainModel) handleFileTreeClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + // Calculate clicked Y relative to tree content (accounting for viewport scroll) + clickedY := msg.Y - headerHeight - searchHeight + m.fileTree.GetYOffset() + + // Find file at this Y position using tree traversal + filePath := m.fileTree.GetFileAtY(clickedY) + if filePath == "" { + return m, nil + } + + // Find file index by path + for i, f := range m.files { + if filenode.GetFileName(f) == filePath { + m.diffViewer.GoToTop() + cmd := m.setCursor(i) + return m, cmd + } + } + return m, nil +} + +func (m mainModel) handleScroll(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + sidebarWidth := m.sidebarWidth() + lines := 3 + + if m.isShowingFileTree && msg.X < sidebarWidth { + // Scroll file tree + if msg.Button == tea.MouseButtonWheelUp { + m.fileTree.ScrollUp(lines) + } else if msg.Button == tea.MouseButtonWheelDown { + m.fileTree.ScrollDown(lines) + } + } else { + // Scroll diff viewer + if msg.Button == tea.MouseButtonWheelUp { + m.diffViewer.ScrollUp(lines) + } else if msg.Button == tea.MouseButtonWheelDown { + m.diffViewer.ScrollDown(lines) + } + } + return m, nil +} + +func (m mainModel) handleSidebarDrag(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + // Clamp to reasonable bounds + minWidth := 20 + maxWidth := m.width / 2 + newWidth := max(minWidth, min(maxWidth, msg.X)) + + m.customSidebarWidth = newWidth + + // Resize components + cmds := []tea.Cmd{} + cmds = append(cmds, m.diffViewer.SetSize(m.width-m.sidebarWidth(), m.height-footerHeight-headerHeight)) + cmds = append(cmds, m.fileTree.SetSize(m.sidebarWidth(), m.height-footerHeight-headerHeight-searchHeight)) + + return m, tea.Batch(cmds...) +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} diff --git a/pkg/ui/panes/diffviewer/diffviewer.go b/pkg/ui/panes/diffviewer/diffviewer.go index 1f860ad..40549a5 100644 --- a/pkg/ui/panes/diffviewer/diffviewer.go +++ b/pkg/ui/panes/diffviewer/diffviewer.go @@ -132,6 +132,17 @@ func (m *Model) LineDown(n int) { m.vp.LineDown(n) } +// ScrollUp scrolls the viewport up by the given number of lines. +func (m *Model) ScrollUp(lines int) { + m.vp.LineUp(lines) +} + +// ScrollDown scrolls the viewport down by the given number of lines. +func (m *Model) ScrollDown(lines int) { + m.vp.LineDown(lines) +} + + func diff(file *gitdiff.File, width int) tea.Cmd { if width == 0 || file == nil { return nil diff --git a/pkg/ui/panes/filetree/filetree.go b/pkg/ui/panes/filetree/filetree.go index 6355357..abdcb47 100644 --- a/pkg/ui/panes/filetree/filetree.go +++ b/pkg/ui/panes/filetree/filetree.go @@ -138,6 +138,55 @@ func (m *Model) SetSize(width, height int) tea.Cmd { return nil } +// GetYOffset returns the viewport's current Y scroll offset. +func (m Model) GetYOffset() int { + return m.vp.YOffset +} + +// GetFileAtY returns the file path at the given Y coordinate (0-indexed visual line), or "" if none. +func (m Model) GetFileAtY(y int) string { + if m.tree == nil { + return "" + } + // Convert visual line (0-indexed) to YOffset (1-indexed from tree traversal) + // YOffset starts at 1 for root, 2 for first child, etc. + // If root is hidden (Value == dirIcon+"."), first visible is at YOffset 2 + yOffset := y + 1 // Convert from 0-indexed to 1-indexed + if m.tree.Value() == dirIcon+"." { + yOffset++ // Root is hidden, so visual line 0 = YOffset 2 + } + return m.findFileAtY(m.tree, yOffset) +} + +// findFileAtY traverses the tree to find the FileNode at position y. +func (m Model) findFileAtY(t *tree.Tree, y int) string { + children := t.Children() + for i := 0; i < children.Length(); i++ { + child := children.At(i) + switch c := child.(type) { + case *tree.Tree: + if result := m.findFileAtY(c, y); result != "" { + return result + } + case filenode.FileNode: + if c.YOffset == y { + return c.Path() + } + } + } + return "" +} + +// ScrollUp scrolls the viewport up by the given number of lines. +func (m *Model) ScrollUp(lines int) { + m.vp.LineUp(lines) +} + +// ScrollDown scrolls the viewport down by the given number of lines. +func (m *Model) ScrollDown(lines int) { + m.vp.LineDown(lines) +} + func (m Model) printWithoutRoot() string { if m.tree.Value() != dirIcon+"." { return m.tree.String() From 68a608602c6635cd9d0dbbd19e0b5c95b60e5839 Mon Sep 17 00:00:00 2001 From: Pablo Speciale Date: Tue, 6 Jan 2026 15:16:06 +0100 Subject: [PATCH 02/10] feat: click on search box to enter filter mode --- pkg/ui/mainModel.go | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/pkg/ui/mainModel.go b/pkg/ui/mainModel.go index 371be7e..8438c53 100644 --- a/pkg/ui/mainModel.go +++ b/pkg/ui/mainModel.go @@ -443,8 +443,13 @@ func (m mainModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { m.draggingSidebar = true return m, nil } - // Click in file tree + // Click in sidebar area if m.isShowingFileTree && msg.X < sidebarWidth { + // Check if click is in search box area + if msg.Y >= headerHeight && msg.Y < headerHeight+searchHeight { + return m.handleSearchBoxClick() + } + // Click in file tree return m.handleFileTreeClick(msg) } } @@ -466,6 +471,24 @@ func (m mainModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m mainModel) handleSearchBoxClick() (tea.Model, tea.Cmd) { + if m.searching { + return m, nil + } + m.searching = true + m.search.Width = m.sidebarWidth() - 5 + m.search.SetValue("") + m.resultsCursor = 0 + m.filtered = make([]string, 0) + + m.resultsVp.Width = constants.SearchingFileTreeWidth + m.resultsVp.Height = m.height - footerHeight - headerHeight - searchHeight + m.resultsVp.SetContent(m.resultsView()) + + dfCmd := m.diffViewer.SetSize(m.width-m.sidebarWidth(), m.height-footerHeight-headerHeight) + return m, tea.Batch(dfCmd, m.search.Focus()) +} + func (m mainModel) handleFileTreeClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { // Calculate clicked Y relative to tree content (accounting for viewport scroll) clickedY := msg.Y - headerHeight - searchHeight + m.fileTree.GetYOffset() From 4837c67c5415ed4a3844a21bf56f48d1634b223f Mon Sep 17 00:00:00 2001 From: Pablo Speciale Date: Tue, 6 Jan 2026 15:19:33 +0100 Subject: [PATCH 03/10] feat: click on search results to select file --- pkg/ui/mainModel.go | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/pkg/ui/mainModel.go b/pkg/ui/mainModel.go index 8438c53..53f2ad9 100644 --- a/pkg/ui/mainModel.go +++ b/pkg/ui/mainModel.go @@ -449,7 +449,10 @@ func (m mainModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if msg.Y >= headerHeight && msg.Y < headerHeight+searchHeight { return m.handleSearchBoxClick() } - // Click in file tree + // Click in results list (when searching) or file tree + if m.searching { + return m.handleSearchResultClick(msg) + } return m.handleFileTreeClick(msg) } } @@ -471,6 +474,35 @@ func (m mainModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m mainModel) handleSearchResultClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + // Calculate which result was clicked + clickedIndex := msg.Y - headerHeight - searchHeight + m.resultsVp.YOffset + if clickedIndex < 0 || clickedIndex >= len(m.filtered) { + return m, nil + } + + // Select the clicked result + selected := m.filtered[clickedIndex] + m.stopSearch() + + var cmd tea.Cmd + var cmds []tea.Cmd + dfCmd := m.diffViewer.SetSize(m.width-m.sidebarWidth(), m.height-footerHeight-headerHeight) + cmds = append(cmds, dfCmd) + + for i, f := range m.files { + if filenode.GetFileName(f) == selected { + m.cursor = i + m.diffViewer, cmd = m.diffViewer.SetFilePatch(f) + m.fileTree = m.fileTree.SetCursor(i) + cmds = append(cmds, cmd) + break + } + } + + return m, tea.Batch(cmds...) +} + func (m mainModel) handleSearchBoxClick() (tea.Model, tea.Cmd) { if m.searching { return m, nil From ff2021025fbf93b41d4f2edef751af8662cc4022 Mon Sep 17 00:00:00 2001 From: Pablo Speciale Date: Tue, 6 Jan 2026 15:21:34 +0100 Subject: [PATCH 04/10] fix: handle mouse events in search mode --- pkg/ui/mainModel.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/ui/mainModel.go b/pkg/ui/mainModel.go index 53f2ad9..6bc8db8 100644 --- a/pkg/ui/mainModel.go +++ b/pkg/ui/mainModel.go @@ -93,6 +93,11 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd + // Handle mouse events regardless of search mode + if msg, ok := msg.(tea.MouseMsg); ok { + return m.handleMouse(msg) + } + if !m.searching { switch msg := msg.(type) { case tea.KeyMsg: @@ -172,9 +177,6 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd = m.setCursor(0) cmds = append(cmds, cmd) - case tea.MouseMsg: - return m.handleMouse(msg) - case common.ErrMsg: fmt.Printf("Error: %v\n", msg.Err) log.Fatal(msg.Err) From f58c2a54e30ec31921d8e915c1bfa9ec69a2137c Mon Sep 17 00:00:00 2001 From: Pablo Speciale Date: Tue, 6 Jan 2026 22:38:47 +0100 Subject: [PATCH 05/10] refactor: use bubblezone for mouse click detection - Add bubblezone dependency for zone-based click detection - Mark UI zones (searchbox, filetree, searchresults, diffviewer) - Replace coordinate-based checks with zone.InBounds() - Use zone.Pos() for relative coordinates in click handlers - Keep hybrid approach for sidebar resize (coordinate-based) - Update FileNode to implement new tree.Node interface --- go.mod | 24 +++++--- go.sum | 40 ++++++++------ main.go | 3 + pkg/filenode/file_node.go | 4 ++ pkg/ui/mainModel.go | 112 ++++++++++++++++++++++---------------- 5 files changed, 110 insertions(+), 73 deletions(-) diff --git a/go.mod b/go.mod index 19c74ca..7931629 100644 --- a/go.mod +++ b/go.mod @@ -1,23 +1,28 @@ module github.com/dlvhdr/diffnav -go 1.22.6 +go 1.23.0 + +toolchain go1.24.11 require ( github.com/atotto/clipboard v0.1.4 github.com/bluekeyes/go-gitdiff v0.8.0 github.com/charmbracelet/bubbles v0.20.0 - github.com/charmbracelet/bubbletea v1.1.0 - github.com/charmbracelet/lipgloss v0.13.0 + github.com/charmbracelet/bubbletea v1.3.4 + github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/log v0.4.0 - github.com/charmbracelet/x/ansi v0.3.2 + github.com/charmbracelet/x/ansi v0.8.0 + github.com/lrstanley/bubblezone v1.0.0 github.com/muesli/reflow v0.3.0 - github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a + github.com/muesli/termenv v0.16.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/x/term v0.2.0 // indirect + github.com/charmbracelet/colorprofile v0.3.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect @@ -27,8 +32,9 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.25.0 // indirect - golang.org/x/text v0.18.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect ) diff --git a/go.sum b/go.sum index ff04ffd..37264cb 100644 --- a/go.sum +++ b/go.sum @@ -8,24 +8,30 @@ github.com/bluekeyes/go-gitdiff v0.8.0 h1:Nn1wfw3/XeKoc3lWk+2bEXGUHIx36kj80FM1gV github.com/bluekeyes/go-gitdiff v0.8.0/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE= github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= -github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c= -github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= -github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= -github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= +github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= +github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= +github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= +github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= -github.com/charmbracelet/x/ansi v0.3.2 h1:wsEwgAN+C9U06l9dCVMX0/L3x7ptvY1qmjMwyfE6USY= -github.com/charmbracelet/x/ansi v0.3.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= -github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA= +github.com/lrstanley/bubblezone v1.0.0/go.mod h1:kcTekA8HE/0Ll2bWzqHlhA2c513KDNLW7uDfDP4Mly8= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -41,8 +47,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= -github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -51,16 +57,18 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 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= diff --git a/main.go b/main.go index 3a3cd56..f7a87e6 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/log" "github.com/charmbracelet/x/ansi" + zone "github.com/lrstanley/bubblezone" "github.com/muesli/termenv" "github.com/dlvhdr/diffnav/pkg/config" @@ -18,6 +19,8 @@ import ( ) func main() { + zone.NewGlobal() + stat, err := os.Stdin.Stat() if err != nil { panic(err) diff --git a/pkg/filenode/file_node.go b/pkg/filenode/file_node.go index 9409e20..b48499a 100644 --- a/pkg/filenode/file_node.go +++ b/pkg/filenode/file_node.go @@ -63,6 +63,10 @@ func (f FileNode) Hidden() bool { return false } +func (f FileNode) SetHidden(bool) {} + +func (f FileNode) SetValue(any) {} + func GetFileName(file *gitdiff.File) string { if file.NewName != "" { return file.NewName diff --git a/pkg/ui/mainModel.go b/pkg/ui/mainModel.go index 6bc8db8..426f0e0 100644 --- a/pkg/ui/mainModel.go +++ b/pkg/ui/mainModel.go @@ -12,6 +12,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/log" + zone "github.com/lrstanley/bubblezone" "github.com/dlvhdr/diffnav/pkg/config" "github.com/dlvhdr/diffnav/pkg/filenode" @@ -25,6 +26,12 @@ const ( footerHeight = 2 headerHeight = 2 searchHeight = 3 + + // Zone IDs for bubblezone click detection. + zoneSearchBox = "searchbox" + zoneFileTree = "filetree" + zoneSearchResults = "searchresults" + zoneDiffViewer = "diffviewer" ) type Panel int @@ -298,24 +305,25 @@ func (m mainModel) View() string { sidebar := "" if m.isShowingFileTree { - search := lipgloss.NewStyle(). + searchBox := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("8")). MaxHeight(3). Width(m.sidebarWidth() - 2). Render(m.search.View()) + searchBox = zone.Mark(zoneSearchBox, searchBox) content := "" width := m.sidebarWidth() if m.searching { - content = m.resultsVp.View() + content = zone.Mark(zoneSearchResults, m.resultsVp.View()) } else { - content = m.fileTree.View() + content = zone.Mark(zoneFileTree, m.fileTree.View()) } content = lipgloss.NewStyle(). Width(width). - Height(m.height - m.footerHeight() - m.headerHeight() - 1).Render(lipgloss.JoinVertical(lipgloss.Left, search, content)) + Height(m.height - m.footerHeight() - m.headerHeight() - 1).Render(lipgloss.JoinVertical(lipgloss.Left, searchBox, content)) sidebar = lipgloss.NewStyle(). Width(width). @@ -323,6 +331,7 @@ func (m mainModel) View() string { BorderForeground(leftColor).Render(content) } dv := lipgloss.NewStyle().MaxHeight(m.height - m.footerHeight() - m.headerHeight() - 1).Width(m.width - m.sidebarWidth()).Render(m.diffViewer.View()) + dv = zone.Mark(zoneDiffViewer, dv) mainContent := lipgloss.JoinHorizontal(lipgloss.Top, sidebar, dv) @@ -343,7 +352,7 @@ func (m mainModel) View() string { sections = append(sections, m.footerView()) } - return lipgloss.JoinVertical(lipgloss.Left, sections...) + return zone.Scan(lipgloss.JoinVertical(lipgloss.Left, sections...)) } type fileTreeMsg struct { @@ -426,35 +435,29 @@ func (m *mainModel) setCursor(cursor int) tea.Cmd { } func (m mainModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - // Calculate boundaries - sidebarWidth := m.sidebarWidth() - contentStartY := headerHeight - contentEndY := m.height - footerHeight - - // Check if in content area (not header/footer) - if msg.Y < contentStartY || msg.Y >= contentEndY { - return m, nil + // Handle scroll wheel first. + if msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown { + return m.handleScroll(msg) } - // Handle based on action and position switch msg.Action { case tea.MouseActionPress: if msg.Button == tea.MouseButtonLeft { - // Check for resize border (within 2px of sidebar edge) + // Keep coordinate check for resize border (hybrid approach). + sidebarWidth := m.sidebarWidth() if m.isShowingFileTree && abs(msg.X-sidebarWidth) <= 2 { m.draggingSidebar = true return m, nil } - // Click in sidebar area - if m.isShowingFileTree && msg.X < sidebarWidth { - // Check if click is in search box area - if msg.Y >= headerHeight && msg.Y < headerHeight+searchHeight { - return m.handleSearchBoxClick() - } - // Click in results list (when searching) or file tree - if m.searching { - return m.handleSearchResultClick(msg) - } + + // Zone-based detection for everything else. + if zone.Get(zoneSearchBox).InBounds(msg) { + return m.handleSearchBoxClick() + } + if m.searching && zone.Get(zoneSearchResults).InBounds(msg) { + return m.handleSearchResultClick(msg) + } + if !m.searching && zone.Get(zoneFileTree).InBounds(msg) { return m.handleFileTreeClick(msg) } } @@ -468,22 +471,21 @@ func (m mainModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } } - // Handle scroll wheel - if msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown { - return m.handleScroll(msg) - } - return m, nil } func (m mainModel) handleSearchResultClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - // Calculate which result was clicked - clickedIndex := msg.Y - headerHeight - searchHeight + m.resultsVp.YOffset - if clickedIndex < 0 || clickedIndex >= len(m.filtered) { + // Use zone-relative coordinates. + _, y := zone.Get(zoneSearchResults).Pos(msg) + if y < 0 { + return m, nil + } + clickedIndex := y + m.resultsVp.YOffset + if clickedIndex >= len(m.filtered) { return m, nil } - // Select the clicked result + // Select the clicked result. selected := m.filtered[clickedIndex] m.stopSearch() @@ -515,7 +517,7 @@ func (m mainModel) handleSearchBoxClick() (tea.Model, tea.Cmd) { m.resultsCursor = 0 m.filtered = make([]string, 0) - m.resultsVp.Width = constants.SearchingFileTreeWidth + m.resultsVp.Width = m.config.UI.SearchTreeWidth m.resultsVp.Height = m.height - footerHeight - headerHeight - searchHeight m.resultsVp.SetContent(m.resultsView()) @@ -524,16 +526,20 @@ func (m mainModel) handleSearchBoxClick() (tea.Model, tea.Cmd) { } func (m mainModel) handleFileTreeClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - // Calculate clicked Y relative to tree content (accounting for viewport scroll) - clickedY := msg.Y - headerHeight - searchHeight + m.fileTree.GetYOffset() + // Use zone-relative coordinates. + _, y := zone.Get(zoneFileTree).Pos(msg) + if y < 0 { + return m, nil + } + clickedY := y + m.fileTree.GetYOffset() - // Find file at this Y position using tree traversal + // Find file at this Y position using tree traversal. filePath := m.fileTree.GetFileAtY(clickedY) if filePath == "" { return m, nil } - // Find file index by path + // Find file index by path. for i, f := range m.files { if filenode.GetFileName(f) == filePath { m.diffViewer.GoToTop() @@ -545,21 +551,31 @@ func (m mainModel) handleFileTreeClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } func (m mainModel) handleScroll(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - sidebarWidth := m.sidebarWidth() lines := 3 - if m.isShowingFileTree && msg.X < sidebarWidth { - // Scroll file tree + // Check if scrolling in sidebar (file tree or search results). + if zone.Get(zoneFileTree).InBounds(msg) || zone.Get(zoneSearchResults).InBounds(msg) { if msg.Button == tea.MouseButtonWheelUp { - m.fileTree.ScrollUp(lines) - } else if msg.Button == tea.MouseButtonWheelDown { - m.fileTree.ScrollDown(lines) + if m.searching { + m.resultsVp.LineUp(lines) + } else { + m.fileTree.ScrollUp(lines) + } + } else { + if m.searching { + m.resultsVp.LineDown(lines) + } else { + m.fileTree.ScrollDown(lines) + } } - } else { - // Scroll diff viewer + return m, nil + } + + // Check if scrolling in diff viewer. + if zone.Get(zoneDiffViewer).InBounds(msg) { if msg.Button == tea.MouseButtonWheelUp { m.diffViewer.ScrollUp(lines) - } else if msg.Button == tea.MouseButtonWheelDown { + } else { m.diffViewer.ScrollDown(lines) } } From 4de402d6225167457834e9b6eb25e9ef31c158b8 Mon Sep 17 00:00:00 2001 From: Pablo Speciale Date: Tue, 6 Jan 2026 22:41:00 +0100 Subject: [PATCH 06/10] fix: prevent file tree scroll jump when clicking files Add SetCursorNoScroll method to filetree that updates selection without scrolling the viewport. Use it in handleFileTreeClick so the view stays in place when clicking a visible file. --- pkg/ui/mainModel.go | 6 +++++- pkg/ui/panes/filetree/filetree.go | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/pkg/ui/mainModel.go b/pkg/ui/mainModel.go index 426f0e0..215090f 100644 --- a/pkg/ui/mainModel.go +++ b/pkg/ui/mainModel.go @@ -542,8 +542,12 @@ func (m mainModel) handleFileTreeClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { // Find file index by path. for i, f := range m.files { if filenode.GetFileName(f) == filePath { + m.cursor = i m.diffViewer.GoToTop() - cmd := m.setCursor(i) + var cmd tea.Cmd + m.diffViewer, cmd = m.diffViewer.SetFilePatch(f) + // Use SetCursorNoScroll to avoid jumping the file tree view. + m.fileTree = m.fileTree.SetCursorNoScroll(i) return m, cmd } } diff --git a/pkg/ui/panes/filetree/filetree.go b/pkg/ui/panes/filetree/filetree.go index abdcb47..080cb93 100644 --- a/pkg/ui/panes/filetree/filetree.go +++ b/pkg/ui/panes/filetree/filetree.go @@ -46,6 +46,19 @@ func (m Model) SetCursor(cursor int) Model { return m } +// SetCursorNoScroll updates the selected file without scrolling the viewport. +// Use this when the user clicks on a file they can already see. +func (m Model) SetCursorNoScroll(cursor int) Model { + if len(m.files) == 0 { + return m + } + name := filenode.GetFileName(m.files[cursor]) + m.selectedFile = &name + applyStyles(m.tree, m.selectedFile) + m.vp.SetContent(m.printWithoutRoot()) + return m +} + func (m Model) CopyFilePath(cursor int) tea.Cmd { if len(m.files) == 0 { return nil From 35af88e1f605c716f263df80038f86f22029283f Mon Sep 17 00:00:00 2001 From: Pablo Speciale Date: Wed, 7 Jan 2026 22:43:14 +0100 Subject: [PATCH 07/10] refactor: extract isRootHidden() to reduce duplication Consolidate duplicate root-hidden checks into a single reusable method. Also fixes inconsistency where one check used '.' and another used dirIcon+'.' - now all use the correct dirIcon+'.' check. --- pkg/ui/panes/filetree/filetree.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/pkg/ui/panes/filetree/filetree.go b/pkg/ui/panes/filetree/filetree.go index 080cb93..9cb03a5 100644 --- a/pkg/ui/panes/filetree/filetree.go +++ b/pkg/ui/panes/filetree/filetree.go @@ -25,6 +25,11 @@ type Model struct { selectedFile *string } +// isRootHidden returns true if the tree root is hidden (not displayed). +func (m Model) isRootHidden() bool { + return m.tree != nil && m.tree.Value() == dirIcon+"." +} + func (m Model) SetFiles(files []*gitdiff.File) Model { m.files = files t := buildFullFileTree(files) @@ -87,8 +92,7 @@ func (m *Model) scrollSelectedFileIntoView(t *tree.Tree) { if child.Path() == *m.selectedFile { // offset is 1-based, so we need to subtract 1 offset := child.YOffset - 1 - contextLines - // we also need to subtract 1 if the root is not shown - if m.tree.Value() == "." { + if m.isRootHidden() { offset = offset - 1 } m.vp.SetYOffset(offset) @@ -161,12 +165,11 @@ func (m Model) GetFileAtY(y int) string { if m.tree == nil { return "" } - // Convert visual line (0-indexed) to YOffset (1-indexed from tree traversal) + // Convert visual line (0-indexed) to YOffset (1-indexed from tree traversal). // YOffset starts at 1 for root, 2 for first child, etc. - // If root is hidden (Value == dirIcon+"."), first visible is at YOffset 2 - yOffset := y + 1 // Convert from 0-indexed to 1-indexed - if m.tree.Value() == dirIcon+"." { - yOffset++ // Root is hidden, so visual line 0 = YOffset 2 + yOffset := y + 1 + if m.isRootHidden() { + yOffset++ // Root is hidden, so visual line 0 = YOffset 2. } return m.findFileAtY(m.tree, yOffset) } @@ -201,7 +204,7 @@ func (m *Model) ScrollDown(lines int) { } func (m Model) printWithoutRoot() string { - if m.tree.Value() != dirIcon+"." { + if !m.isRootHidden() { return m.tree.String() } From 3d98d69def831f91f85c3788a0712adf4b1c492d Mon Sep 17 00:00:00 2001 From: Pablo Speciale Date: Wed, 7 Jan 2026 23:11:55 +0100 Subject: [PATCH 08/10] feat: auto-hide sidebar when dragged below threshold When user drags sidebar width below 10px, automatically hide it. Pressing 'e' to show the tree again resets to default width (26). --- pkg/ui/mainModel.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/pkg/ui/mainModel.go b/pkg/ui/mainModel.go index 215090f..7ee7adb 100644 --- a/pkg/ui/mainModel.go +++ b/pkg/ui/mainModel.go @@ -126,7 +126,9 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, dfCmd, m.search.Focus()) case "e": m.isShowingFileTree = !m.isShowingFileTree - if !m.isShowingFileTree { + if m.isShowingFileTree { + m.customSidebarWidth = 0 // Reset to default width. + } else { m.activePanel = DiffViewerPanel } dfCmd := m.diffViewer.SetSize(m.width-m.sidebarWidth(), m.height-m.footerHeight()-m.headerHeight()) @@ -587,14 +589,22 @@ func (m mainModel) handleScroll(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } func (m mainModel) handleSidebarDrag(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - // Clamp to reasonable bounds + // Hide sidebar if dragged below threshold. + if msg.X < 10 { + m.isShowingFileTree = false + m.draggingSidebar = false + cmd := m.diffViewer.SetSize(m.width, m.height-footerHeight-headerHeight) + return m, cmd + } + + // Clamp to reasonable bounds. minWidth := 20 maxWidth := m.width / 2 newWidth := max(minWidth, min(maxWidth, msg.X)) m.customSidebarWidth = newWidth - // Resize components + // Resize components. cmds := []tea.Cmd{} cmds = append(cmds, m.diffViewer.SetSize(m.width-m.sidebarWidth(), m.height-footerHeight-headerHeight)) cmds = append(cmds, m.fileTree.SetSize(m.sidebarWidth(), m.height-footerHeight-headerHeight-searchHeight)) From 3200ec3c2c07eba249c60851f24dfea766b9ffc4 Mon Sep 17 00:00:00 2001 From: Pablo Speciale Date: Wed, 7 Jan 2026 23:22:15 +0100 Subject: [PATCH 09/10] feat: show grab line when sidebar is collapsed Allow dragging the sidebar back open by clicking and dragging the thin vertical line that remains when sidebar is hidden. --- pkg/ui/mainModel.go | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/pkg/ui/mainModel.go b/pkg/ui/mainModel.go index 7ee7adb..891a951 100644 --- a/pkg/ui/mainModel.go +++ b/pkg/ui/mainModel.go @@ -32,6 +32,9 @@ const ( zoneFileTree = "filetree" zoneSearchResults = "searchresults" zoneDiffViewer = "diffviewer" + + // Sidebar resize detection threshold in pixels. + sidebarGrabThreshold = 2 ) type Panel int @@ -331,6 +334,16 @@ func (m mainModel) View() string { Width(width). Border(lipgloss.NormalBorder(), false, true, false, false). BorderForeground(leftColor).Render(content) + } else { + // Show a thin grab line when sidebar is hidden. + // Width(0) means only the border is rendered (1 char). + grabLine := lipgloss.NewStyle(). + Width(0). + Height(m.height - m.footerHeight() - m.headerHeight() - 1). + Border(lipgloss.NormalBorder(), false, true, false, false). + BorderForeground(lipgloss.Color("8")). + Render("") + sidebar = grabLine } dv := lipgloss.NewStyle().MaxHeight(m.height - m.footerHeight() - m.headerHeight() - 1).Width(m.width - m.sidebarWidth()).Render(m.diffViewer.View()) dv = zone.Mark(zoneDiffViewer, dv) @@ -447,8 +460,14 @@ func (m mainModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if msg.Button == tea.MouseButtonLeft { // Keep coordinate check for resize border (hybrid approach). sidebarWidth := m.sidebarWidth() - if m.isShowingFileTree && abs(msg.X-sidebarWidth) <= 2 { + if m.isShowingFileTree && abs(msg.X-sidebarWidth) <= sidebarGrabThreshold { + m.draggingSidebar = true + return m, nil + } + // Allow grabbing the line when sidebar is hidden. + if !m.isShowingFileTree && msg.X <= sidebarGrabThreshold { m.draggingSidebar = true + m.isShowingFileTree = true return m, nil } From dad63d133c4f218a211025c28d787848d0f716bb Mon Sep 17 00:00:00 2001 From: Pablo Speciale Date: Wed, 7 Jan 2026 23:27:27 +0100 Subject: [PATCH 10/10] refactor: extract magic numbers to constants Add constants for sidebar constraints and scroll speed: - sidebarMinWidth (20) - sidebarHideWidth (10) - scrollLines (3) Also add comment explaining Width(0) for grab line. --- pkg/ui/mainModel.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pkg/ui/mainModel.go b/pkg/ui/mainModel.go index 891a951..78d0e5f 100644 --- a/pkg/ui/mainModel.go +++ b/pkg/ui/mainModel.go @@ -35,6 +35,13 @@ const ( // Sidebar resize detection threshold in pixels. sidebarGrabThreshold = 2 + + // Sidebar width constraints. + sidebarMinWidth = 20 + sidebarHideWidth = 10 + + // Scroll speed in lines per wheel tick. + scrollLines = 3 ) type Panel int @@ -576,7 +583,7 @@ func (m mainModel) handleFileTreeClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } func (m mainModel) handleScroll(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - lines := 3 + lines := scrollLines // Check if scrolling in sidebar (file tree or search results). if zone.Get(zoneFileTree).InBounds(msg) || zone.Get(zoneSearchResults).InBounds(msg) { @@ -609,7 +616,7 @@ func (m mainModel) handleScroll(msg tea.MouseMsg) (tea.Model, tea.Cmd) { func (m mainModel) handleSidebarDrag(msg tea.MouseMsg) (tea.Model, tea.Cmd) { // Hide sidebar if dragged below threshold. - if msg.X < 10 { + if msg.X < sidebarHideWidth { m.isShowingFileTree = false m.draggingSidebar = false cmd := m.diffViewer.SetSize(m.width, m.height-footerHeight-headerHeight) @@ -617,7 +624,7 @@ func (m mainModel) handleSidebarDrag(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } // Clamp to reasonable bounds. - minWidth := 20 + minWidth := sidebarMinWidth maxWidth := m.width / 2 newWidth := max(minWidth, min(maxWidth, msg.X))