Skip to content

Commit 66d336c

Browse files
perf(remote): Fast SFTP walk and background indexing
- Add SFTPFs.Walk using ReadDir (1 round trip per dir vs N Stat calls) - Add FastWalker interface, Index.Build() uses it when available - Single file: create one-entry index without walking parent dir - Drop CacheOnReadFs (caused overhead on directory operations) - Build fulltext + link graph in background after TUI starts - Add Index.AddFile for manual entry injection
1 parent c46374e commit 66d336c

File tree

4 files changed

+117
-26
lines changed

4 files changed

+117
-26
lines changed

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Dual-mode markdown navigator: TUI (Bubble Tea) and web (stdlib net/http). Inter-document link navigation with history, backlinks, and broken link detection. Syntax highlighting (50+ languages), git-aware, zero-config.
44

5-
**Version:** v0.3.1
5+
**Version:** v0.3.2
66

77
## Tech Stack
88

cmd/lookit/main.go

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
"time"
1111

1212
tea "github.com/charmbracelet/bubbletea"
13-
"github.com/spf13/afero"
1413
"github.com/spf13/cobra"
1514
"github.com/spf13/cobra/doc"
1615
"golang.org/x/term"
@@ -26,7 +25,7 @@ import (
2625
"github.com/Benjamin-Connelly/lookit/internal/web"
2726
)
2827

29-
var version = "v0.3.1"
28+
var version = "v0.3.2"
3029

3130
var cfg *config.Config
3231

