Skip to content

Commit 4f9188e

Browse files
authored
navigate down/up with j/k (#468)
* Add Vim navigation to stack view * Advertise Vim navigation in stack modify * Add Vim navigation to stack checkout * Add Vim navigation to stack switch * Advertise Vim navigation in stack merge
1 parent ab00aa4 commit 4f9188e

14 files changed

Lines changed: 252 additions & 109 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,22 +255,22 @@ If the stack of PRs has been created on GitHub, run `gh stack submit` afterwards
255255
- **Fold down** (`d`): Absorb a branch's commits into the branch below (toward trunk). Folded branch removed from stack.
256256
- **Fold up** (`u`): Absorb a branch's commits into the branch above (away from trunk). Folded branch removed from stack.
257257
- **Insert** (`i`/`I`): Insert a new empty branch into the stack. `i` inserts below the cursor; `I` inserts above.
258-
- **Reorder** (`Shift+`/`Shift+`): Move a branch up (away from trunk) or down (toward trunk) in the stack.
258+
- **Reorder** (`Shift+`/`Shift+`): Move a branch down (toward trunk) or up (away from trunk) in the stack.
259259
- **Rename** (`r`): Rename a branch locally and in the stack metadata.
260260
- **Undo** (`z`): Undo the last staged action.
261261

262262
**Keybindings:**
263263

264264
| Key | Action |
265265
|-----|--------|
266-
| ``/`` | Navigate branch list |
266+
| ``/`` or `j`/`k` | Navigate branch list |
267267
| `f` | View files changed |
268268
| `c` | View commits |
269269
| `x` | Drop branch |
270270
| `r` | Rename branch |
271271
| `i/I` | Insert branch below/above |
272272
| `d/u` | Fold branch down/up |
273-
| `Shift+`/`Shift+` | Move branch up/down |
273+
| `Shift+`/`Shift+` | Move branch down/up |
274274
| `z` | Undo last action |
275275
| `Ctrl+S` | Apply all changes |
276276
| `q`/`Esc` | Cancel and exit |

cmd/switch.go

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package cmd
22

33
import (
44
"fmt"
5+
"strings"
56

6-
"github.com/cli/go-gh/v2/pkg/prompter"
7+
"github.com/AlecAivazis/survey/v2"
8+
"github.com/cli/go-gh/v2/pkg/text"
79
"github.com/github/gh-stack/internal/config"
810
"github.com/github/gh-stack/internal/git"
911
"github.com/spf13/cobra"
@@ -17,11 +19,11 @@ func SwitchCmd(cfg *config.Config) *cobra.Command {
1719
and switch to the selected one.
1820
1921
Branches are displayed from top (furthest from trunk) to bottom (closest to
20-
trunk) with their position number. Use the arrow keys to navigate and Enter
21-
to select.
22+
trunk) with their position number. Use the down/up arrow keys or j/k to
23+
navigate and Enter to select.
2224
23-
To move one branch up or down without an interactive picker, use
24-
'gh stack up' or 'gh stack down' instead.`,
25+
To move one branch down or up without an interactive picker, use
26+
'gh stack down' or 'gh stack up' instead.`,
2527
Example: ` # Open the branch picker for the current stack
2628
$ gh stack switch`,
2729
Args: cobra.NoArgs,
@@ -61,17 +63,7 @@ func runSwitch(cfg *config.Config) error {
6163
}
6264
}
6365

64-
var selectFn func(prompt, def string, opts []string) (int, error)
65-
if cfg.SelectFn != nil {
66-
selectFn = cfg.SelectFn
67-
} else {
68-
p := prompter.New(cfg.In, cfg.Out, cfg.Err)
69-
selectFn = func(prompt, def string, opts []string) (int, error) {
70-
return p.Select(prompt, def, opts)
71-
}
72-
}
73-
74-
selected, err := selectFn("Select a branch in the stack to switch to:", defaultOpt, options)
66+
selected, err := selectSwitchBranch(cfg, "Select a branch in the stack to switch to:", defaultOpt, options)
7567
if err != nil {
7668
if isInterruptError(err) {
7769
clearSelectPrompt(cfg, len(options))
@@ -103,3 +95,46 @@ func runSwitch(cfg *config.Config) error {
10395
cfg.Successf("Switched to %s", targetBranch)
10496
return nil
10597
}
98+
99+
func selectSwitchBranch(cfg *config.Config, prompt, defaultValue string, options []string) (int, error) {
100+
if cfg.SelectFn != nil {
101+
return cfg.SelectFn(prompt, defaultValue, options)
102+
}
103+
104+
var selected int
105+
err := survey.AskOne(
106+
newSwitchSelect(prompt, defaultValue, options),
107+
&selected,
108+
survey.WithStdio(cfg.In, cfg.Out, cfg.Err),
109+
)
110+
if err != nil {
111+
return 0, fmt.Errorf("could not prompt: %w", err)
112+
}
113+
return selected, nil
114+
}
115+
116+
func newSwitchSelect(prompt, defaultValue string, options []string) *survey.Select {
117+
selectPrompt := &survey.Select{
118+
Message: prompt,
119+
Options: options,
120+
PageSize: selectPromptPageSize,
121+
VimMode: true,
122+
Filter: switchSelectFilter,
123+
}
124+
if defaultValue != "" {
125+
for _, option := range options {
126+
if option == defaultValue {
127+
selectPrompt.Default = defaultValue
128+
break
129+
}
130+
}
131+
}
132+
return selectPrompt
133+
}
134+
135+
func switchSelectFilter(filter, value string, _ int) bool {
136+
filter = strings.ToLower(filter)
137+
value = strings.ToLower(value)
138+
return strings.Contains(value, filter) ||
139+
strings.Contains(text.RemoveDiacritics(value), filter)
140+
}

cmd/switch_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,21 @@ func TestSwitch_CmdIntegration(t *testing.T) {
271271
err := cmd.Execute()
272272
assert.NoError(t, err)
273273
}
274+
275+
func TestNewSwitchSelect_EnablesVimNavigation(t *testing.T) {
276+
options := []string{"3. résumé", "2. b2", "1. b1"}
277+
prompt := newSwitchSelect("Select a branch:", "2. b2", options)
278+
279+
assert.True(t, prompt.VimMode)
280+
assert.Equal(t, selectPromptPageSize, prompt.PageSize)
281+
assert.Equal(t, "2. b2", prompt.Default)
282+
assert.Equal(t, options, prompt.Options)
283+
require.NotNil(t, prompt.Filter)
284+
assert.True(t, prompt.Filter("resume", options[0], 0), "filter should retain diacritic-insensitive matching")
285+
}
286+
287+
func TestSwitchCmd_DescribesVimNavigation(t *testing.T) {
288+
cfg, _, _ := config.NewTestConfig()
289+
290+
assert.Contains(t, SwitchCmd(cfg).Long, "down/up arrow keys or j/k")
291+
}

docs/src/content/docs/guides/modify.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@ Inserts a new empty branch into the stack at the cursor position. Lowercase `i`
5555

5656
Opens an inline prompt to enter a new name for the branch. The branch is renamed locally and in the stack metadata. On the next `submit`, the new branch name is pushed to GitHub.
5757

58-
### Reorder (`Shift+`/`Shift+`)
58+
### Reorder (`Shift+`/`Shift+`)
5959

60-
Moves the selected branch up (away from trunk) or down (toward trunk) in the stack. A cascading rebase adjusts all affected branches. Note: reordering and structural changes (drop/fold/insert/rename) cannot be mixed in the same session.
60+
Moves the selected branch down (toward trunk) or up (away from trunk) in the stack. A cascading rebase adjusts all affected branches. Note: reordering and structural changes (drop/fold/insert/rename) cannot be mixed in the same session.
6161

6262
### Undo (`z`)
6363

docs/src/content/docs/guides/workflows.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ gh stack modify
357357
# u → fold up (into branch above)
358358
# i → insert below
359359
# I → insert above
360-
# Shift+↑/↓ → reorder
360+
# Shift+↓/↑ → reorder
361361
# r → rename
362362
# z → undo
363363
# Ctrl+S → apply changes
@@ -385,7 +385,7 @@ gh stack submit
385385
**Reorder branches:**
386386
1. `gh stack modify`
387387
2. Navigate to the branch to move
388-
3. Press `Shift+` to move up or `Shift+` to move down
388+
3. Press `Shift+` to move down or `Shift+` to move up
389389
4. Press `Ctrl+S` to apply
390390
5. `gh stack submit`
391391

internal/tui/checkoutview/model.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ func (t tab) String() string {
3535
type keyMap struct {
3636
Up key.Binding
3737
Down key.Binding
38+
VimUp key.Binding
39+
VimDown key.Binding
3840
Select key.Binding
3941
NextTab key.Binding
4042
PrevTab key.Binding
@@ -45,6 +47,8 @@ type keyMap struct {
4547
var keys = keyMap{
4648
Up: key.NewBinding(key.WithKeys("up", "ctrl+p")),
4749
Down: key.NewBinding(key.WithKeys("down", "ctrl+n")),
50+
VimUp: key.NewBinding(key.WithKeys("k")),
51+
VimDown: key.NewBinding(key.WithKeys("j")),
4852
Select: key.NewBinding(key.WithKeys("enter")),
4953
NextTab: key.NewBinding(key.WithKeys("tab", "right")),
5054
PrevTab: key.NewBinding(key.WithKeys("shift+tab", "left")),
@@ -111,11 +115,11 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
111115
m.cancelled = true
112116
return m, tea.Quit
113117

114-
case key.Matches(msg, keys.Up):
118+
case key.Matches(msg, keys.Up) || (!m.searching && key.Matches(msg, keys.VimUp)):
115119
m.moveCursor(-1)
116120
return m, nil
117121

118-
case key.Matches(msg, keys.Down):
122+
case key.Matches(msg, keys.Down) || (!m.searching && key.Matches(msg, keys.VimDown)):
119123
m.moveCursor(1)
120124
return m, nil
121125

@@ -563,13 +567,13 @@ func (m Model) renderFooter() string {
563567
var pairs [][2]string
564568
if m.searching {
565569
pairs = [][2]string{
566-
{"↑↓", "navigate"},
570+
{"↓↑", "navigate"},
567571
{"enter", "select"},
568572
{"esc", "clear search"},
569573
}
570574
} else {
571575
pairs = [][2]string{
572-
{"↑↓", "navigate"},
576+
{"↓↑/jk", "navigate"},
573577
{"←→", "tabs"},
574578
{"/", "search"},
575579
{"enter", "select"},

internal/tui/checkoutview/model_test.go

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -144,13 +144,25 @@ func TestSearchMatchesMidStackBranch(t *testing.T) {
144144
}
145145

146146
func TestCursorNavigationClamps(t *testing.T) {
147-
m := sized(New(sampleRows()))
148-
// Up at the top stays at 0.
149-
m = drive(m, tea.KeyMsg{Type: tea.KeyUp})
150-
assert.Equal(t, 0, m.cursor)
151-
// Down past the end clamps to the last row.
152-
m = drive(m, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown})
153-
assert.Equal(t, 2, m.cursor)
147+
tests := []struct {
148+
name string
149+
up tea.KeyMsg
150+
down tea.KeyMsg
151+
}{
152+
{name: "arrow keys", up: tea.KeyMsg{Type: tea.KeyUp}, down: tea.KeyMsg{Type: tea.KeyDown}},
153+
{name: "vim keys", up: runeKey("k"), down: runeKey("j")},
154+
}
155+
156+
for _, tt := range tests {
157+
t.Run(tt.name, func(t *testing.T) {
158+
m := sized(New(sampleRows()))
159+
m = drive(m, tt.up)
160+
assert.Equal(t, 0, m.cursor)
161+
162+
m = drive(m, tt.down, tt.down, tt.down)
163+
assert.Equal(t, 2, m.cursor)
164+
})
165+
}
154166
}
155167

156168
func TestEnterSelectsLocalRow(t *testing.T) {
@@ -210,6 +222,14 @@ func TestQTypesIntoSearchInsteadOfQuitting(t *testing.T) {
210222
assert.False(t, m.Cancelled())
211223
}
212224

225+
func TestVimKeysTypeIntoSearchInsteadOfNavigating(t *testing.T) {
226+
m := sized(New(sampleRows()))
227+
m = drive(m, runeKey("/"), runeKey("j"), runeKey("k"))
228+
229+
assert.True(t, m.searching)
230+
assert.Equal(t, "jk", m.query)
231+
}
232+
213233
func TestView_RendersColumnsAndRows(t *testing.T) {
214234
m := sized(New(sampleRows()))
215235
out := stripANSI(m.View())
@@ -222,6 +242,7 @@ func TestView_RendersColumnsAndRows(t *testing.T) {
222242
assert.Contains(t, out, "Remote")
223243
assert.Contains(t, out, "1h ago")
224244
assert.Contains(t, out, "/ search")
245+
assert.Contains(t, out, "↓↑/jk")
225246
}
226247

227248
func TestView_SearchFooterAndPrompt(t *testing.T) {
@@ -230,6 +251,8 @@ func TestView_SearchFooterAndPrompt(t *testing.T) {
230251
out := stripANSI(m.View())
231252
assert.Contains(t, out, "/ a")
232253
assert.Contains(t, out, "clear search")
254+
assert.Contains(t, out, "↓↑ navigate")
255+
assert.NotContains(t, out, "↓↑/jk")
233256
}
234257

235258
func TestView_EmptyStateMessage(t *testing.T) {

internal/tui/mergeview/model_test.go

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ func step(m Model, msg tea.Msg) Model {
2929

3030
func keyType(t tea.KeyType) tea.KeyMsg { return tea.KeyMsg{Type: t} }
3131

32+
func keyRune(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} }
33+
3234
func space() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeySpace} }
3335

3436
func TestNew_DefaultsSelectAll(t *testing.T) {
@@ -124,19 +126,32 @@ func TestSelect_Viewport(t *testing.T) {
124126
assert.Contains(t, view, "more")
125127
}
126128

127-
func TestSelect_ArrowDirection(t *testing.T) {
128-
m := New(baseOptions()) // cursor starts at the top of the stack (index 2)
129-
assert.Equal(t, 2, m.cursor)
130-
131-
// "up" moves toward the top of the stack and is clamped there.
132-
m = step(m, keyType(tea.KeyUp))
133-
assert.Equal(t, 2, m.cursor)
129+
func TestSelect_NavigationDirection(t *testing.T) {
130+
tests := []struct {
131+
name string
132+
up tea.KeyMsg
133+
down tea.KeyMsg
134+
}{
135+
{name: "arrow keys", up: keyType(tea.KeyUp), down: keyType(tea.KeyDown)},
136+
{name: "vim keys", up: keyRune('k'), down: keyRune('j')},
137+
}
134138

135-
// "down" moves toward the bottom of the stack (lower index).
136-
m = step(m, keyType(tea.KeyDown))
137-
assert.Equal(t, 1, m.cursor)
138-
m = step(m, keyType(tea.KeyUp))
139-
assert.Equal(t, 2, m.cursor)
139+
for _, tt := range tests {
140+
t.Run(tt.name, func(t *testing.T) {
141+
m := New(baseOptions()) // cursor starts at the top of the stack (index 2)
142+
assert.Equal(t, 2, m.cursor)
143+
144+
// Up moves toward the top of the stack and is clamped there.
145+
m = step(m, tt.up)
146+
assert.Equal(t, 2, m.cursor)
147+
148+
// Down moves toward the bottom of the stack (lower index).
149+
m = step(m, tt.down)
150+
assert.Equal(t, 1, m.cursor)
151+
m = step(m, tt.up)
152+
assert.Equal(t, 2, m.cursor)
153+
})
154+
}
140155
}
141156

142157
func TestTruncate_WideRunes(t *testing.T) {
@@ -260,6 +275,18 @@ func TestMethod_SelectAndAdvance(t *testing.T) {
260275
assert.Equal(t, "rebase", m.method)
261276
}
262277

278+
func TestMethod_VimNavigation(t *testing.T) {
279+
m := New(baseOptions())
280+
m = step(m, keyType(tea.KeyEnter))
281+
require.Equal(t, StepMethod, m.step)
282+
require.Equal(t, 1, m.methodCursor)
283+
284+
m = step(m, keyRune('j'))
285+
assert.Equal(t, 2, m.methodCursor)
286+
m = step(m, keyRune('k'))
287+
assert.Equal(t, 1, m.methodCursor)
288+
}
289+
263290
func TestMethod_EscCancels(t *testing.T) {
264291
m := New(baseOptions())
265292
m = step(m, keyType(tea.KeyEnter))
@@ -392,9 +419,12 @@ func TestView_RendersBannerAndSteps(t *testing.T) {
392419
assert.Contains(t, sel, "Confirm")
393420
assert.Contains(t, sel, "Will merge 3 PRs into main")
394421
assert.Contains(t, sel, "feat-a") // branch shown on the item's second line
422+
assert.Contains(t, sel, "↓↑/jk")
395423

396424
m = step(m, keyType(tea.KeyTab))
397-
assert.Contains(t, m.View(), "Squash and merge") // method labels, no subheading
425+
method := m.View()
426+
assert.Contains(t, method, "Squash and merge") // method labels, no subheading
427+
assert.Contains(t, method, "↓↑/jk")
398428

399429
m = step(m, keyType(tea.KeyTab))
400430
confirm := m.View()

internal/tui/mergeview/view.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ func (m Model) viewSelect() string {
198198
}
199199

200200
// Reserve the indicator lines at all times (blank when nothing is hidden) so
201-
// the list doesn't shift as the ↑/↓ hints appear and disappear while scrolling.
201+
// the list doesn't shift as the ↓/↑ hints appear and disappear while scrolling.
202202
if start > 0 {
203203
b.WriteString(faintStyle.Render(fmt.Sprintf(" ↑ %d more", start)) + "\n")
204204
} else {
@@ -251,7 +251,7 @@ func (m Model) viewSelect() string {
251251
}
252252
b.WriteString("\n\n")
253253
b.WriteString(shortcuts(
254-
[2]string{"↑/↓", "move"},
254+
[2]string{"↓↑/jk", "move"},
255255
[2]string{"space", "toggle"},
256256
[2]string{"tab/enter", "next"},
257257
[2]string{"esc", "cancel"},
@@ -278,7 +278,7 @@ func (m Model) viewMethod() string {
278278

279279
b.WriteString("\n")
280280
b.WriteString(shortcuts(
281-
[2]string{"↑/↓", "move"},
281+
[2]string{"↓↑/jk", "move"},
282282
[2]string{"tab/enter", "next"},
283283
[2]string{"shift+tab", "back"},
284284
[2]string{"esc", "cancel"},

0 commit comments

Comments
 (0)