Feat: filepicker search

This commit is contained in:
Grail Finder
2026-02-17 11:16:52 +03:00
parent c83779b479
commit 475936fb1b
2 changed files with 116 additions and 136 deletions

View File

@@ -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.

186
tables.go
View File

@@ -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)