Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ Press `/` to activate fuzzy filter mode. The filter uses fuzzy matching to filte

## Keyboard Shortcuts

> **Going back vs. quitting:** `Esc` or lowercase `q` always mean **go back / cancel**
> the current view or dialog — they never close the application. Only **capital `Q`**
> (or `Ctrl+C`) quits telekube. This keeps a stray `q` from killing the app, and gives
> you a reliable way out of a view in terminals where `Esc` is delayed (e.g. tmux).

### Navigation

| Key | Action |
Expand All @@ -170,7 +175,7 @@ Press `/` to activate fuzzy filter mode. The filter uses fuzzy matching to filte
| `Enter` | View resource details (table format) |
| `Y` | View as YAML |
| `J` | View as JSON |
| `Esc` | Return to list view |
| `Esc` / `q` | Return to list view (does not quit) |

### Actions

Expand Down Expand Up @@ -211,7 +216,8 @@ Press `/` to activate fuzzy filter mode. The filter uses fuzzy matching to filte
| `/` | Search/filter resources |
| `r` | Refresh current view |
| `?` | Show help |
| `q` | Quit |
| `Esc` / `q` | Go back / cancel (never quits) |
| `Q` / `Ctrl+C` | Quit the application |

## Detail View

Expand All @@ -225,7 +231,8 @@ When viewing resource details (`Enter`, `Y`, or `J`), use these keys to navigate
| `u` | Scroll half page up |
| `g` | Go to top |
| `G` | Go to bottom |
| `Esc` / `q` | Return to list |
| `Esc` / `q` | Return to list (goes back; does not quit) |
| `Q` / `Ctrl+C` | Quit the application |

## Configuration

Expand All @@ -240,7 +247,7 @@ pager: "less"

# Custom keybindings
keybindings:
quit: "q"
quit: "Q" # capital Q quits the app; Esc / lowercase q go back
help: "?"
refresh: "r"
search: "/"
Expand Down
6 changes: 3 additions & 3 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ func TestDefaultConfig(t *testing.T) {
}

