fix(browser): fix directory navigation and entry tapping

This commit is contained in:
Greg Pomerantz 2026-06-02 08:14:59 -04:00
parent 99b733cb7d
commit 2c3702f421
8 changed files with 153 additions and 35 deletions

View File

@ -1,5 +1,12 @@
package browser
import (
"path/filepath"
"strings"
"pad/internal/ui"
)
// HandleScroll updates the browser scroll index by the given delta (in entries).
// Clamps to valid bounds and triggers prefetch/eviction as needed.
func HandleScroll(s *BrowserState, delta int) {
@ -41,3 +48,33 @@ func clampScrollOffset(s *BrowserState) {
s.ScrollOffset = maxScroll
}
}
// HandleBrowserTap processes a tap on the ListView at the given index.
func HandleBrowserTap(bm *BrowserManager, s *BrowserState, index int) {
if index < 0 || index >= s.TotalEntries {
return
}
entry, ok := getEntryByIndex(s, index)
if !ok {
return // Page not loaded
}
if entry.IsDir {
// Navigate into directory or up
var newPath string
if entry.Name == ".." {
newPath = filepath.Dir(s.CurrentPath)
} else {
newPath = filepath.Join(s.CurrentPath, entry.Name)
if !strings.HasPrefix(newPath, "/") {
newPath = "/" + newPath
}
}
bm.NavigateTo(newPath)
} else {
// Open file
s.SelectedIndex = index
ui.OpenFile(entry.Path)
}
}

View File

