diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 3bff59e..17c7f21 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -52,11 +52,11 @@ func DefaultKeyMap() KeyMap { ), Left: key.NewBinding( key.WithKeys("left"), - key.WithHelp("←", "prev tab"), + key.WithHelp("←", "back/prev tab"), ), Right: key.NewBinding( key.WithKeys("right"), - key.WithHelp("→", "next tab"), + key.WithHelp("→", "open/next tab"), ), Enter: key.NewBinding( key.WithKeys("enter"), @@ -128,7 +128,7 @@ func DefaultKeyMap() KeyMap { ), Cancel: key.NewBinding( key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), + key.WithHelp("esc", "cancel/quit"), ), Help: key.NewBinding( key.WithKeys("?"), @@ -143,15 +143,15 @@ func DefaultKeyMap() KeyMap { // ShortHelp returns keybindings for the short help view func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Help, k.Quit} + return []key.Binding{k.Help, k.Cancel, k.Quit} } // FullHelp returns keybindings for the expanded help view func (k KeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ - {k.Up, k.Down, k.Enter, k.Back}, + {k.Up, k.Down, k.Right, k.Left}, {k.Tab, k.Buckets, k.Browser, k.Bookmarks}, {k.Download, k.Sync, k.AddBookmark, k.Refresh}, - {k.Help, k.Quit}, + {k.Help, k.Cancel, k.Quit}, } } diff --git a/internal/tui/update.go b/internal/tui/update.go index 2ae5c62..39b0a50 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -42,14 +42,30 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.showHelp = !m.showHelp return m, nil - case key.Matches(msg, m.keys.Tab), key.Matches(msg, m.keys.Right): + case key.Matches(msg, m.keys.Tab): m.nextView() return m, nil - case key.Matches(msg, m.keys.ShiftTab), key.Matches(msg, m.keys.Left): + case key.Matches(msg, m.keys.ShiftTab): m.prevView() return m, nil + case key.Matches(msg, m.keys.Right): + // In the browser, → opens a folder (handled by the view). Elsewhere + // it switches to the next tab. + if m.activeView != ViewBrowser { + m.nextView() + return m, nil + } + + case key.Matches(msg, m.keys.Left): + // In the browser, ← goes to the parent folder (handled by the view). + // Elsewhere it switches to the previous tab. + if m.activeView != ViewBrowser { + m.prevView() + return m, nil + } + case key.Matches(msg, m.keys.Buckets): m.activeView = ViewBuckets return m, nil @@ -63,17 +79,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case key.Matches(msg, m.keys.Cancel): + // Cancel an in-progress download first. if m.activeView == ViewDownload && m.downloadView.IsActive() { if m.downloadMgr != nil { m.downloadMgr.Cancel() } return m, nil } - // Close help if open + // Close help if open. if m.showHelp { m.showHelp = false return m, nil } + // If the active view is filtering, let it handle Esc (cancel filter) + // instead of quitting. + if m.isActiveViewFiltering() { + break + } + // Otherwise Esc quits the app. + m.cancel() + return m, tea.Quit case key.Matches(msg, m.keys.Refresh): return m.handleRefresh() @@ -266,6 +291,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } +// isActiveViewFiltering reports whether the currently active list view is in +// filter-input mode, in which case Esc should cancel the filter rather than +// quit the app. +func (m Model) isActiveViewFiltering() bool { + switch m.activeView { + case ViewProfiles: + return m.profilesView.IsFiltering() + case ViewBuckets: + return m.bucketsView.IsFiltering() + case ViewBrowser: + return m.browserView.IsFiltering() + case ViewBookmarks: + return m.bookmarksView.IsFiltering() + } + return false +} + func (m *Model) nextView() { switch m.activeView { case ViewBuckets: @@ -298,6 +340,7 @@ func (m Model) handleRefresh() (tea.Model, tea.Cmd) { m.bucketsView.SetLoading(true) return m, m.loadBuckets() case ViewBrowser: + m.browserView.RememberCursor() m.browserView.SetLoading(true) return m, m.loadObjects() case ViewBookmarks: diff --git a/internal/views/bookmarksview/bookmarks.go b/internal/views/bookmarksview/bookmarks.go index 843cb90..1438fc0 100644 --- a/internal/views/bookmarksview/bookmarks.go +++ b/internal/views/bookmarksview/bookmarks.go @@ -74,6 +74,12 @@ func (m *Model) SetSize(width, height int) { m.list.SetSize(width, height) } +// IsFiltering reports whether a filter is active (being typed or applied), so +// callers can let the list handle Esc (cancel/clear filter) instead of quitting. +func (m Model) IsFiltering() bool { + return m.list.FilterState() != list.Unfiltered +} + // SetStore sets the bookmark store func (m *Model) SetStore(store *bookmarks.Store) { m.store = store diff --git a/internal/views/browser/browser.go b/internal/views/browser/browser.go index 03c9d92..61537d7 100644 --- a/internal/views/browser/browser.go +++ b/internal/views/browser/browser.go @@ -71,6 +71,10 @@ type Model struct { width int height int + // cursorByPrefix remembers the cursor position for each visited prefix so + // navigating back (or refreshing) restores where the user was. + cursorByPrefix map[string]int + // Multi-select selected map[string]bool // map of Key -> selected @@ -102,9 +106,10 @@ func New() Model { Padding(0, 1) return Model{ - list: l, - history: []string{}, - selected: make(map[string]bool), + list: l, + history: []string{}, + selected: make(map[string]bool), + cursorByPrefix: make(map[string]int), } } @@ -115,12 +120,25 @@ func (m *Model) SetSize(width, height int) { m.list.SetSize(width, height-2) // Reserve space for path } +// IsFiltering reports whether a filter is active (being typed or applied), so +// callers can let the list handle Esc (cancel/clear filter) instead of quitting. +func (m Model) IsFiltering() bool { + return m.list.FilterState() != list.Unfiltered +} + +// RememberCursor saves the current cursor position for the current prefix so a +// subsequent reload (e.g. refresh) can restore it. +func (m *Model) RememberCursor() { + m.cursorByPrefix[m.prefix] = m.list.Index() +} + // SetBucket sets the current bucket func (m *Model) SetBucket(bucket string) { m.bucket = bucket m.prefix = "" m.history = []string{} m.selected = make(map[string]bool) // Clear selection + m.cursorByPrefix = make(map[string]int) m.updateTitle() } @@ -141,6 +159,17 @@ func (m *Model) SetObjects(objects []aws.S3Object) { items[i] = Item{object: obj, selected: false} } m.list.SetItems(items) + + // Restore the remembered cursor position for this prefix. + if idx, ok := m.cursorByPrefix[m.prefix]; ok && len(objects) > 0 { + if idx >= len(objects) { + idx = len(objects) - 1 + } + if idx < 0 { + idx = 0 + } + m.list.Select(idx) + } } // SetError sets an error state @@ -201,10 +230,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } return m, nil - case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): + case key.Matches(msg, key.NewBinding(key.WithKeys("enter", "right"))): if item, ok := m.list.SelectedItem().(Item); ok { if item.object.IsPrefix { - // Navigate into prefix + // Remember where we are, then navigate into the prefix + m.cursorByPrefix[m.prefix] = m.list.Index() m.history = append(m.history, m.prefix) m.prefix = item.object.Key m.selectedObject = item.object @@ -214,8 +244,9 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } } - case key.Matches(msg, key.NewBinding(key.WithKeys("backspace"))): + case key.Matches(msg, key.NewBinding(key.WithKeys("backspace", "left"))): if len(m.history) > 0 { + m.cursorByPrefix[m.prefix] = m.list.Index() m.prefix = m.history[len(m.history)-1] m.history = m.history[:len(m.history)-1] m.action = ActionBack @@ -223,11 +254,15 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { return m, nil } else if m.prefix != "" { // Go back to bucket root + m.cursorByPrefix[m.prefix] = m.list.Index() m.prefix = "" m.action = ActionBack m.updateTitle() return m, nil } + // At bucket root with no history: swallow so it doesn't fall + // through (the global handler already declined to switch tabs). + return m, nil case key.Matches(msg, key.NewBinding(key.WithKeys("d"))): // Download selected items, or current item if none selected diff --git a/internal/views/buckets/buckets.go b/internal/views/buckets/buckets.go index ca9f20a..56c7c7c 100644 --- a/internal/views/buckets/buckets.go +++ b/internal/views/buckets/buckets.go @@ -76,6 +76,12 @@ func (m *Model) SetSize(width, height int) { m.list.SetSize(width, height) } +// IsFiltering reports whether a filter is active (being typed or applied), so +// callers can let the list handle Esc (cancel/clear filter) instead of quitting. +func (m Model) IsFiltering() bool { + return m.list.FilterState() != list.Unfiltered +} + // SetBuckets updates the bucket list func (m *Model) SetBuckets(buckets []aws.Bucket) { m.buckets = buckets diff --git a/internal/views/profiles/profiles.go b/internal/views/profiles/profiles.go index 43848c9..a87d0b1 100644 --- a/internal/views/profiles/profiles.go +++ b/internal/views/profiles/profiles.go @@ -72,6 +72,12 @@ func (m *Model) SetSize(width, height int) { m.list.SetSize(width, height) } +// IsFiltering reports whether a filter is active (being typed or applied), so +// callers can let the list handle Esc (cancel/clear filter) instead of quitting. +func (m Model) IsFiltering() bool { + return m.list.FilterState() != list.Unfiltered +} + // LoadProfiles loads available AWS profiles func (m *Model) LoadProfiles() error { profiles, err := aws.ListProfiles()