blob: fc8d0f33493cbf8e11243432262ac184482f78c2 [file]
package browser
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"regexp"
"sort"
"strings"
"time"
"golang.org/x/term"
)
const (
// ANSI escape sequences for terminal manipulation.
ansiReset = "\x1b[0m"
ansiBoldCyan = "\x1b[1;36m"
ansiBoldYellow = "\x1b[1;33m"
ansiEraseLine = "\x1b[K" // Erase from cursor to end of line
ansiClearLine = "\r" + ansiEraseLine // Return cursor and erase entire line
ansiClearDisplay = "\x1b[J" // Erase from cursor to end of screen
ansiCursorUpFmt = "\x1b[%dA" // Format string to move cursor up %d lines
)
// FdReader combines io.Reader with an Fd() method, which is implemented
// by standard OS files like os.Stdin.
type FdReader interface {
io.Reader
Fd() uintptr
}
// PrefixReader is an optional interface that fs.FS can be implemented to
// improve efficiency for on-the-fly limited prefix directory searches.
// This is crucial for GCS, is a flat filesystem where directory structures are
// simulated, to avoid listing millions of files every time the user presses a
// key.
//
// ReadDirPrefix performs a prefix-based search, finding all immediate
// child files and subdirectories that match the specified prefix.
//
// For example, given a GCS bucket or filesystem containing:
// - "foo/bar-file.txt"
// - "foo/bar-sub/nested.txt"
// - "foo/baz.txt"
//
// Calling ReadDirPrefix("foo/bar", 10) should return:
// 1. An fs.DirEntry representing the file:
// - Name(): "bar-file.txt"
// - IsDir(): false
// 2. An fs.DirEntry representing the subfolder:
// - Name(): "bar-sub"
// - IsDir(): true
//
// The file "foo/baz.txt" is omitted because it does not match the prefix, and
// "foo/bar-sub/nested.txt" is omitted because it is not an immediate child of
// "foo/".
type PrefixReader interface {
ReadDirPrefix(prefix string, limit int) ([]fs.DirEntry, error)
}
// Browser encapsulates the state of the interactive directory explorer.
type Browser struct {
Fsys fs.FS
In FdReader
Out io.Writer
}
// New creates a new Browser instance configured with os.Stdin and os.Stdout.
func New(fsys fs.FS) *Browser {
return &Browser{
Fsys: fsys,
In: os.Stdin,
Out: os.Stdout,
}
}
// Browse launches a dynamic, interactive terminal-based directory browser
// starting at the given startPrefix over the filesystem. It returns the
// selected object's full path name or ErrUserCanceled if canceled.
func Browse(ctx context.Context, fsys fs.FS, startPrefix string) (string, error) {
return New(fsys).Browse(ctx, startPrefix)
}
// Browse launches a dynamic, interactive terminal-based directory browser
// starting at the given startPrefix over the filesystem. It returns the
// selected object's full path name or ErrUserCanceled if canceled.
func (b *Browser) Browse(ctx context.Context, startPrefix string) (string, error) {
return b.browsePrefixInteractive(ctx, startPrefix)
}
// ErrUserCanceled is returned if the user canceled browsing.
var ErrUserCanceled = errors.New("user canceled browsing")
func makeRaw(fd int) (*term.State, error) {
if !term.IsTerminal(fd) {
return nil, nil
}
return term.MakeRaw(fd)
}
func restore(fd int, state *term.State) error {
if state == nil {
return nil
}
return term.Restore(fd, state)
}
type keyType int
const (
keyUnknown keyType = iota
keyUp
keyDown
keyLeft
keyRight
keyEnter
keyTab
keyQuit
keyBackspace
)
type browseEntry struct {
Name string
DisplayName string
IsDir bool
Size int64
Updated time.Time
}
func (b *Browser) readKey() (keyType, rune, string, error) {
var buf [256]byte
n, err := b.In.Read(buf[:])
if err != nil {
return keyUnknown, 0, "", err
}
if n == 0 {
return keyUnknown, 0, "", nil
}
if n > 1 {
// Check if it's an arrow key escape sequence (3 bytes)
if n == 3 && buf[0] == 27 && buf[1] == '[' {
switch buf[2] {
case 'A':
return keyUp, 0, "", nil
case 'B':
return keyDown, 0, "", nil
case 'C':
return keyRight, 0, "", nil
case 'D':
return keyLeft, 0, "", nil
}
}
// Otherwise, it represents pasted text or quick successive key presses!
var pasted strings.Builder
for i := 0; i < n; i++ {
b := buf[i]
if b >= 32 && b <= 126 {
pasted.WriteByte(b)
}
}
pastedStr := pasted.String()
if len(pastedStr) > 0 {
return keyUnknown, 0, pastedStr, nil
}
return keyUnknown, 0, "", nil
}
raw := buf[0]
switch raw {
case 3: // Ctrl+C (ETX)
return keyQuit, 0, "", nil
case '\r', '\n':
return keyEnter, 0, "", nil
case '\t':
return keyTab, 0, "", nil
case 127, 8: // Backspace or Ctrl+H
return keyBackspace, 0, "", nil
}
return keyUnknown, rune(raw), "", nil
}
func (b *Browser) fetchFolderEntries(prefix, subPrefix string, limit int, cache map[string]*browseEntry, cachedPrefixes map[string]bool) error {
fullPrefix := prefix + subPrefix
// 1. Check if we can short-circuit
for cachedPref, fullyLoaded := range cachedPrefixes {
if fullyLoaded {
if strings.HasPrefix(subPrefix, cachedPref) {
return nil
}
}
}
// 2. Check if we already queried this exact subPrefix
if _, exists := cachedPrefixes[subPrefix]; exists {
return nil
}
// 3. Query the filesystem
var dirEntries []fs.DirEntry
var err error
fullyLoaded := true
if pr, ok := b.Fsys.(PrefixReader); ok {
dirEntries, err = pr.ReadDirPrefix(fullPrefix, limit)
if err != nil {
return err
}
if len(dirEntries) >= limit {
fullyLoaded = false
}
} else {
readPath := strings.TrimSuffix(prefix, "/")
if readPath == "" {
readPath = "."
}
dirEntries, err = fs.ReadDir(b.Fsys, readPath)
if err != nil {
return err
}
}
// Process dirEntries into our cache
for _, entry := range dirEntries {
name := entry.Name()
if prefix != "" {
name = prefix + name
}
if entry.IsDir() && !strings.HasSuffix(name, "/") {
name += "/"
}
if entry.Name() == ".." || entry.Name() == "" {
continue
}
info, err := entry.Info()
var size int64
var updated time.Time
if err == nil {
size = info.Size()
updated = info.ModTime()
}
cache[name] = &browseEntry{
Name: name,
DisplayName: entry.Name(),
IsDir: entry.IsDir(),
Size: size,
Updated: updated,
}
}
cachedPrefixes[subPrefix] = fullyLoaded
return nil
}
var dateRegex = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}`)
func looksLikeDate(s string) bool {
return dateRegex.MatchString(s)
}
func (b *Browser) browsePrefixInteractive(ctx context.Context, prefix string) (string, error) {
cache := make(map[string]*browseEntry)
cachedPrefixes := make(map[string]bool)
err := b.fetchFolderEntries(prefix, "", 150, cache, cachedPrefixes)
if err != nil {
return "", err
}
if prefix == "" && len(cache) == 0 {
return "", nil
}
pageSize := 15
page := 0
selectedOnPage := 0
if prefix != "" {
selectedOnPage = 1
}
searchBuf := ""
// Put terminal in raw mode if Out is configured
var oldState *term.State
var fd int
fd = int(b.In.Fd())
oldState, err = makeRaw(fd)
if err != nil {
return "", err
}
defer func() {
_ = restore(fd, oldState)
}()
cleanup := func() {
_ = restore(fd, oldState)
// Move the cursor up by (pageSize + 4) lines and wipe everything from
// the cursor to the end of the screen to cleanly clear the browser menu.
_, _ = fmt.Fprintf(b.Out, ansiCursorUpFmt+ansiClearDisplay, pageSize+4)
}
firstDraw := true
var numLines int
for {
// Build and filter active list of entries from our cache
var entries []*browseEntry
for _, entry := range cache {
if entry.DisplayName == ".." {
continue
}
if strings.HasPrefix(strings.ToLower(entry.DisplayName), strings.ToLower(searchBuf)) {
entries = append(entries, entry)
}
}
// Sort entries: Directories first, then Files
sort.Slice(entries, func(i, j int) bool {
if entries[i].IsDir && !entries[j].IsDir {
return true
}
if !entries[i].IsDir && entries[j].IsDir {
return false
}
if entries[i].IsDir && entries[j].IsDir {
iDate := looksLikeDate(entries[i].DisplayName)
jDate := looksLikeDate(entries[j].DisplayName)
if iDate && jDate {
return entries[i].DisplayName > entries[j].DisplayName
}
if iDate && !jDate {
return true // Dates come before non-dates
}
if !iDate && jDate {
return false // Non-dates come after dates
}
return entries[i].DisplayName < entries[j].DisplayName
}
return entries[i].Updated.After(entries[j].Updated)
})
// Prepend '..' parent directory if we are not at the root
if prefix != "" {
trimmed := strings.TrimSuffix(prefix, "/")
lastSlash := strings.LastIndex(trimmed, "/")
parentPrefix := ""
if lastSlash != -1 {
parentPrefix = trimmed[:lastSlash+1]
}
entries = append([]*browseEntry{{
Name: parentPrefix,
DisplayName: "..",
IsDir: true,
}}, entries...)
}
start := page * pageSize
if start >= len(entries) {
start = 0
page = 0
}
end := start + pageSize
if end > len(entries) {
end = len(entries)
}
numItemsOnPage := end - start
if selectedOnPage >= numItemsOnPage {
selectedOnPage = numItemsOnPage - 1
}
if selectedOnPage < 0 {
selectedOnPage = 0
}
if !firstDraw {
_, _ = fmt.Fprintf(b.Out, ansiCursorUpFmt, numLines)
} else {
// Pre-allocate maximum possible vertical space once at start to avoid screen scrolls during loop
maxMenuLines := pageSize + 4
for i := 0; i < maxMenuLines; i++ {
_, _ = fmt.Fprint(b.Out, "\r\n")
}
_, _ = fmt.Fprintf(b.Out, ansiCursorUpFmt, maxMenuLines)
numLines = maxMenuLines
}
firstDraw = false
// Print Header
header := fmt.Sprintf("--- Folder: %s (Page %d/%d) ---", prefix, page+1, (len(entries)+pageSize-1)/pageSize)
if prefix == "" {
header = fmt.Sprintf("--- Folder: [FS Root] (Page %d/%d) ---", page+1, (len(entries)+pageSize-1)/pageSize)
}
_, _ = fmt.Fprintf(b.Out, ansiClearLine+"%s\r\n", header)
// Print Items
for i := start; i < end; i++ {
entry := entries[i]
idxOnPage := i - start
prefixChar := " "
if idxOnPage == selectedOnPage {
prefixChar = "> "
}
var lineText string
if entry.IsDir {
lineText = entry.DisplayName
} else {
sizeKB := float64(entry.Size) / 1024.0
lineText = fmt.Sprintf("%-50s (%.1f KB)", entry.DisplayName, sizeKB)
}
if idxOnPage == selectedOnPage {
_, _ = fmt.Fprintf(b.Out, ansiClearLine+"%s"+ansiBoldCyan+"%s"+ansiReset+"\r\n", prefixChar, lineText)
} else {
_, _ = fmt.Fprintf(b.Out, ansiClearLine+"%s%s\r\n", prefixChar, lineText)
}
}
// Empty lines if the items on the page are fewer than the pageSize, to
// keep visual consistency and heights stable.
for i := numItemsOnPage; i < pageSize; i++ {
_, _ = fmt.Fprint(b.Out, ansiClearLine+"\r\n")
}
// Print Footer and Navigation hints
_, _ = fmt.Fprint(b.Out, ansiClearLine+"------------------------------------------------------------\r\n")
navHint := "Arrow Keys [Up/Down]: Navigate [Left/Right]: Prev/Next Page"
_, _ = fmt.Fprintf(b.Out, ansiClearLine+"%s\r\n", navHint)
searchPrompt := "<type to filter>"
if searchBuf != "" {
searchPrompt = searchBuf
}
_, _ = fmt.Fprintf(b.Out, ansiClearLine+"Search: "+ansiBoldYellow+"%s"+ansiReset+"█ [Enter]: Select [Backspace]: Back/Delete [Ctrl+C]: Quit\r\n", searchPrompt)
key, char, pastedStr, err := b.readKey()
if err != nil {
return "", err
}
if key == keyQuit {
cleanup()
return "", ErrUserCanceled
}
searchChanged := false
switch key {
case keyUp:
if selectedOnPage > 0 {
selectedOnPage--
} else {
selectedOnPage = numItemsOnPage - 1 // Wrap around
}
case keyDown:
if selectedOnPage < numItemsOnPage-1 {
selectedOnPage++
} else {
selectedOnPage = 0 // Wrap around
}
case keyLeft:
if page > 0 {
page--
selectedOnPage = 0
}
case keyRight:
if end < len(entries) {
page++
selectedOnPage = 0
}
case keyBackspace:
if len(searchBuf) > 0 {
searchBuf = searchBuf[:len(searchBuf)-1]
searchChanged = true
} else {
// Backspacing on an empty search acts as "Go Back"
cleanup()
return "", nil
}
case keyEnter, keyTab:
selectedIdx := start + selectedOnPage
if selectedIdx >= 0 && selectedIdx < len(entries) {
entry := entries[selectedIdx]
if entry.IsDir {
cleanup()
// If they select '..', go back up
if entry.DisplayName == ".." {
return "", nil
}
// Recursively call browser on the subfolder
subObj, err := b.browsePrefixInteractive(ctx, entry.Name)
if err != nil {
return "", err
}
if subObj != "" {
return subObj, nil // Bubble up the selected object path!
}
// If subfolder was cancelled, re-enter raw mode and repaint current folder
oldState, err = makeRaw(fd)
if err != nil {
return "", err
}
firstDraw = true // Trigger full repaint and pre-allocation
} else {
cleanup()
return entry.Name, nil
}
}
default:
if pastedStr != "" {
searchBuf += pastedStr
searchChanged = true
} else if char >= 32 && char <= 126 {
searchBuf += string(char)
searchChanged = true
}
}
if searchChanged {
if searchBuf != "" {
err := b.fetchFolderEntries(prefix, searchBuf, 150, cache, cachedPrefixes)
if err != nil {
return "", err
}
}
page = 0
selectedOnPage = 0
if prefix != "" {
selectedOnPage = 1
}
}
numLines = pageSize + 4
}
}