fix(browser): fix directory navigation and entry tapping
This commit is contained in:
parent
99b733cb7d
commit
2c3702f421
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
60
internal/browser/navigation_test.go
Normal file
60
internal/browser/navigation_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user