@ -7,8 +7,8 @@ import (
)
// BrowserLayout computes the element tree for the browser (file listing) page.
// Pure function: (screen dimensions, browser state, sort handler) → []ui.Element
func BrowserLayout(screenW, screenH ui.Dp, state *BrowserState, sortHandler func(any)) []ui.Element {
// Pure function: (screen dimensions, browser state, sort handler, tap handler) → []ui.Element
func BrowserLayout(screenW, screenH ui.Dp, state *BrowserState, sortHandler func(any), tapHandler func(any)) []ui.Element {
margin := ui.Dp(10)
contentWidth := screenW - margin*2
@ -79,7 +79,10 @@ func BrowserLayout(screenW, screenH ui.Dp, state *BrowserState, sortHandler func
listRegion,
ui.Dp(state.ScrollOffset), // scroll offset in pixels (per-pixel)
state.SelectedIndex,
[]ui.Interaction{{Gesture: ui.Scroll, Handler: scrollHandler}},
[]ui.Interaction{
{Gesture: ui.Scroll, Handler: scrollHandler},
},
tapHandler,
)
elems := []ui.Element{headerBar, searchBar}

View File

@ -63,7 +63,7 @@ func findListView(elements []ui.Element) (ui.ListView, bool) {
func TestBrowserLayout_ElementCount(t *testing.T) {
s := makeTestState(t, 200, 0, 20)
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil)
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil, nil)
// Should produce: header + search bar + list view = 3 elements
if len(elements) < 3 {
@ -76,7 +76,7 @@ func TestBrowserLayout_ElementCount(t *testing.T) {
func TestBrowserLayout_HeaderRegion(t *testing.T) {
s := makeTestState(t, 200, 0, 20)
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil)
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil, nil)
// First element should be the header container
if len(elements) == 0 {
@ -102,7 +102,7 @@ func TestBrowserLayout_HeaderRegion(t *testing.T) {
func TestBrowserLayout_ListRegion(t *testing.T) {
s := makeTestState(t, 200, 0, 20)
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil)
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil, nil)
listView, found := findListView(elements)
if !found {
@ -127,7 +127,7 @@ func TestBrowserLayout_ListRegion(t *testing.T) {
func TestBrowserLayout_VisibleEntriesCount(t *testing.T) {
s := makeTestState(t, 500, 100, 20)
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil)
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil, nil)
listView, found := findListView(elements)
if !found {
@ -149,7 +149,7 @@ func TestBrowserLayout_EmptyDirectory(t *testing.T) {
s.CurrentPath = "/empty/dir"
s.TotalEntries = 0
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil)
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil, nil)
if len(elements) < 3 {
t.Errorf("expected at least 3 elements for empty dir, got %d", len(elements))
@ -179,7 +179,7 @@ func TestBrowserLayout_SingleEntry(t *testing.T) {
}
s.Pages[0] = NewPage(0, entries)
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil)
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil, nil)
listView, found := findListView(elements)
if !found {
@ -214,7 +214,7 @@ func TestBrowserLayout_PartialPage(t *testing.T) {
s.Pages[0] = NewPage(0, entries[:100])
s.Pages[1] = NewPage(1, entries[100:])
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil)
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil, nil)
listView, found := findListView(elements)
if !found {
@ -241,7 +241,7 @@ func TestBrowserLayout_UnloadedPage(t *testing.T) {
entries := make([]Entry, 100)
s.Pages[0] = NewPage(0, entries)
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil)
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil, nil)
// Layout should not crash with unloaded pages
if len(elements) < 3 {
@ -262,7 +262,7 @@ func TestBrowserLayout_WithSearchQuery(t *testing.T) {
}
}
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil)
elements := BrowserLayout(ui.Dp(800), ui.Dp(1200), s, nil, nil)
// Should still produce valid layout
if len(elements) < 3 {
@ -278,7 +278,7 @@ func TestBrowserLayout_ScreenDimensions(t *testing.T) {
// Test with different screen sizes
for _, w := range []ui.Dp{400, 800, 1200} {
for _, h := range []ui.Dp{600, 1200, 1800} {
elements := BrowserLayout(w, h, s, nil)
elements := BrowserLayout(w, h, s, nil, nil)
if len(elements) < 3 {
t.Errorf("layout failed for %dx%d: expected >= 3 elements, got %d", int(w), int(h), len(elements))

View File

@ -3,6 +3,7 @@ package browser
import (
"fmt"
"path/filepath"
"time"
"pad/internal/io/pool"
"pad/internal/io/pool/mock"
@ -94,6 +95,17 @@ func (bm *BrowserManager) handleBuildIndexSuccess(result pool.Result) {
entries := result.Data.([]mock.DirEntry)
var browserEntries []Entry
// Add ".." entry if not at root
if bm.dirPath != "/" && bm.dirPath != "." {
browserEntries = append(browserEntries, Entry{
Path: filepath.Dir(bm.dirPath),
Name: "..",
Size: 0,
ModTime: time.Now(),
IsDir: true,
})
}
for _, e := range entries {
info, _ := e.Info()
browserEntries = append(browserEntries, Entry{

View File

@ -0,0 +1,60 @@
package browser
import (
"testing"
"time"
"pad/internal/io/pool"
"pad/internal/io/pool/mock"
)
// TestTapDirectory verifies that tapping a directory entry updates the
// BrowserState.CurrentPath.
func TestTapDirectory(t *testing.T) {
state := NewBrowserState()
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, _ := NewBrowserManager(state, wp, fs)
state.CurrentPath = "/root"
// Mock entries: a subdirectory "sub" and a file
entries := []Entry{
NewEntry("/root/sub", "sub", 0, time.Time{}, true),
NewEntry("/root/file.txt", "file.txt", 100, time.Time{}, false),
}
// Inject entries into the state
state.Pages[0] = NewPage(0, entries)
state.TotalEntries = 2
// Simulate tapping the directory at index 0
HandleBrowserTap(bm, state, 0)
if state.CurrentPath != "/root/sub" {
t.Errorf("expected CurrentPath='/root/sub', got %s", state.CurrentPath)
}
}
// TestTapUp verifies that tapping a ".." entry updates the
// BrowserState.CurrentPath to the parent directory.
func TestTapUp(t *testing.T) {
state := NewBrowserState()
fs := mock.NewFileSystem()
wp := pool.NewWorkerPool(1)
bm, _ := NewBrowserManager(state, wp, fs)
state.CurrentPath = "/root/sub"
// Mock entries: ".." and a file
entries := []Entry{
NewEntry("/root", "..", 0, time.Time{}, true),
NewEntry("/root/sub/file.txt", "file.txt", 100, time.Time{}, false),
}
state.Pages[0] = NewPage(0, entries)
state.TotalEntries = 2
// Simulate tapping the ".." entry at index 0
HandleBrowserTap(bm, state, 0)
if state.CurrentPath != "/root" {
t.Errorf("expected CurrentPath='/root', got %s", state.CurrentPath)
}
}

View File

@ -142,17 +142,17 @@ func (l *Logic) Run() {
return
case update := <-l.configChan:
update.apply(l.state)
l.frameChan <- l.state.layout()
l.frameChan <- l.state.layout(l.browserManager)
case y := <-l.lastLineYChan:
if ui.Dp(y) != l.state.LastLineY {
l.state.LastLineY = ui.Dp(y)
l.frameChan <- l.state.layout()
l.frameChan <- l.state.layout(l.browserManager)
}
case events := <-l.inputChan:
for _, evt := range events {
evt.Handler(evt.Data)
}
l.frameChan <- l.state.layout()
l.frameChan <- l.state.layout(l.browserManager)
case query := <-l.searchQueryChan:
if query != l.state.Browser.Query {
l.state.Browser.Query = query
@ -160,11 +160,11 @@ func (l *Logic) Run() {
browser.HandleSearch(&l.state.Browser, query)
}
}
l.frameChan <- l.state.layout()
l.frameChan <- l.state.layout(l.browserManager)
case res := <-l.workerPool.ResultChan():
l.handleWorkerResult(res)
case <-l.resultChan:
l.frameChan <- l.state.layout()
l.frameChan <- l.state.layout(l.browserManager)
}
}
}
@ -174,7 +174,7 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
if res.IsBrowserResult() {
l.browserManager.HandleResult(res)
}
l.frameChan <- l.state.layout()
l.frameChan <- l.state.layout(l.browserManager)
}
// applyBuildIndexResult applies a completed BuildIndexTask result to browser state.

View File

@ -96,7 +96,7 @@ func (s *State) Scale() float32 {
// and computes the element tree. Called only when a frame is needed.
// Search query sync is handled by the logic goroutine via searchQueryChan,
// not here, to ensure proper channel-based state flow.
func (s *State) layout() []ui.Element {
func (s *State) layout(bm *browser.BrowserManager) []ui.Element {
dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale)
dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale)
@ -114,7 +114,12 @@ func (s *State) layout() []ui.Element {
switch s.page {
case BrowserPage:
s.Elems = browser.BrowserLayout(dpW, dpH, &s.Browser, ToggleSortOrder)
tapHandler := func(data any) {
if idx, ok := data.(int); ok {
browser.HandleBrowserTap(bm, &s.Browser, idx)
}
}
s.Elems = browser.BrowserLayout(dpW, dpH, &s.Browser, ToggleSortOrder, tapHandler)
case EditorPage:
s.Elems = EditorLayout(dpW, dpH, s.WordWrap)
}

View File

@ -234,7 +234,7 @@ type ListView struct {
Items []ListItem
ScrollOffset Dp // pixel-level scroll offset
Selected int
RowFilenames []string // filenames for click navigation
RowTapHandler func(any) // handler for row taps, receives index as any
}
func (lv ListView) Region() Region { return lv.region }
@ -299,15 +299,15 @@ func (lv ListView) Draw(gtx layout.Context, r *Renderer) {
}
// Register click area for this row
rowID := fmt.Sprintf("list_row_%d", rowGlobalIndex)
var filename string
if i < len(lv.RowFilenames) {
filename = lv.RowFilenames[i]
}
r.RegisterClick(gtx, rowID, Region{
X: lv.region.X, Y: y,
W: lv.region.W, H: rowHeight,
}, func(data any) {
OpenFile(filename)
if lv.RowTapHandler != nil {
lv.RowTapHandler(rowGlobalIndex)
} else {
OpenFile(item.Text)
}
})
// Draw main text
textRegion := Region{
@ -331,7 +331,7 @@ func (lv ListView) Draw(gtx layout.Context, r *Renderer) {
}
// NewListView creates a visible ListView element.
func NewListView(id string, items []ListItem, region Region, scrollOffset Dp, selected int, interactions []Interaction) ListView {
func NewListView(id string, items []ListItem, region Region, scrollOffset Dp, selected int, interactions []Interaction, rowTapHandler func(any)) ListView {
return ListView{
id: id,
region: region,
@ -340,6 +340,7 @@ func NewListView(id string, items []ListItem, region Region, scrollOffset Dp, se
Items: items,
ScrollOffset: scrollOffset,
Selected: selected,
RowTapHandler: rowTapHandler,
}
}