summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGrail Finder <wohilas@gmail.com>2026-02-17 11:16:52 +0300
committerGrail Finder <wohilas@gmail.com>2026-02-17 11:16:52 +0300
commit475936fb1b2200c600869ba71c45057a6424252d (patch)
treeab7bf743995c5054abf2b625f5f0a4ac4fd1298d
parentc83779b4796bdb3e42b43cdeffa1f4dba2014de3 (diff)
Feat: filepicker search
-rw-r--r--docs/filepicker-search.md66
-rw-r--r--tables.go186
2 files changed, 116 insertions, 136 deletions
diff --git a/docs/filepicker-search.md b/docs/filepicker-search.md
deleted file mode 100644
index e595c7a..0000000
--- a/docs/filepicker-search.md
+++ /dev/null
@@ -1,66 +0,0 @@
-# Filepicker Search Implementation - Notes
-
-## Goal
-Add `/` key functionality in filepicker (Ctrl+O) to filter/search files by name, similar to how `/` works in the main TUI textview.
-
-## Requirements
-- Press `/` to activate search mode
-- Live case-insensitive filtering
-- `../` (parent directory) always visible
-- Show "No matching files" when nothing matches
-- Esc to cancel (return to main app for sending messages)
-- Enter to confirm search and close search input
-
-## Approaches Tried
-
-### Approach 1: Modify Flex Layout In-Place
-Add search input to the existing flex container by replacing listView with searchInput.
-
-**Issues:**
-- tview's `RemoveItem`/`AddItem` causes UI freezes/hangs
-- Using `app.QueueUpdate` or `app.Draw` didn't help
-- Layout changes don't render properly
-
-### Approach 2: Add Input Capture to ListView
-Handle `/` key in listView's SetInputCapture.
-
-**Issues:**
-- Key events don't reach listView when filepicker is open
-- Global app input capture handles `/` for main textview search first
-- Even when checking `pages.GetFrontPage()`, the key isn't captured
-
-### Approach 3: Global Handler with Page Replacement
-Handle `/` in global app input capture when filepicker page is frontmost.
-
-**Issues:**
-- Search input appears but text is invisible (color issues)
-- Enter/Esc not handled - main TUI captures them
-- Creating new pages adds on top instead of replacing, causing split-screen effect
-- Enter on file item opens new filepicker (page stacking issue)
-
-### Approach 4: Overlay Page (Modal-style)
-Create a new flex with search input on top and filepicker below, replace the page.
-
-**Issues:**
-- Page replacement causes split-screen between main app and filepicker
-- Search input renders but invisible text
-- Enter/Esc handled by main TUI, not search input
-- State lost when recreating filepicker
-
-## Root Causes
-
-1. **tview UI update issues**: Direct manipulation of flex items causes freezes or doesn't render
-2. **Input capture priority**: Even with page overlay, main TUI's global input capture processes keys first
-3. **Esc key conflict**: Esc is used for sending messages in main TUI, and it's hard to distinguish when filepicker is open
-4. **Focus management**: tview's focus system doesn't work as expected with dynamic layouts
-
-## Possible Solutions (Not Tried)
-
-1. **Use tview's built-in Filter method**: ListView has a SetFilterFunc that might work
-2. **Create separate search primitive**: Instead of replacing list, use a separate text input overlay
-3. **Different key for search**: Use a key that isn't already mapped in main TUI
-4. **Fork/extend tview**: May need to modify tview itself for better dynamic UI updates
-5. **Use form with text input**: tview.Forms might handle input better
-
-## Current State
-All search-related changes rolled back. Filepicker works as before without search functionality.
diff --git a/tables.go b/tables.go
index e4066c8..239811c 100644
--- a/tables.go
+++ b/tables.go
@@ -789,17 +789,18 @@ func makeFilePicker() *tview.Flex {
var selectedFile string
// Track currently displayed directory (changes as user navigates)
currentDisplayDir := startDir
+ // --- NEW: search state ---
+ searching := false
+ searchQuery := ""
// Helper function to check if a file has an allowed extension from config
hasAllowedExtension := func(filename string) bool {
- // If no allowed extensions are specified in config, allow all files
if cfg.FilePickerExts == "" {
return true
}
- // Split the allowed extensions from the config string
allowedExts := strings.Split(cfg.FilePickerExts, ",")
lowerFilename := strings.ToLower(strings.TrimSpace(filename))
for _, ext := range allowedExts {
- ext = strings.TrimSpace(ext) // Remove any whitespace around the extension
+ ext = strings.TrimSpace(ext)
if ext != "" && strings.HasSuffix(lowerFilename, "."+ext) {
return true
}
@@ -844,12 +845,12 @@ func makeFilePicker() *tview.Flex {
flex := tview.NewFlex().SetDirection(tview.FlexRow)
flex.AddItem(hFlex, 0, 3, true)
flex.AddItem(statusView, 3, 0, false)
- // Refresh the file list
- var refreshList func(string)
- refreshList = func(dir string) {
+ // Refresh the file list – now accepts a filter string
+ var refreshList func(string, string)
+ refreshList = func(dir string, filter string) {
listView.Clear()
// Update the current display directory
- currentDisplayDir = dir // Update the current display directory
+ currentDisplayDir = dir
// Add exit option at the top
listView.AddItem("Exit file picker [gray](Close without selecting)[-]", "", 'x', func() {
pages.RemovePage(filePickerPage)
@@ -857,14 +858,16 @@ func makeFilePicker() *tview.Flex {
// Add parent directory (..) if not at root
if dir != "/" {
parentDir := path.Dir(dir)
- // Special handling for edge cases - only return if we're truly at a system root
- // For Unix-like systems, path.Dir("/") returns "/" which would cause parentDir == dir
- if parentDir == dir && dir == "/" {
- // We're at the root ("/") and trying to go up, just don't add the parent item
- } else {
+ // For Unix-like systems, avoid infinite loop when at root
+ if parentDir != dir {
listView.AddItem("../ [gray](Parent Directory)[-]", "", 'p', func() {
- imgPreview.SetImage(nil)
- refreshList(parentDir)
+ // Clear search on navigation
+ searching = false
+ searchQuery = ""
+ if cfg.ImagePreview {
+ imgPreview.SetImage(nil)
+ }
+ refreshList(parentDir, "")
dirStack = append(dirStack, parentDir)
currentStackPos = len(dirStack) - 1
})
@@ -876,48 +879,66 @@ func makeFilePicker() *tview.Flex {
statusView.SetText("Error reading directory: " + err.Error())
return
}
- // Add directories and files to the list
+ // Helper to check if an item passes the filter
+ matchesFilter := func(name string) bool {
+ if filter == "" {
+ return true
+ }
+ return strings.Contains(strings.ToLower(name), strings.ToLower(filter))
+ }
+ // Add directories
for _, file := range files {
name := file.Name()
- // Skip hidden files and directories (those starting with a dot)
if strings.HasPrefix(name, ".") {
continue
}
- if file.IsDir() {
- // Capture the directory name for the closure to avoid loop variable issues
+ if file.IsDir() && matchesFilter(name) {
dirName := name
listView.AddItem(dirName+"/ [gray](Directory)[-]", "", 0, func() {
- imgPreview.SetImage(nil)
+ // Clear search on navigation
+ searching = false
+ searchQuery = ""
+ if cfg.ImagePreview {
+ imgPreview.SetImage(nil)
+ }
newDir := path.Join(dir, dirName)
- refreshList(newDir)
+ refreshList(newDir, "")
dirStack = append(dirStack, newDir)
currentStackPos = len(dirStack) - 1
statusView.SetText("Current: " + newDir)
})
- } else if hasAllowedExtension(name) {
- // Only show files that have allowed extensions (from config)
- // Capture the file name for the closure to avoid loop variable issues
+ }
+ }
+ // Add files with allowed extensions
+ for _, file := range files {
+ name := file.Name()
+ if strings.HasPrefix(name, ".") || file.IsDir() {
+ continue
+ }
+ if hasAllowedExtension(name) && matchesFilter(name) {
fileName := name
fullFilePath := path.Join(dir, fileName)
listView.AddItem(fileName+" [gray](File)[-]", "", 0, func() {
selectedFile = fullFilePath
statusView.SetText("Selected: " + selectedFile)
- // Check if the file is an image
if isImageFile(fileName) {
- // For image files, offer to attach to the next LLM message
statusView.SetText("Selected image: " + selectedFile)
- } else {
- // For non-image files, display as before
- statusView.SetText("Selected: " + selectedFile)
}
})
}
}
- statusView.SetText("Current: " + dir)
+ // Update status line based on search state
+ if searching {
+ statusView.SetText("Search: " + searchQuery + "_")
+ } else if searchQuery != "" {
+ statusView.SetText("Current: " + dir + " (filter: " + searchQuery + ")")
+ } else {
+ statusView.SetText("Current: " + dir)
+ }
}
// Initialize the file list
- refreshList(startDir)
- // Update image preview when selection changes
+ refreshList(startDir, "")
+ // Update image preview when selection changes (unchanged)
if cfg.ImagePreview && imgPreview != nil {
listView.SetChangedFunc(func(index int, mainText, secondaryText string, rune rune) {
itemText, _ := listView.GetItemText(index)
@@ -938,24 +959,51 @@ func makeFilePicker() *tview.Flex {
return
}
filePath := path.Join(currentDisplayDir, actualItemName)
- go func() {
- file, err := os.Open(filePath)
- if err != nil {
- app.QueueUpdate(func() { imgPreview.SetImage(nil) })
- return
- }
- defer file.Close()
- img, _, err := image.Decode(file)
- if err != nil {
- app.QueueUpdate(func() { imgPreview.SetImage(nil) })
- return
- }
- app.QueueUpdate(func() { imgPreview.SetImage(img) })
- }()
+ file, err := os.Open(filePath)
+ if err != nil {
+ imgPreview.SetImage(nil)
+ return
+ }
+ defer file.Close()
+ img, _, err := image.Decode(file)
+ if err != nil {
+ imgPreview.SetImage(nil)
+ return
+ }
+ imgPreview.SetImage(img)
})
}
// Set up keyboard navigation
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ // --- Handle search mode ---
+ if searching {
+ switch event.Key() {
+ case tcell.KeyEsc:
+ // Exit search, clear filter
+ searching = false
+ searchQuery = ""
+ refreshList(currentDisplayDir, "")
+ return nil
+ case tcell.KeyBackspace, tcell.KeyBackspace2:
+ if len(searchQuery) > 0 {
+ searchQuery = searchQuery[:len(searchQuery)-1]
+ refreshList(currentDisplayDir, searchQuery)
+ }
+ return nil
+ case tcell.KeyRune:
+ r := event.Rune()
+ if r != 0 {
+ searchQuery += string(r)
+ refreshList(currentDisplayDir, searchQuery)
+ }
+ return nil
+ default:
+ // Pass all other keys (arrows, Enter, etc.) to normal processing
+ // This allows selecting items while still in search mode
+ return event
+ }
+ }
+ // --- Not searching ---
switch event.Key() {
case tcell.KeyEsc:
pages.RemovePage(filePickerPage)
@@ -967,43 +1015,46 @@ func makeFilePicker() *tview.Flex {
if currentStackPos > 0 {
currentStackPos--
prevDir := dirStack[currentStackPos]
- refreshList(prevDir)
- // Trim the stack to current position to avoid deep history
+ // Clear search when navigating with backspace
+ searching = false
+ searchQuery = ""
+ refreshList(prevDir, "")
+ // Trim the stack to current position
dirStack = dirStack[:currentStackPos+1]
}
return nil
+ case tcell.KeyRune:
+ if event.Rune() == '/' {
+ // Enter search mode
+ searching = true
+ searchQuery = ""
+ refreshList(currentDisplayDir, "")
+ return nil
+ }
case tcell.KeyEnter:
// Get the currently highlighted item in the list
itemIndex := listView.GetCurrentItem()
if itemIndex >= 0 && itemIndex < listView.GetItemCount() {
- // We need to get the text of the currently selected item to determine if it's a directory
- // Since we can't directly get the item text, we'll keep track of items differently
- // Let's improve the approach by tracking the currently selected item
itemText, _ := listView.GetItemText(itemIndex)
logger.Info("choosing dir", "itemText", itemText)
- // Check for the exit option first (should be the first item)
+ // Check for the exit option first
if strings.HasPrefix(itemText, "Exit file picker") {
pages.RemovePage(filePickerPage)
return nil
}
- // Extract the actual filename/directory name by removing the type info in brackets
- // Format is "name [gray](type)[-]"
+ // Extract the actual filename/directory name by removing the type info
actualItemName := itemText
if bracketPos := strings.Index(itemText, " ["); bracketPos != -1 {
actualItemName = itemText[:bracketPos]
}
// Check if it's a directory (ends with /)
if strings.HasSuffix(actualItemName, "/") {
- // This is a directory, we need to get the full path
- // Since the item text ends with "/" and represents a directory
var targetDir string
if strings.HasPrefix(actualItemName, "../") {
- // Parent directory - need to go up from current directory
+ // Parent directory
targetDir = path.Dir(currentDisplayDir)
- // Avoid going above root - if parent is same as current and it's system root
if targetDir == currentDisplayDir && currentDisplayDir == "/" {
- // We're at root, don't navigate
- logger.Warn("went to root", "dir", targetDir)
+ logger.Warn("at root, cannot go up")
return nil
}
} else {
@@ -1011,27 +1062,23 @@ func makeFilePicker() *tview.Flex {
dirName := strings.TrimSuffix(actualItemName, "/")
targetDir = path.Join(currentDisplayDir, dirName)
}
- // Navigate to the selected directory
- logger.Info("going to the dir", "dir", targetDir)
+ // Navigate – clear search
+ logger.Info("going to dir", "dir", targetDir)
if cfg.ImagePreview && imgPreview != nil {
imgPreview.SetImage(nil)
}
- refreshList(targetDir)
+ searching = false
+ searchQuery = ""
+ refreshList(targetDir, "")
dirStack = append(dirStack, targetDir)
currentStackPos = len(dirStack) - 1
statusView.SetText("Current: " + targetDir)
return nil
} else {
- // It's a file - construct the full path from current directory and the actual item name
- // We can't rely only on the selectedFile variable since Enter key might be pressed
- // without having clicked the file first
+ // It's a file
filePath := path.Join(currentDisplayDir, actualItemName)
- // Verify it's actually a file (not just lacking a directory suffix)
if info, err := os.Stat(filePath); err == nil && !info.IsDir() {
- // Check if the file is an image
if isImageFile(actualItemName) {
- // For image files, set it as an attachment for the next LLM message
- // Use the version without UI updates to avoid hangs in event handlers
logger.Info("setting image", "file", actualItemName)
SetImageAttachment(filePath)
logger.Info("after setting image", "file", actualItemName)
@@ -1040,7 +1087,6 @@ func makeFilePicker() *tview.Flex {
pages.RemovePage(filePickerPage)
logger.Info("after update drawn", "file", actualItemName)
} else {
- // For non-image files, update the text area with file path
textArea.SetText(filePath, true)
app.SetFocus(textArea)
pages.RemovePage(filePickerPage)