package ui import ( "fmt" "os" "path/filepath" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) // FileBrowserMode selects between picking an existing path and choosing a save target. type FileBrowserMode int const ( FileBrowserLoad FileBrowserMode = iota FileBrowserSave ) // FileBrowserConfig configures the file browser dialog. type FileBrowserConfig struct { Mode FileBrowserMode StartPath string // current value of the input; may be empty Extensions []string // empty = show all files AllowDir bool // a directory is a valid result (directory-based formats) ReturnPage string // page to switch back to when the dialog closes OnSelect func(path string) } // attachFileBrowser makes Enter on the named input open the file browser, // filtered for the currently selected format. func (se *SchemaEditor) attachFileBrowser(form *tview.Form, label, returnPage string, mode FileBrowserMode, format func() string) { item, ok := form.GetFormItemByLabel(label).(*tview.InputField) if !ok { return } item.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() != tcell.KeyEnter { return event } f := format() se.showFileBrowser(FileBrowserConfig{ Mode: mode, StartPath: item.GetText(), Extensions: FormatExtensions(f), AllowDir: IsDirectoryFormat(f), ReturnPage: returnPage, OnSelect: func(path string) { item.SetText(path) }, }) return nil }) } // showFileBrowser displays the file browser page. Esc closes it without // calling OnSelect, leaving the originating input unchanged. func (se *SchemaEditor) showFileBrowser(cfg FileBrowserConfig) { const pageName = "file-browser" dir, startName := ResolveStart(cfg.StartPath) showHidden := false useFilter := len(cfg.Extensions) > 0 var entries []FileEntry // rows shown below the ".." row title := tview.NewTextView(). SetText("[::b]Select File"). SetTextAlign(tview.AlignCenter). SetDynamicColors(true) if cfg.Mode == FileBrowserSave { title.SetText("[::b]Save As") } info := tview.NewTextView().SetDynamicColors(true) fileTable := tview.NewTable().SetSelectable(true, false).SetFixed(0, 0) fileTable.SetBorder(true) nameInput := tview.NewInputField().SetLabel("File name: ").SetFieldWidth(0) nameInput.SetText(startName) closeBrowser := func() { se.pages.RemovePage(pageName) se.pages.SwitchToPage(cfg.ReturnPage) } finish := func(path string) { closeBrowser() cfg.OnSelect(path) } var refresh func() refresh = func() { exts := cfg.Extensions if !useFilter { exts = nil } list, err := ListDir(dir, exts, showHidden) if err != nil { se.showErrorDialog("Error", fmt.Sprintf("Cannot read %s: %v", dir, err)) list = nil } entries = list fileTable.Clear() fileTable.SetCell(0, 0, tview.NewTableCell("[..]").SetTextColor(tcell.ColorAqua)) for i, e := range entries { cell := tview.NewTableCell(e.Name) if e.IsDir { cell.SetText(e.Name + "/").SetTextColor(tcell.ColorAqua) } fileTable.SetCell(i+1, 0, cell) } fileTable.Select(0, 0) if len(entries) > 0 { fileTable.Select(1, 0) } filterText := "all files" if useFilter { filterText = fmt.Sprintf("%v", cfg.Extensions) } hiddenText := "hidden: off" if showHidden { hiddenText = "hidden: on" } info.SetText(fmt.Sprintf("%s [yellow](%s, filter: %s)[-]", tview.Escape(dir), hiddenText, tview.Escape(filterText))) fileTable.SetTitle(" Files ") } goUp := func() { parent := filepath.Dir(dir) if parent == dir { return } prev := filepath.Base(dir) dir = parent refresh() for i, e := range entries { if e.Name == prev { fileTable.Select(i+1, 0) break } } } selected := func() (FileEntry, bool) { row, _ := fileTable.GetSelection() if row < 1 || row > len(entries) { return FileEntry{}, false } return entries[row-1], true } // confirmOverwrite asks before replacing an existing file (not directories). confirmOverwrite := func(path string) { modal := tview.NewModal(). SetText(fmt.Sprintf("File already exists:\n%s\n\nOverwrite it?", path)). AddButtons([]string{"Cancel", "Overwrite"}). SetDoneFunc(func(_ int, label string) { se.pages.RemovePage("overwrite-confirm") se.pages.SwitchToPage(pageName) if label == "Overwrite" { finish(path) } }) modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyEscape { se.pages.RemovePage("overwrite-confirm") se.pages.SwitchToPage(pageName) return nil } return event }) se.pages.AddAndSwitchToPage("overwrite-confirm", modal, true) } chooseSave := func() { name := nameInput.GetText() if name == "" { if cfg.AllowDir { finish(dir) return } se.showErrorDialog("Error", "Enter a file name") return } path := filepath.Join(dir, name) if st, err := os.Stat(path); err == nil { if st.IsDir() { se.showErrorDialog("Error", name+" is a directory") return } confirmOverwrite(path) return } finish(path) } // chooseHighlighted handles Select: the highlighted entry in load mode, or // the typed name in save mode. chooseHighlighted := func() { if cfg.Mode == FileBrowserSave { chooseSave() return } e, ok := selected() switch { case ok && !e.IsDir: finish(filepath.Join(dir, e.Name)) case ok && cfg.AllowDir: finish(filepath.Join(dir, e.Name)) case cfg.AllowDir: finish(dir) default: se.showErrorDialog("Error", "Select a file") } } activate := func() { row, _ := fileTable.GetSelection() if row == 0 { goUp() return } e, ok := selected() if !ok { return } if e.IsDir { dir = filepath.Join(dir, e.Name) refresh() return } if cfg.Mode == FileBrowserSave { nameInput.SetText(e.Name) return } finish(filepath.Join(dir, e.Name)) } toggleHidden := func() { showHidden = !showHidden; refresh() } toggleFilter := func() { if len(cfg.Extensions) > 0 { useFilter = !useFilter refresh() } } btnSelect := tview.NewButton("Select [s]").SetSelectedFunc(chooseHighlighted) btnHidden := tview.NewButton("Hidden [h]").SetSelectedFunc(toggleHidden) btnFilter := tview.NewButton("Filter [f]").SetSelectedFunc(toggleFilter) btnBack := tview.NewButton("Back [b]").SetSelectedFunc(closeBrowser) btnFlex := tview.NewFlex(). AddItem(btnSelect, 0, 1, false). AddItem(btnHidden, 0, 1, false). AddItem(btnFilter, 0, 1, false). AddItem(btnBack, 0, 1, false) flex := tview.NewFlex().SetDirection(tview.FlexRow). AddItem(title, 1, 0, false). AddItem(info, 1, 0, false). AddItem(fileTable, 0, 1, true) focusOrder := []tview.Primitive{fileTable} if cfg.Mode == FileBrowserSave { flex.AddItem(nameInput, 1, 0, false) focusOrder = append(focusOrder, nameInput) } flex.AddItem(btnFlex, 1, 0, false) focusOrder = append(focusOrder, btnSelect, btnHidden, btnFilter, btnBack) // Circular Tab / Shift+Tab across every focusable widget. cycle := func(event *tcell.EventKey) *tcell.EventKey { step := 0 switch event.Key() { case tcell.KeyTab: step = 1 case tcell.KeyBacktab: step = -1 default: return event } for i, p := range focusOrder { if p.HasFocus() { se.app.SetFocus(focusOrder[(i+step+len(focusOrder))%len(focusOrder)]) break } } return nil } fileTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event = cycle(event); event == nil { return nil } switch event.Key() { case tcell.KeyEscape: closeBrowser() return nil case tcell.KeyEnter: activate() return nil case tcell.KeyBackspace, tcell.KeyBackspace2, tcell.KeyLeft: goUp() return nil } switch event.Rune() { case 's': chooseHighlighted() return nil case 'h': toggleHidden() return nil case 'f': toggleFilter() return nil case 'b': closeBrowser() return nil } return event }) nameInput.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event = cycle(event); event == nil { return nil } switch event.Key() { case tcell.KeyEscape: closeBrowser() return nil case tcell.KeyEnter: chooseSave() return nil } return event }) for _, b := range []*tview.Button{btnSelect, btnHidden, btnFilter, btnBack} { b.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event = cycle(event); event == nil { return nil } if event.Key() == tcell.KeyEscape { closeBrowser() return nil } return event }) } refresh() if startName != "" { for i, e := range entries { if e.Name == startName { fileTable.Select(i+1, 0) break } } } se.pages.AddAndSwitchToPage(pageName, flex, true) se.app.SetFocus(fileTable) }