Merge branch 'feat/filepicker-search'

This commit is contained in:
Grail Finder
2026-02-17 11:17:17 +03:00

154
tables.go
View File

@@ -789,17 +789,18 @@ func makeFilePicker() *tview.Flex {
var selectedFile string var selectedFile string
// Track currently displayed directory (changes as user navigates) // Track currently displayed directory (changes as user navigates)
currentDisplayDir := startDir currentDisplayDir := startDir
// --- NEW: search state ---
searching := false
searchQuery := ""
// Helper function to check if a file has an allowed extension from config // Helper function to check if a file has an allowed extension from config
hasAllowedExtension := func(filename string) bool { hasAllowedExtension := func(filename string) bool {
// If no allowed extensions are specified in config, allow all files
if cfg.FilePickerExts == "" { if cfg.FilePickerExts == "" {
return true return true
} }
// Split the allowed extensions from the config string
allowedExts := strings.Split(cfg.FilePickerExts, ",") allowedExts := strings.Split(cfg.FilePickerExts, ",")
lowerFilename := strings.ToLower(strings.TrimSpace(filename)) lowerFilename := strings.ToLower(strings.TrimSpace(filename))
for _, ext := range allowedExts { for _, ext := range allowedExts {
ext = strings.TrimSpace(ext) // Remove any whitespace around the extension ext = strings.TrimSpace(ext)
if ext != "" && strings.HasSuffix(lowerFilename, "."+ext) { if ext != "" && strings.HasSuffix(lowerFilename, "."+ext) {
return true return true
} }
@@ -844,12 +845,12 @@ func makeFilePicker() *tview.Flex {
flex := tview.NewFlex().SetDirection(tview.FlexRow) flex := tview.NewFlex().SetDirection(tview.FlexRow)
flex.AddItem(hFlex, 0, 3, true) flex.AddItem(hFlex, 0, 3, true)
flex.AddItem(statusView, 3, 0, false) flex.AddItem(statusView, 3, 0, false)
// Refresh the file list // Refresh the file list now accepts a filter string
var refreshList func(string) var refreshList func(string, string)
refreshList = func(dir string) { refreshList = func(dir string, filter string) {
listView.Clear() listView.Clear()
// Update the current display directory // Update the current display directory
currentDisplayDir = dir // Update the current display directory currentDisplayDir = dir
// Add exit option at the top // Add exit option at the top
listView.AddItem("Exit file picker [gray](Close without selecting)[-]", "", 'x', func() { listView.AddItem("Exit file picker [gray](Close without selecting)[-]", "", 'x', func() {
pages.RemovePage(filePickerPage) pages.RemovePage(filePickerPage)
@@ -857,14 +858,16 @@ func makeFilePicker() *tview.Flex {
// Add parent directory (..) if not at root // Add parent directory (..) if not at root
if dir != "/" { if dir != "/" {
parentDir := path.Dir(dir) parentDir := path.Dir(dir)
// Special handling for edge cases - only return if we're truly at a system root // For Unix-like systems, avoid infinite loop when at root
// For Unix-like systems, path.Dir("/") returns "/" which would cause parentDir == dir if parentDir != dir {
if parentDir == dir && dir == "/" {
// We're at the root ("/") and trying to go up, just don't add the parent item
} else {
listView.AddItem("../ [gray](Parent Directory)[-]", "", 'p', func() { listView.AddItem("../ [gray](Parent Directory)[-]", "", 'p', func() {
// Clear search on navigation
searching = false
searchQuery = ""
if cfg.ImagePreview {
imgPreview.SetImage(nil) imgPreview.SetImage(nil)
refreshList(parentDir) }
refreshList(parentDir, "")
dirStack = append(dirStack, parentDir) dirStack = append(dirStack, parentDir)
currentStackPos = len(dirStack) - 1 currentStackPos = len(dirStack) - 1
}) })
@@ -876,48 +879,66 @@ func makeFilePicker() *tview.Flex {
statusView.SetText("Error reading directory: " + err.Error()) statusView.SetText("Error reading directory: " + err.Error())
return 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 { for _, file := range files {
name := file.Name() name := file.Name()
// Skip hidden files and directories (those starting with a dot)
if strings.HasPrefix(name, ".") { if strings.HasPrefix(name, ".") {
continue continue
} }
if file.IsDir() { if file.IsDir() && matchesFilter(name) {
// Capture the directory name for the closure to avoid loop variable issues
dirName := name dirName := name
listView.AddItem(dirName+"/ [gray](Directory)[-]", "", 0, func() { listView.AddItem(dirName+"/ [gray](Directory)[-]", "", 0, func() {
// Clear search on navigation
searching = false
searchQuery = ""
if cfg.ImagePreview {
imgPreview.SetImage(nil) imgPreview.SetImage(nil)
}
newDir := path.Join(dir, dirName) newDir := path.Join(dir, dirName)
refreshList(newDir) refreshList(newDir, "")
dirStack = append(dirStack, newDir) dirStack = append(dirStack, newDir)
currentStackPos = len(dirStack) - 1 currentStackPos = len(dirStack) - 1
statusView.SetText("Current: " + newDir) 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 fileName := name
fullFilePath := path.Join(dir, fileName) fullFilePath := path.Join(dir, fileName)
listView.AddItem(fileName+" [gray](File)[-]", "", 0, func() { listView.AddItem(fileName+" [gray](File)[-]", "", 0, func() {
selectedFile = fullFilePath selectedFile = fullFilePath
statusView.SetText("Selected: " + selectedFile) statusView.SetText("Selected: " + selectedFile)
// Check if the file is an image
if isImageFile(fileName) { if isImageFile(fileName) {
// For image files, offer to attach to the next LLM message
statusView.SetText("Selected image: " + selectedFile) statusView.SetText("Selected image: " + selectedFile)
} else {
// For non-image files, display as before
statusView.SetText("Selected: " + selectedFile)
} }
}) })
} }
} }
// 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) statusView.SetText("Current: " + dir)
} }
}
// Initialize the file list // Initialize the file list
refreshList(startDir) refreshList(startDir, "")
// Update image preview when selection changes // Update image preview when selection changes (unchanged)
if cfg.ImagePreview && imgPreview != nil { if cfg.ImagePreview && imgPreview != nil {
listView.SetChangedFunc(func(index int, mainText, secondaryText string, rune rune) { listView.SetChangedFunc(func(index int, mainText, secondaryText string, rune rune) {
itemText, _ := listView.GetItemText(index) itemText, _ := listView.GetItemText(index)
@@ -954,6 +975,35 @@ func makeFilePicker() *tview.Flex {
} }
// Set up keyboard navigation // Set up keyboard navigation
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { 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() { switch event.Key() {
case tcell.KeyEsc: case tcell.KeyEsc:
pages.RemovePage(filePickerPage) pages.RemovePage(filePickerPage)
@@ -965,43 +1015,46 @@ func makeFilePicker() *tview.Flex {
if currentStackPos > 0 { if currentStackPos > 0 {
currentStackPos-- currentStackPos--
prevDir := dirStack[currentStackPos] prevDir := dirStack[currentStackPos]
refreshList(prevDir) // Clear search when navigating with backspace
// Trim the stack to current position to avoid deep history searching = false
searchQuery = ""
refreshList(prevDir, "")
// Trim the stack to current position
dirStack = dirStack[:currentStackPos+1] dirStack = dirStack[:currentStackPos+1]
} }
return nil return nil
case tcell.KeyRune:
if event.Rune() == '/' {
// Enter search mode
searching = true
searchQuery = ""
refreshList(currentDisplayDir, "")
return nil
}
case tcell.KeyEnter: case tcell.KeyEnter:
// Get the currently highlighted item in the list // Get the currently highlighted item in the list
itemIndex := listView.GetCurrentItem() itemIndex := listView.GetCurrentItem()
if itemIndex >= 0 && itemIndex < listView.GetItemCount() { 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) itemText, _ := listView.GetItemText(itemIndex)
logger.Info("choosing dir", "itemText", itemText) 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") { if strings.HasPrefix(itemText, "Exit file picker") {
pages.RemovePage(filePickerPage) pages.RemovePage(filePickerPage)
return nil return nil
} }
// Extract the actual filename/directory name by removing the type info in brackets // Extract the actual filename/directory name by removing the type info
// Format is "name [gray](type)[-]"
actualItemName := itemText actualItemName := itemText
if bracketPos := strings.Index(itemText, " ["); bracketPos != -1 { if bracketPos := strings.Index(itemText, " ["); bracketPos != -1 {
actualItemName = itemText[:bracketPos] actualItemName = itemText[:bracketPos]
} }
// Check if it's a directory (ends with /) // Check if it's a directory (ends with /)
if strings.HasSuffix(actualItemName, "/") { 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 var targetDir string
if strings.HasPrefix(actualItemName, "../") { if strings.HasPrefix(actualItemName, "../") {
// Parent directory - need to go up from current directory // Parent directory
targetDir = path.Dir(currentDisplayDir) targetDir = path.Dir(currentDisplayDir)
// Avoid going above root - if parent is same as current and it's system root
if targetDir == currentDisplayDir && currentDisplayDir == "/" { if targetDir == currentDisplayDir && currentDisplayDir == "/" {
// We're at root, don't navigate logger.Warn("at root, cannot go up")
logger.Warn("went to root", "dir", targetDir)
return nil return nil
} }
} else { } else {
@@ -1009,27 +1062,23 @@ func makeFilePicker() *tview.Flex {
dirName := strings.TrimSuffix(actualItemName, "/") dirName := strings.TrimSuffix(actualItemName, "/")
targetDir = path.Join(currentDisplayDir, dirName) targetDir = path.Join(currentDisplayDir, dirName)
} }
// Navigate to the selected directory // Navigate clear search
logger.Info("going to the dir", "dir", targetDir) logger.Info("going to dir", "dir", targetDir)
if cfg.ImagePreview && imgPreview != nil { if cfg.ImagePreview && imgPreview != nil {
imgPreview.SetImage(nil) imgPreview.SetImage(nil)
} }
refreshList(targetDir) searching = false
searchQuery = ""
refreshList(targetDir, "")
dirStack = append(dirStack, targetDir) dirStack = append(dirStack, targetDir)
currentStackPos = len(dirStack) - 1 currentStackPos = len(dirStack) - 1
statusView.SetText("Current: " + targetDir) statusView.SetText("Current: " + targetDir)
return nil return nil
} else { } else {
// It's a file - construct the full path from current directory and the actual item name // It's a file
// We can't rely only on the selectedFile variable since Enter key might be pressed
// without having clicked the file first
filePath := path.Join(currentDisplayDir, actualItemName) 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() { if info, err := os.Stat(filePath); err == nil && !info.IsDir() {
// Check if the file is an image
if isImageFile(actualItemName) { 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) logger.Info("setting image", "file", actualItemName)
SetImageAttachment(filePath) SetImageAttachment(filePath)
logger.Info("after setting image", "file", actualItemName) logger.Info("after setting image", "file", actualItemName)
@@ -1038,7 +1087,6 @@ func makeFilePicker() *tview.Flex {
pages.RemovePage(filePickerPage) pages.RemovePage(filePickerPage)
logger.Info("after update drawn", "file", actualItemName) logger.Info("after update drawn", "file", actualItemName)
} else { } else {
// For non-image files, update the text area with file path
textArea.SetText(filePath, true) textArea.SetText(filePath, true)
app.SetFocus(textArea) app.SetFocus(textArea)
pages.RemovePage(filePickerPage) pages.RemovePage(filePickerPage)