From fba54c589e95d3ded60c1f7deb2a304af2a9ba96 Mon Sep 17 00:00:00 2001 From: kjvjobin Date: Thu, 6 Nov 2025 21:22:49 +0530 Subject: [PATCH 1/3] fix(tui): allow Enter to select after filtering; Fixes #4 --- cmd/tui_picker.go | 121 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 92 insertions(+), 29 deletions(-) diff --git a/cmd/tui_picker.go b/cmd/tui_picker.go index a472ed7..0c24b40 100644 --- a/cmd/tui_picker.go +++ b/cmd/tui_picker.go @@ -14,9 +14,12 @@ import ( "k8s.io/client-go/kubernetes" ) -type podRow struct { - ns, name, ready, status, restarts, age string -} +type focusArea int + +const ( + focusTable focusArea = iota + focusFilter +) type pickModel struct { allPods []corev1.Pod @@ -24,19 +27,20 @@ type pickModel struct { filter textinput.Model selected *corev1.Pod termWidth, termHt int + focus focusArea } func newPickModel(pods []corev1.Pod) pickModel { - m := pickModel{allPods: pods} + m := pickModel{allPods: pods, focus: focusTable} - // ASCII prompt avoids width quirks in some fonts + // Filter input (ASCII prompt avoids width quirks) ti := textinput.New() - ti.Placeholder = "Press / to filter… (Enter to select, Esc to cancel)" + ti.Placeholder = "Press / to filter… (Enter applies; Enter again selects; Esc cancels)" ti.Prompt = "> " ti.Blur() m.filter = ti - // Seed; real sizing happens on first WindowSizeMsg + // Seed columns; will be resized on first WindowSizeMsg cols := []table.Column{ {Title: "NAMESPACE", Width: 12}, {Title: "NAME", Width: 24}, @@ -53,13 +57,12 @@ func newPickModel(pods []corev1.Pod) pickModel { table.WithHeight(10), ) - // ZERO padding/margins/borders so headers align perfectly + // Compact styles (no vertical padding so rows are tight) st := table.Styles{ Header: lipgloss.NewStyle().Bold(true), Cell: lipgloss.NewStyle(), - // Selected row style—no extra padding Selected: lipgloss.NewStyle(). - Background(lipgloss.Color("57")). // tweak if you like + Background(lipgloss.Color("57")). // purple Foreground(lipgloss.Color("230")), } t.SetStyles(st) @@ -116,46 +119,81 @@ func (m pickModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch k.String() { case "/": + m.focus = focusFilter m.filter.Focus() return m, nil + + case "tab": + // Toggle focus + if m.focus == focusFilter { + m.filter.Blur() + m.focus = focusTable + } else { + m.focus = focusFilter + m.filter.Focus() + } + return m, nil + case "esc": - if m.filter.Focused() { + // If filtering, blur (and keep current filtered rows) + if m.focus == focusFilter { m.filter.Blur() - m.filter.SetValue("") - m.applyFilter("") + m.focus = focusTable return m, nil } return m, tea.Quit + case "enter", "ctrl+j": - if m.filter.Focused() { + if m.focus == focusFilter { + // Apply filter first m.applyFilter(m.filter.Value()) + + // If exactly one row remains, auto-select it and quit + if len(m.table.Rows()) == 1 { + r := m.table.Rows()[0] + if pod, ok := m.findPod(r); ok { + m.selected = &pod + return m, tea.Quit + } + } + + // Otherwise blur filter and return focus to table; + // user can press Enter again to select the highlighted row. + m.filter.Blur() + m.focus = focusTable return m, nil } + + // Table-focused: select highlighted row if len(m.table.Rows()) == 0 { return m, nil } r := m.table.SelectedRow() - ns, name := r[0], r[1] - for i := range m.allPods { - if m.allPods[i].Namespace == ns && m.allPods[i].Name == name { - m.selected = &m.allPods[i] - break - } + if pod, ok := m.findPod(r); ok { + m.selected = &pod } return m, tea.Quit } } - var cmd tea.Cmd - m.table, cmd = m.table.Update(msg) + // Default updates + var cmds []tea.Cmd + + // Update table always (so arrows work even when filter focused off) + var tcmd tea.Cmd + m.table, tcmd = m.table.Update(msg) + cmds = append(cmds, tcmd) - if m.filter.Focused() { + // Update filter only when focused + if m.focus == focusFilter { var fcmd tea.Cmd m.filter, fcmd = m.filter.Update(msg) + // Live filtering: update rows as user types m.applyFilter(m.filter.Value()) - return m, tea.Batch(cmd, fcmd) + cmds = append(cmds, fcmd) } - return m, cmd + + return m, tea.Batch(cmds...) } func (m *pickModel) resize() { @@ -170,7 +208,7 @@ func (m *pickModel) resize() { m.table.SetHeight(tableHeight) m.table.SetWidth(m.termWidth) - // Fixed columns; give remainder to NAME. + // Column widths: fixed for most, NAME grows/shrinks wNS := 16 wReady := 5 wStatus := 18 @@ -182,6 +220,7 @@ func (m *pickModel) resize() { if wName < 10 { wName = 10 } + m.table.SetColumns([]table.Column{ {Title: "NAMESPACE", Width: wNS}, {Title: "NAME", Width: wName}, @@ -196,23 +235,47 @@ func (m *pickModel) applyFilter(q string) { q = strings.TrimSpace(strings.ToLower(q)) if q == "" { m.table.SetRows(rowsFromPods(m.allPods)) + // Keep cursor in-bounds + if len(m.table.Rows()) > 0 && m.table.Cursor() >= len(m.table.Rows()) { + m.table.SetCursor(len(m.table.Rows()) - 1) + } return } filtered := make([]corev1.Pod, 0, len(m.allPods)) for _, p := range m.allPods { s := strings.ToLower(strings.Join([]string{ - p.Namespace, p.Name, podStatus(&p), fmt.Sprintf("%v", p.Labels), + p.Namespace, + p.Name, + podStatus(&p), + fmt.Sprintf("%v", p.Labels), }, " ")) if strings.Contains(s, q) { filtered = append(filtered, p) } } m.table.SetRows(rowsFromPods(filtered)) + // Reset cursor to first result when the list changes + if len(m.table.Rows()) > 0 { + m.table.SetCursor(0) + } +} + +func (m pickModel) findPod(r table.Row) (corev1.Pod, bool) { + if len(r) < 2 { + return corev1.Pod{}, false + } + ns, name := r[0], r[1] + for i := range m.allPods { + if m.allPods[i].Namespace == ns && m.allPods[i].Name == name { + return m.allPods[i], true + } + } + return corev1.Pod{}, false } func (m pickModel) View() string { - // No extra blank lines; keeps header tight to first row - title := lipgloss.NewStyle().Bold(true).Render("Select a pod (↑/↓ or j/k to move, / to filter, Enter to select, Esc to cancel)") + title := lipgloss.NewStyle().Bold(true). + Render("Select a pod (↑/↓ or j/k move, / filter, Enter selects, Tab switch, Esc cancel)") return title + "\n" + m.table.View() + "\n" + m.filter.View() } From 64b67f60078a765b4a73e3ee42e2d04d8e1827a8 Mon Sep 17 00:00:00 2001 From: kjvjobin Date: Thu, 6 Nov 2025 21:26:29 +0530 Subject: [PATCH 2/3] docs: add v0.1.1 notes for filter/select fix (refs #4) --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f1019c5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +## v0.1.1 — TUI filter/select fix +- Fix: After filtering pods, pressing Enter now selects correctly. + - Enter applies filter; if one result, auto-selects; otherwise returns focus to table. + - Tab toggles focus; Esc cancels filter. +- Improves overall UX without changing keybindings for table navigation. From 10aa6c2f24167dc76a42e8b8fccca6c4a8485475 Mon Sep 17 00:00:00 2001 From: kjvjobin Date: Thu, 6 Nov 2025 21:37:20 +0530 Subject: [PATCH 3/3] adds(auto-tag): based on labels --- .github/workflows/auto-tag.yml | 100 +++++++++++++++++++++++++++++++++ cmd/kubeutil.go | 4 +- cmd/root.go | 2 +- 3 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/auto-tag.yml diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml new file mode 100644 index 0000000..f471101 --- /dev/null +++ b/.github/workflows/auto-tag.yml @@ -0,0 +1,100 @@ +name: Auto tag from PR labels + +on: + pull_request: + types: [closed] + branches: [main] + +permissions: + contents: write + +jobs: + tag: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # need full history & tags + + - name: Fetch tags + run: git fetch --tags --force + + - name: Determine bump from PR labels + id: bump + shell: bash + env: + PR_LABELS: ${{ toJson(github.event.pull_request.labels) }} + run: | + # Collect label names (lowercase) + labels=$(echo "$PR_LABELS" | jq -r '.[].name' | tr '[:upper:]' '[:lower:]') + echo "PR labels: $labels" + + level="patch" # default if none present + if echo "$labels" | grep -q '\bmajor\b'; then + level="major" + elif echo "$labels" | grep -q '\bminor\b'; then + level="minor" + elif echo "$labels" | grep -q '\bpatch\b'; then + level="patch" + fi + + echo "level=$level" >> "$GITHUB_OUTPUT" + echo "Selected bump: $level" + + - name: Compute next tag + id: next + shell: bash + env: + LEVEL: ${{ steps.bump.outputs.level }} + run: | + # Latest semver tag (vX.Y.Z). If none, seed at v0.1.0 + latest="$(git tag --list 'v*.*.*' | sort -V | tail -n1)" + if [[ -z "$latest" ]]; then + latest="v0.1.0" + echo "No previous tag; starting at $latest" + fi + ver="${latest#v}" + IFS='.' read -r MA MI PA <<<"$ver" + + case "$LEVEL" in + major) MA=$((MA+1)); MI=0; PA=0 ;; + minor) MI=$((MI+1)); PA=0 ;; + patch) PA=$((PA+1)) ;; + *) PA=$((PA+1)) ;; + esac + + next="v${MA}.${MI}.${PA}" + echo "latest=$latest" + echo "next=$next" + echo "next=$next" >> "$GITHUB_OUTPUT" + + - name: Tag and push + env: + NEXT: ${{ steps.next.outputs.next }} + run: | + if git rev-parse -q --verify "refs/tags/${NEXT}" >/dev/null; then + echo "Tag ${NEXT} already exists; nothing to do." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$NEXT" -m "chore(release): $NEXT" + git push origin "$NEXT" + + # Optional: comment back on the PR with the new version + - name: Comment with result + if: ${{ always() }} + uses: actions/github-script@v7 + with: + script: | + const next = core.getInput('next', { required: false }) || '${{ steps.next.outputs.next }}'; + const level = '${{ steps.bump.outputs.level }}'; + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: `🔖 Released **${next}** (bump: \`${level}\`).` + }); + result-encoding: string diff --git a/cmd/kubeutil.go b/cmd/kubeutil.go index d68f56a..3592961 100644 --- a/cmd/kubeutil.go +++ b/cmd/kubeutil.go @@ -2,10 +2,10 @@ package cmd import ( "fmt" - "os" - "path/filepath" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/clientcmd" + "os" + "path/filepath" ) func kubeconfigPath() string { diff --git a/cmd/root.go b/cmd/root.go index af05e1d..f313fd0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,7 +27,7 @@ var rootCmd = &cobra.Command{ `, RunE: func(cmd *cobra.Command, args []string) error { if flagVersion { - fmt.Println("kdebug v0.1.0") + fmt.Println("kdebug v0.1.1") return nil } return runInteractive()