// Check some keybindings
if cfg.Keybindings.Quit != "q" {
t.Errorf("expected quit keybinding to be 'q', got '%s'", cfg.Keybindings.Quit)
if cfg.Keybindings.Quit != "Q" {
t.Errorf("expected quit keybinding to be 'Q', got '%s'", cfg.Keybindings.Quit)
}

if cfg.Keybindings.Help != "?" {
Expand Down Expand Up @@ -148,7 +148,7 @@ custom_commands:
}

// Check that defaults are preserved for unspecified keybindings
if cfg.Keybindings.Quit != "q" {
if cfg.Keybindings.Quit != "Q" {
t.Errorf("expected default quit keybinding to be preserved, got '%s'", cfg.Keybindings.Quit)
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ func DefaultConfig() *Config {
RefreshInterval: 5 * time.Second,
LogsFollowInNewShell: true,
Keybindings: Keybindings{
Quit: "q",
Quit: "Q",
Help: "?",
Refresh: "r",
Search: "/",
Expand Down
63 changes: 42 additions & 21 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@
detail *detail.Model
confirm *dialog.ConfirmModel
selector *dialog.SelectorModel
input *dialog.InputModel

Check failure on line 154 in internal/tui/app.go

View workflow job for this annotation

GitHub Actions / Lint

field `input` is unused (unused)
copySelector *dialog.MultiSelectorModel

// Pending action state
Expand All @@ -164,7 +164,7 @@
currentContext string
currentNamespace string
resources *kubectl.TableData
selectedIndex int

Check failure on line 167 in internal/tui/app.go

View workflow job for this annotation

GitHub Actions / Lint

field `selectedIndex` is unused (unused)

// Search tab state
searchInput *dialog.InputModel
Expand Down Expand Up @@ -359,18 +359,23 @@
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q":
case m.config.Keybindings.Quit, "ctrl+c":
// The quit key (capital Q by default) and Ctrl+C quit the whole
// application, even from a detail view.
return m, tea.Quit
case "esc", "q":
// Both Esc and lowercase q return to the list in a single press.
// Any search highlight is discarded on the way out (an *open*
// search bar is handled above by detail.IsSearching(), where these
// keys interact with the input instead). Lowercase q is safe here:
// it only goes back, it never quits the application (that is the
// quit key's job). Keeping q available gives a reliable exit in
// terminals/multiplexers where a lone Esc is delayed or swallowed
// (e.g. tmux's escape-time), which is why the Esc hint alone was
// not enough.
m.detail.ClearSearch()
m.viewState = ViewList
return m, nil
case "esc":
// If a search query is active, clear it first; otherwise go back
if m.detail.HasSearchQuery() {
m.detail.ClearSearch()
} else {
m.viewState = ViewList
}
return m, nil
case "/":
m.detail.StartSearch()
return m, nil
Expand Down Expand Up @@ -502,17 +507,29 @@

switch msg := msg.(type) {
case tea.KeyMsg:
// When the help overlay is open it captures input: Esc or ? closes it,
// and other keys are ignored so list actions don't fire behind it.
// Ctrl+C still quits from anywhere.
if m.helpShown {
switch msg.String() {
case m.config.Keybindings.Quit, "ctrl+c":
// The quit key (capital Q) and Ctrl+C quit the app from anywhere.
return m, tea.Quit
case "esc", "q", "?":
// Esc, lowercase q, or ? close the help overlay (they do not quit).
m.helpShown = false
}
return m, nil
}
switch msg.String() {
case "?":
// Toggle help view (works from any state)
// Toggle help view
m.helpShown = !m.helpShown
return m, nil
case "q", "ctrl+c":
if m.helpShown {
// When help is shown, q just closes it
m.helpShown = false
return m, nil
}
case m.config.Keybindings.Quit, "ctrl+c":
// Only the quit key (capital Q by default) and Ctrl+C quit the
// application. Lowercase q never quits — it is a safe back key in the
// other views.
return m, tea.Quit
case "esc":
// Clear an active filter (confirmed via Enter)
Expand Down Expand Up @@ -872,6 +889,7 @@
// Footer/help
b.WriteString("\n\n")
deleteTabKey := m.config.Keybindings.DeleteTab
quitKey := m.config.Keybindings.Quit
if m.loading {
b.WriteString(m.spinner.View() + " Loading...")
} else if m.search.IsActive() {
Expand All @@ -886,9 +904,9 @@
} else if m.search.IsFiltered() {
b.WriteString(wrapAtWidth(fmt.Sprintf("[Esc] clear filter [/] modify filter [d]escribe [L]ogs [D]elete [e]dit [T]erminal [+]new tab [%s]delete tab", deleteTabKey), m.width))
} else if m.currentTab == SearchTabIndex {
b.WriteString(wrapAtWidth(fmt.Sprintf("[Enter] enter command [/]filter results [r]efresh [q]uit [+]new tab [%s]delete tab", deleteTabKey), m.width))
b.WriteString(wrapAtWidth(fmt.Sprintf("[Enter] enter command [/]filter results [r]efresh [%s]uit [+]new tab [%s]delete tab", quitKey, deleteTabKey), m.width))
} else {
b.WriteString(wrapAtWidth(fmt.Sprintf("[d]escribe [L]ogs [Y]aml [D]elete [e]dit [T]erminal [c]ontext [n]amespace [s]ort [/]search [r]efresh [?]help [+]new tab [%s]delete tab", deleteTabKey), m.width))
b.WriteString(wrapAtWidth(fmt.Sprintf("[d]escribe [L]ogs [Y]aml [D]elete [e]dit [T]erminal [c]ontext [n]amespace [s]ort [/]search [r]efresh [?]help [%s]uit [+]new tab [%s]delete tab", quitKey, deleteTabKey), m.width))
}

return b.String()
Expand All @@ -914,7 +932,7 @@
b.WriteString("VIEWS\n")
b.WriteString(" Y View as YAML\n")
b.WriteString(" J View as JSON\n")
b.WriteString(" Esc Return to list view\n\n")
b.WriteString(" Esc / q Return to list view (q here goes back, not quit)\n\n")

b.WriteString("TAB EDITING\n")
b.WriteString(" Enter Edit current tab command\n\n")
Expand Down Expand Up @@ -944,9 +962,10 @@
b.WriteString(" / Search/filter resources\n")
b.WriteString(" r Refresh current view\n")
b.WriteString(" ? Toggle this help view\n")
b.WriteString(" q / Ctrl+C Quit\n\n")
b.WriteString(" Esc / q Go back / cancel (never quits)\n")
b.WriteString(" Q / Ctrl+C Quit the application\n\n")

b.WriteString("Press [?] to close this help view\n")
b.WriteString("Press [Esc], [q] or [?] to close this help view\n")

return b.String()
}
Expand Down Expand Up @@ -1049,7 +1068,7 @@
func (m *Model) loadResourceDetail(format detail.Format) tea.Cmd {
return func() tea.Msg {
selected := m.list.SelectedItem()
if selected == nil || len(selected) == 0 {

Check failure on line 1071 in internal/tui/app.go

View workflow job for this annotation

GitHub Actions / Lint

S1009: should omit nil check; len() for nil slices is defined as zero (gosimple)
return ErrorMsg{Err: nil}
}

Expand Down Expand Up @@ -1321,6 +1340,7 @@
m.detail.SetContent("Delete Result", strings.Join(messages, "\n"), detail.FormatTable)
m.detail.SetSize(m.width, m.height-2)
m.viewState = ViewDetail
m.loading = false

return nil
}
Expand Down Expand Up @@ -1596,7 +1616,7 @@
}

// rolloutRestart triggers a rollout restart
func (m *Model) rolloutRestart() tea.Cmd {

Check failure on line 1619 in internal/tui/app.go

View workflow job for this annotation

GitHub Actions / Lint

func `(*Model).rolloutRestart` is unused (unused)
return func() tea.Msg {
names, namespace := m.getSelectedResourceInfo()
if len(names) == 0 {
Expand All @@ -1619,6 +1639,7 @@
m.detail.SetContent("Rollout Restart", output, detail.FormatTable)
m.detail.SetSize(m.width, m.height-2)
m.viewState = ViewDetail
m.loading = false

return nil
}
Expand Down
136 changes: 136 additions & 0 deletions internal/tui/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,146 @@ package tui
import (
"testing"

tea "github.com/charmbracelet/bubbletea"
"github.com/clobrano/telekube/internal/config"
"github.com/clobrano/telekube/internal/kubectl"
"github.com/clobrano/telekube/internal/tui/components/detail"
"github.com/clobrano/telekube/internal/tui/components/list"
)

func newTestModel() *Model {
return NewModel(config.DefaultConfig(), kubectl.NewKubectl("kubectl"), "")
}

// TestDetailEscExitsAfterSearch reproduces the regression where a result view
// (e.g. "Delete Result") would not respond to Esc because search state left over
// from a previous detail view was absorbing the key. Esc must reliably return to
// the list in a single press.
func TestDetailEscExitsAfterSearch(t *testing.T) {
m := newTestModel()

// View a YAML detail and run a search that leaves a confirmed query.
m.detail.SetContent("pod-x", "apiVersion: v1\nkind: Pod", detail.FormatYAML)
m.viewState = ViewDetail
m.detail.StartSearch()
for _, r := range "pod" {
m.detail, _ = m.detail.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
}
m.detail, _ = m.detail.Update(tea.KeyMsg{Type: tea.KeyEnter})

// Now show a delete result on the same detail model, as executeDelete does.
m.detail.SetContent("Delete Result", "Deleted computeinstance 'x'.", detail.FormatTable)
m.viewState = ViewDetail
m.loading = true

// A single Esc must exit back to the list.
upd, _ := m.Update(tea.KeyMsg{Type: tea.KeyEscape})
m = upd.(*Model)

if m.viewState != ViewList {
t.Fatalf("viewState after Esc = %v, want ViewList", m.viewState)
}
}

// TestDetailEscExitsSinglePress verifies a plain detail view exits on the first
// Esc even when a search highlight is active within that same view.
func TestDetailEscExitsSinglePress(t *testing.T) {
m := newTestModel()
m.detail.SetContent("pod-x", "line one\nline two", detail.FormatYAML)
m.viewState = ViewDetail

// Confirm a search query within this view.
m.detail.StartSearch()
for _, r := range "line" {
m.detail, _ = m.detail.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
}
m.detail, _ = m.detail.Update(tea.KeyMsg{Type: tea.KeyEnter})

if !m.detail.HasSearchQuery() {
t.Fatal("precondition: expected an active search query")
}

// One Esc exits; it does not merely clear the highlight and stay.
upd, _ := m.Update(tea.KeyMsg{Type: tea.KeyEscape})
m = upd.(*Model)

if m.viewState != ViewList {
t.Fatalf("viewState after Esc = %v, want ViewList", m.viewState)
}
}

// TestDetailQExits verifies q also returns to the list from a detail/result
// view (a reliable exit where a lone Esc is delayed, e.g. tmux) and that it
// does NOT quit the application.
func TestDetailQExits(t *testing.T) {
m := newTestModel()
m.detail.SetContent("Delete Result", "Deleted computeinstance 'x'.", detail.FormatTable)
m.viewState = ViewDetail

upd, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
m = upd.(*Model)

if m.viewState != ViewList {
t.Fatalf("viewState after q = %v, want ViewList", m.viewState)
}
if cmd != nil {
// tea.Quit is a non-nil command; q must not quit from the detail view.
if msg := cmd(); msg != nil {
if _, isQuit := msg.(tea.QuitMsg); isQuit {
t.Fatal("q quit the application from the detail view; it must only go back")
}
}
}
}

// TestListCapitalQQuits verifies capital Q quits from the list view.
func TestListCapitalQQuits(t *testing.T) {
m := newTestModel()
m.viewState = ViewList

_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'Q'}})
if cmd == nil {
t.Fatal("Q in list view returned nil cmd, want tea.Quit")
}
if msg := cmd(); msg == nil {
t.Fatal("Q in list view produced nil msg, want tea.QuitMsg")
} else if _, isQuit := msg.(tea.QuitMsg); !isQuit {
t.Fatalf("Q in list view produced %T, want tea.QuitMsg", msg)
}
}

// TestListLowerQDoesNotQuit verifies lowercase q never quits the application.
func TestListLowerQDoesNotQuit(t *testing.T) {
m := newTestModel()
m.viewState = ViewList

_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
if cmd != nil {
if msg := cmd(); msg != nil {
if _, isQuit := msg.(tea.QuitMsg); isQuit {
t.Fatal("lowercase q quit the application from the list view; only Q must quit")
}
}
}
}

// TestDetailCapitalQQuits verifies capital Q quits directly from a detail view.
func TestDetailCapitalQQuits(t *testing.T) {
m := newTestModel()
m.detail.SetContent("pod-x", "apiVersion: v1", detail.FormatYAML)
m.viewState = ViewDetail

_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'Q'}})
if cmd == nil {
t.Fatal("Q in detail view returned nil cmd, want tea.Quit")
}
if msg := cmd(); msg == nil {
t.Fatal("Q in detail view produced nil msg, want tea.QuitMsg")
} else if _, isQuit := msg.(tea.QuitMsg); !isQuit {
t.Fatalf("Q in detail view produced %T, want tea.QuitMsg", msg)
}
}

// newModelForTest builds a minimal Model wired with the given table data so the
// describe-identifier helpers can be exercised without kubectl.
func newModelForTest(data *kubectl.TableData) *Model {
Expand Down
10 changes: 7 additions & 3 deletions internal/tui/components/detail/detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,16 @@ func New(resourceName, content string, format Format) *Model {
}
}

// SetContent updates the content and format
// SetContent updates the content and format. It also resets any search state
// so a freshly opened view never inherits a stale search from a previous one
// (a lingering search query or an open search bar would otherwise absorb the
// Esc key and prevent the view from closing).
func (m *Model) SetContent(resourceName, content string, format Format) {
m.resourceName = resourceName
m.content = content
m.format = format
m.scrollOffset = 0
m.ClearSearch()
}

// StartSearch activates the search input bar
Expand Down Expand Up @@ -401,9 +405,9 @@ func (m *Model) View() string {

// Header with resource name and format
headerText := fmt.Sprintf("%s - %s", m.resourceName, m.format.String())
hintText := "[Esc] Back [/] Search"
hintText := "[Esc/q] Back [/] Search [Q]uit"
if m.searchQuery != "" {
hintText = "[Esc] Back [/] Search [n/N] Next/Prev"
hintText = "[Esc/q] Back [/] Search [n/N] Next/Prev [Q]uit"
}
hint := m.styles.Hint.Render(hintText)
headerPadding := m.width - lipgloss.Width(headerText) - lipgloss.Width(hint) - 4
Expand Down
Loading
Loading