@@ -664,36 +663,40 @@ func runRemote(target *remote.Target) error {
664663

665664
fmt.Fprintf(os.Stderr, "Connected to %s. Indexing...\n", resolved.Display())
666665

667-
// Build afero filesystem: SFTP reads cached in memory with 30s TTL
666+
// Use SFTP filesystem directly (no CacheOnReadFs — its directory
667+
// handling adds excessive round trips over SSH)
668668
sftpFs := remote.NewSFTPFs(conn.SFTP())
669-
cachedFs := afero.NewCacheOnReadFs(sftpFs, afero.NewMemMapFs(), 30*time.Second)
670669

671-
// Resolve root: if target is a file, use parent dir and pre-select
670+
// Resolve root: if target is a file, build a single-entry index
671+
// instead of walking the parent directory (which could be huge)
672672
root := resolved.Path
673673
var initialFile string
674-
if info, err := cachedFs.Stat(root); err == nil && !info.IsDir() {
675-
initialFile = filepath.Base(root)
676-
root = filepath.Dir(root)
677-
}
674+
var idx *index.Index
678675

679-
// Build index directly from remote filesystem
680-
idx := index.NewWithFs(root, cachedFs)
681-
if err := idx.Build(); err != nil {
682-
return fmt.Errorf("building index: %w", err)
676+
info, err := sftpFs.Stat(root)
677+
if err != nil {
678+
return fmt.Errorf("stat remote path: %w", err)
683679
}
684680

685-
// Build fulltext search index (in-memory for remote)
686-
if err := idx.BuildFulltext(""); err != nil {
687-
fmt.Fprintf(os.Stderr, "warning: fulltext index unavailable: %v\n", err)
681+
if !info.IsDir() {
682+
// Single file: create index with just this entry
683+
initialFile = filepath.Base(root)
684+
root = filepath.Dir(root)
685+
idx = index.NewWithFs(root, sftpFs)
686+
idx.AddFile(resolved.Path, initialFile, info.Size(), info.ModTime())
687+
} else {
688+
// Directory: walk via SFTP
689+
idx = index.NewWithFs(root, sftpFs)
690+
if err := idx.Build(); err != nil {
691+
return fmt.Errorf("building index: %w", err)
692+
}
688693
}
689-
defer idx.CloseFulltext()
690694

691695
links := index.NewLinkGraph()
692-
links.BuildFromIndex(idx)
693696

694697
fmt.Fprintf(os.Stderr, "Ready. Starting TUI...\n")
695698

696-
// Create TUI with remote info
699+
// Create TUI with remote info (fulltext + link graph build in background)
697700
model := tui.New(cfg, idx, links)
698701
if initialFile != "" {
699702
model.SelectFile(initialFile)
@@ -703,11 +706,16 @@ func runRemote(target *remote.Target) error {
703706
State: conn.State().String(),
704707
})
705708

706-
// Background poller: rebuild index periodically to detect remote changes
709+
// Background: build fulltext + link graph, then poll for changes
707710
done := make(chan struct{})
708711
defer close(done)
712+
defer idx.CloseFulltext()
709713
lastRefresh := time.Now()
710714
go func() {
715+
// Build link graph and fulltext in background (reads files over SFTP)
716+
links.BuildFromIndex(idx)
717+
_ = idx.BuildFulltext("")
718+
711719
ticker := time.NewTicker(1 * time.Second)
712720
defer ticker.Stop()
713721
for {
@@ -735,7 +743,7 @@ func runRemote(target *remote.Target) error {
735743
}()
736744

737745
p := tea.NewProgram(model, tea.WithAltScreen())
738-
_, err := p.Run()
746+
_, err = p.Run()
739747
return err
740748
}
741749

internal/index/index.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@ var hiddenDirs = map[string]bool{
105105
".git": true, ".hg": true, ".svn": true, ".bzr": true,
106106
}
107107

108+
// FastWalker is an optional interface that filesystems can implement
109+
// for optimized directory traversal (e.g., SFTP uses ReadDir instead
110+
// of per-file Stat calls).
111+
type FastWalker interface {
112+
Walk(root string, fn func(path string, info os.FileInfo, err error) error) error
113+
}
114+
108115
// Build walks the root directory and populates the index.
109116
func (idx *Index) Build() error {
110117
idx.mu.Lock()
@@ -116,7 +123,7 @@ func (idx *Index) Build() error {
116123

117124
gitignore := loadGitignore(idx.fs, idx.root)
118125

119-
err := afero.Walk(idx.fs, idx.root, func(path string, info os.FileInfo, err error) error {
126+
walkFn := func(path string, info os.FileInfo, err error) error {
120127
if err != nil {
121128
return nil
122129
}
@@ -175,7 +182,16 @@ func (idx *Index) Build() error {
175182
}
176183

177184
return nil
178-
})
185+
}
186+
187+
// Use fast walker if available (e.g., SFTP ReadDir is one round
188+
// trip per directory vs per-file Stat calls in afero.Walk)
189+
var err error
190+
if fw, ok := idx.fs.(FastWalker); ok {
191+
err = fw.Walk(idx.root, walkFn)
192+
} else {
193+
err = afero.Walk(idx.fs, idx.root, walkFn)
194+
}
179195
if err != nil {
180196
return err
181197
}
@@ -241,6 +257,25 @@ func (idx *Index) Root() string {
241257
return idx.root
242258
}
243259

260+
// AddFile adds a single file entry to the index without walking.
261+
// The path must be absolute; relPath is relative to the index root.
262+
func (idx *Index) AddFile(absPath, relPath string, size int64, modTime time.Time) {
263+
idx.mu.Lock()
264+
defer idx.mu.Unlock()
265+
266+
entry := FileEntry{
267+
Path: absPath,
268+
RelPath: relPath,
269+
Size: size,
270+
ModTime: modTime,
271+
IsMarkdown: isMarkdown(filepath.Base(relPath)),
272+
}
273+
idx.entries = append(idx.entries, entry)
274+
idx.byPath[relPath] = &idx.entries[len(idx.entries)-1]
275+
idx.stats.FileCount++
276+
idx.stats.TotalSize += size
277+
}
278+
244279
// Stats returns aggregate statistics about the index.
245280
func (idx *Index) Stats() Stats {
246281
idx.mu.RLock()

internal/remote/sftpfs.go

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"errors"
55
"io"
66
"os"
7+
"path/filepath"
8+
"strings"
79
"time"
810

911
"github.com/pkg/sftp"
@@ -141,5 +143,51 @@ func (d *sftpDir) Seek(offset int64, whence int) (int64, error) { return 0, erro
141143
func (d *sftpDir) Write(p []byte) (int, error) { return 0, errReadOnly }
142144
func (d *sftpDir) WriteAt(p []byte, off int64) (int, error) { return 0, errReadOnly }
143145
func (d *sftpDir) WriteString(s string) (ret int, err error) { return 0, errReadOnly }
144-
func (d *sftpDir) Sync() error { return nil }
145-
func (d *sftpDir) Truncate(size int64) error { return errReadOnly }
146+
func (d *sftpDir) Sync() error { return nil }
147+
func (d *sftpDir) Truncate(size int64) error { return errReadOnly }
148+
149+
// WalkFunc is the callback for Walk.
150+
type WalkFunc func(path string, info os.FileInfo, err error) error
151+
152+
// Walk traverses the directory tree rooted at root using SFTP ReadDir
153+
// (one round trip per directory) instead of individual Stat calls.
154+
func (s *SFTPFs) Walk(root string, fn WalkFunc) error {
155+
info, err := s.client.Stat(root)
156+
if err != nil {
157+
return fn(root, nil, err)
158+
}
159+
return s.walk(root, info, fn)
160+
}
161+
162+
func (s *SFTPFs) walk(path string, info os.FileInfo, fn WalkFunc) error {
163+
if !info.IsDir() {
164+
return fn(path, info, nil)
165+
}
166+
167+
if err := fn(path, info, nil); err != nil {
168+
if err == filepath.SkipDir {
169+
return nil
170+
}
171+
return err
172+
}
173+
174+
entries, err := s.client.ReadDir(path)
175+
if err != nil {
176+
return fn(path, info, err)
177+
}
178+
179+
for _, entry := range entries {
180+
name := entry.Name()
181+
if strings.HasPrefix(name, ".") {
182+
continue
183+
}
184+
child := path + "/" + name
185+
if err := s.walk(child, entry, fn); err != nil {
186+
if err == filepath.SkipDir {
187+
continue
188+
}
189+
return err
190+
}
191+
}
192+
return nil
193+
}

0 commit comments

Comments
 (0)