detached 835c479

feat(core): open the stash terminal from the four-finger swipe

A four-finger swipe down on an empty stash now opens the terminal in it when
stash.spawn-terminal is on, like toggle-stash-visible. Bind and swipe share
runSpawn, and a second swipe while the terminal is awaited starts nothing.
commit
835c4797b4dd01deea01251cedaef5b485d339e1
author
brice · · 2026-10-14 20:36 UTC
committer
brice · · 2026-10-14 20:37 UTC
parent
ac0ff68
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +100 -45
M docs/config.md +1 -1
@@ -253,7 +253,7 @@ A cell is `1/max-columns` of the screen width. A column is 1 to 16 cells wide. C
253253
254254 Each workspace has a stash: a strip of windows set aside with `toggle-window-stash`. The selected one shows centered over your columns, with its neighbors dimmed on the sides. `focus-column-left/right` or a three-finger swipe browse it. `toggle-stash-visible` or a four-finger swipe hides it.
255255
256-`stash <command>` runs a program and opens its window straight in the stash of the workspace you were on, selected and focused, even if you switch workspace before it appears. With `stash.spawn-terminal`, `toggle-stash-visible` on an empty stash does the same with `terminal`.
256+`stash <command>` runs a program and opens its window straight in the stash of the workspace you were on, selected and focused, even if you switch workspace before it appears. With `stash.spawn-terminal`, `toggle-stash-visible` or the four-finger swipe down on an empty stash does the same with `terminal`.
257257
258258 ```ini
259259 bind.cmd+shift+f = stash firefox
@@ -253,7 +253,7 @@ A cell is `1/max-columns` of the screen width. A column is 1 to 16 cells wide. C
253 253
254 Each workspace has a stash: a strip of windows set aside with `toggle-window-stash`. The selected one shows centered over your columns, with its neighbors dimmed on the sides. `focus-column-left/right` or a three-finger swipe browse it. `toggle-stash-visible` or a four-finger swipe hides it.254 Each workspace has a stash: a strip of windows set aside with `toggle-window-stash`. The selected one shows centered over your columns, with its neighbors dimmed on the sides. `focus-column-left/right` or a three-finger swipe browse it. `toggle-stash-visible` or a four-finger swipe hides it.
255 255
256-`stash <command>` runs a program and opens its window straight in the stash of the workspace you were on, selected and focused, even if you switch workspace before it appears. With `stash.spawn-terminal`, `toggle-stash-visible` on an empty stash does the same with `terminal`.256+`stash <command>` runs a program and opens its window straight in the stash of the workspace you were on, selected and focused, even if you switch workspace before it appears. With `stash.spawn-terminal`, `toggle-stash-visible` or the four-finger swipe down on an empty stash does the same with `terminal`.
257 257
258 ```ini258 ```ini
259 bind.cmd+shift+f = stash firefox259 bind.cmd+shift+f = stash firefox
M internal/core/gesture.go +28 -22
@@ -284,27 +284,28 @@ func (c *Core) decide(g *swipeGesture) {
284284 }
285285
286286 // swipeEnd lands the swipe. shown reports that another workspace came on
287-// screen.
288-func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
287+// screen. spawn is the workspace whose empty stash asked for its terminal
288+// (see swipeStash): the caller starts it.
289+func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool, spawn *Workspace) {
289290 g := c.swipe
290291 if g != nil && !c.hasScreen(g.screen) {
291292 c.dropSwipe()
292293 }
293294 c.swipe = nil
294295 if g == nil || !c.hasScreen(g.screen) {
295- return false
296+ return false, nil
296297 }
297298 now := c.now()
298299 m := g.screen.mon
299300 switch g.mode {
300301 case swipeNavigation:
301302 c.scrollStop(ports.PointerAxis{Horizontal: ports.ScrollAxis{Stop: true}})
302- return false
303+ return false, nil
303304 case swipeColumns:
304305 w := g.ws
305306 if g.columnsChanged(m) {
306307 w.view.stop()
307- return false
308+ return false, nil
308309 }
309310 shown := float64(w.View) + w.view.off
310311 target, velocity := g.snap.end(e.Cancelled, e.Time)
@@ -330,7 +331,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
330331 case swipeWorkspaces:
331332 if g.listChanged(m) {
332333 m.stopSwitch()
333- return false
334+ return false, nil
334335 }
335336 cur := float64(m.Active) + m.switchView.off
336337 target, velocity := g.snap.end(e.Cancelled, e.Time)
@@ -359,7 +360,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
359360 w := g.ws
360361 if g.stashChanged(m) {
361362 w.stashView.stop()
362- return false
363+ return false, nil
363364 }
364365 drawn := float64(w.stashAt) + w.stashView.off
365366 target, velocity := g.snap.end(e.Cancelled, e.Time)
@@ -384,17 +385,17 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
384385 w.stashView.stop()
385386 }
386387 // The stash never brings another workspace on screen.
387- return false
388+ return false, nil
388389 case swipeOverview:
389390 step := g.snap.step(e.Cancelled, e.Time)
390391 if step == 0 {
391- return false
392+ return false, nil
392393 }
393394 if !m.ov.open && (step > 0 || m.Current().stashShown()) {
394395 return c.swipeStash(g, now)
395396 }
396397 if (step < 0) == m.ov.open {
397- return false
398+ return false, nil
398399 }
399400 before := m.Current()
400401 shots := c.snapshot(now)
@@ -402,11 +403,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
402403 c.focusScreen = c.screenIndex(g.screen.name())
403404 c.keyboard.takeBack()
404405 c.transition(shots, now)
405- return m.Current() != before
406+ return m.Current() != before, nil
406407 case swipeDiscrete:
407408 step := g.snap.step(e.Cancelled, e.Time)
408409 if step == 0 {
409- return false
410+ return false, nil
410411 }
411412 w := g.screen.mon.Current()
412413 p := w.policy()
@@ -423,16 +424,16 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
423424 } else if p.wraps {
424425 // A content swipe cannot escape the last band to another workspace.
425426 if w.onFloat() || w.fullscreen != 0 || w.laneNeighbor(step) < 0 {
426- return false
427+ return false, nil
427428 }
428429 }
429430 // Focus actions act on the focused screen: the swipe's, unless the
430431 // pointer took the focus to another output meanwhile.
431432 if c.cur() != g.screen {
432- return false
433+ return false, nil
433434 }
434435 if c.cur().mon.ov.open {
435- return false
436+ return false, nil
436437 }
437438 before := c.cur().mon.Current()
438439 shots := c.snapshot(now)
@@ -443,26 +444,31 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
443444 c.applyAction(a)
444445 }
445446 c.transition(shots, now)
446- return c.cur().mon.Current() != before
447+ return c.cur().mon.Current() != before, nil
447448 }
448- return shown
449+ return shown, nil
449450 }
450451
451452 // swipeStash runs toggle-stash-visible for a four-finger swipe down, or up
452453 // while the stash is shown, with the overview closed, on the swipe's screen
453454 // unless the pointer took the focus to another output meanwhile. It reports
454-// whether the stash showed or hid: an empty stash stays as it is.
455-func (c *Core) swipeStash(g *swipeGesture, now time.Time) bool {
455+// whether the swipe acted: the stash showed or hid, or an empty stash asked
456+// for its terminal (stash.spawn-terminal), which spawn names for the caller
457+// to start, as a bind does.
458+func (c *Core) swipeStash(g *swipeGesture, now time.Time) (acted bool, spawn *Workspace) {
456459 if c.cur() != g.screen {
457- return false
460+ return false, nil
458461 }
459462 w := g.screen.mon.Current()
460463 hidden, over := w.stashHidden, w.stashOver
461464 shots := c.snapshot(now)
462465 c.keyboard.takeBack()
463- c.applyAction(ActionToggleStashVisible)
466+ effect := c.applyAction(ActionToggleStashVisible)
464467 c.transition(shots, now)
465- return w.stashHidden != hidden || w.stashOver != over
468+ if effect.Spawn {
469+ return true, w
470+ }
471+ return w.stashHidden != hidden || w.stashOver != over, nil
466472 }
467473
468474 // swipedWorkspace is the workspace on the swipe's screen, or nil without
@@ -284,27 +284,28 @@ func (c *Core) decide(g *swipeGesture) {
284 }284 }
285 285
286 // swipeEnd lands the swipe. shown reports that another workspace came on286 // swipeEnd lands the swipe. shown reports that another workspace came on
287-// screen.287+// screen. spawn is the workspace whose empty stash asked for its terminal
288-func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {288+// (see swipeStash): the caller starts it.
289+func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool, spawn *Workspace) {
289 g := c.swipe290 g := c.swipe
290 if g != nil && !c.hasScreen(g.screen) {291 if g != nil && !c.hasScreen(g.screen) {
291 c.dropSwipe()292 c.dropSwipe()
292 }293 }
293 c.swipe = nil294 c.swipe = nil
294 if g == nil || !c.hasScreen(g.screen) {295 if g == nil || !c.hasScreen(g.screen) {
295- return false296+ return false, nil
296 }297 }
297 now := c.now()298 now := c.now()
298 m := g.screen.mon299 m := g.screen.mon
299 switch g.mode {300 switch g.mode {
300 case swipeNavigation:301 case swipeNavigation:
301 c.scrollStop(ports.PointerAxis{Horizontal: ports.ScrollAxis{Stop: true}})302 c.scrollStop(ports.PointerAxis{Horizontal: ports.ScrollAxis{Stop: true}})
302- return false303+ return false, nil
303 case swipeColumns:304 case swipeColumns:
304 w := g.ws305 w := g.ws
305 if g.columnsChanged(m) {306 if g.columnsChanged(m) {
306 w.view.stop()307 w.view.stop()
307- return false308+ return false, nil
308 }309 }
309 shown := float64(w.View) + w.view.off310 shown := float64(w.View) + w.view.off
310 target, velocity := g.snap.end(e.Cancelled, e.Time)311 target, velocity := g.snap.end(e.Cancelled, e.Time)
@@ -330,7 +331,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
330 case swipeWorkspaces:331 case swipeWorkspaces:
331 if g.listChanged(m) {332 if g.listChanged(m) {
332 m.stopSwitch()333 m.stopSwitch()
333- return false334+ return false, nil
334 }335 }
335 cur := float64(m.Active) + m.switchView.off336 cur := float64(m.Active) + m.switchView.off
336 target, velocity := g.snap.end(e.Cancelled, e.Time)337 target, velocity := g.snap.end(e.Cancelled, e.Time)
@@ -359,7 +360,7 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
359 w := g.ws360 w := g.ws
360 if g.stashChanged(m) {361 if g.stashChanged(m) {
361 w.stashView.stop()362 w.stashView.stop()
362- return false363+ return false, nil
363 }364 }
364 drawn := float64(w.stashAt) + w.stashView.off365 drawn := float64(w.stashAt) + w.stashView.off
365 target, velocity := g.snap.end(e.Cancelled, e.Time)366 target, velocity := g.snap.end(e.Cancelled, e.Time)
@@ -384,17 +385,17 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
384 w.stashView.stop()385 w.stashView.stop()
385 }386 }
386 // The stash never brings another workspace on screen.387 // The stash never brings another workspace on screen.
387- return false388+ return false, nil
388 case swipeOverview:389 case swipeOverview:
389 step := g.snap.step(e.Cancelled, e.Time)390 step := g.snap.step(e.Cancelled, e.Time)
390 if step == 0 {391 if step == 0 {
391- return false392+ return false, nil
392 }393 }
393 if !m.ov.open && (step > 0 || m.Current().stashShown()) {394 if !m.ov.open && (step > 0 || m.Current().stashShown()) {
394 return c.swipeStash(g, now)395 return c.swipeStash(g, now)
395 }396 }
396 if (step < 0) == m.ov.open {397 if (step < 0) == m.ov.open {
397- return false398+ return false, nil
398 }399 }
399 before := m.Current()400 before := m.Current()
400 shots := c.snapshot(now)401 shots := c.snapshot(now)
@@ -402,11 +403,11 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
402 c.focusScreen = c.screenIndex(g.screen.name())403 c.focusScreen = c.screenIndex(g.screen.name())
403 c.keyboard.takeBack()404 c.keyboard.takeBack()
404 c.transition(shots, now)405 c.transition(shots, now)
405- return m.Current() != before406+ return m.Current() != before, nil
406 case swipeDiscrete:407 case swipeDiscrete:
407 step := g.snap.step(e.Cancelled, e.Time)408 step := g.snap.step(e.Cancelled, e.Time)
408 if step == 0 {409 if step == 0 {
409- return false410+ return false, nil
410 }411 }
411 w := g.screen.mon.Current()412 w := g.screen.mon.Current()
412 p := w.policy()413 p := w.policy()
@@ -423,16 +424,16 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
423 } else if p.wraps {424 } else if p.wraps {
424 // A content swipe cannot escape the last band to another workspace.425 // A content swipe cannot escape the last band to another workspace.
425 if w.onFloat() || w.fullscreen != 0 || w.laneNeighbor(step) < 0 {426 if w.onFloat() || w.fullscreen != 0 || w.laneNeighbor(step) < 0 {
426- return false427+ return false, nil
427 }428 }
428 }429 }
429 // Focus actions act on the focused screen: the swipe's, unless the430 // Focus actions act on the focused screen: the swipe's, unless the
430 // pointer took the focus to another output meanwhile.431 // pointer took the focus to another output meanwhile.
431 if c.cur() != g.screen {432 if c.cur() != g.screen {
432- return false433+ return false, nil
433 }434 }
434 if c.cur().mon.ov.open {435 if c.cur().mon.ov.open {
435- return false436+ return false, nil
436 }437 }
437 before := c.cur().mon.Current()438 before := c.cur().mon.Current()
438 shots := c.snapshot(now)439 shots := c.snapshot(now)
@@ -443,26 +444,31 @@ func (c *Core) swipeEnd(e ports.SwipeEnd) (shown bool) {
443 c.applyAction(a)444 c.applyAction(a)
444 }445 }
445 c.transition(shots, now)446 c.transition(shots, now)
446- return c.cur().mon.Current() != before447+ return c.cur().mon.Current() != before, nil
447 }448 }
448- return shown449+ return shown, nil
449 }450 }
450 451
451 // swipeStash runs toggle-stash-visible for a four-finger swipe down, or up452 // swipeStash runs toggle-stash-visible for a four-finger swipe down, or up
452 // while the stash is shown, with the overview closed, on the swipe's screen453 // while the stash is shown, with the overview closed, on the swipe's screen
453 // unless the pointer took the focus to another output meanwhile. It reports454 // unless the pointer took the focus to another output meanwhile. It reports
454-// whether the stash showed or hid: an empty stash stays as it is.455+// whether the swipe acted: the stash showed or hid, or an empty stash asked
455-func (c *Core) swipeStash(g *swipeGesture, now time.Time) bool {456+// for its terminal (stash.spawn-terminal), which spawn names for the caller
457+// to start, as a bind does.
458+func (c *Core) swipeStash(g *swipeGesture, now time.Time) (acted bool, spawn *Workspace) {
456 if c.cur() != g.screen {459 if c.cur() != g.screen {
457- return false460+ return false, nil
458 }461 }
459 w := g.screen.mon.Current()462 w := g.screen.mon.Current()
460 hidden, over := w.stashHidden, w.stashOver463 hidden, over := w.stashHidden, w.stashOver
461 shots := c.snapshot(now)464 shots := c.snapshot(now)
462 c.keyboard.takeBack()465 c.keyboard.takeBack()
463- c.applyAction(ActionToggleStashVisible)466+ effect := c.applyAction(ActionToggleStashVisible)
464 c.transition(shots, now)467 c.transition(shots, now)
465- return w.stashHidden != hidden || w.stashOver != over468+ if effect.Spawn {
469+ return true, w
470+ }
471+ return w.stashHidden != hidden || w.stashOver != over, nil
466 }472 }
467 473
468 // swipedWorkspace is the workspace on the swipe's screen, or nil without474 // swipedWorkspace is the workspace on the swipe's screen, or nil without
M internal/core/input.go +38 -21
@@ -49,7 +49,14 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
4949 if c.gesture.on && c.gesture.kind == ports.GestureSwipe {
5050 return c.gestureEnd(ctx, ports.GestureSwipe, v.Cancelled, v.Time)
5151 }
52- return c.slid(ctx, c.swipeEnd(v))
52+ shown, spawn := c.swipeEnd(v)
53+ if spawn != nil {
54+ // As a bind: the terminal starts before the new state is published.
55+ if err := c.runSpawn(ctx, spawn, Effect{Spawn: true, Stash: true}); err != nil {
56+ return err
57+ }
58+ }
59+ return c.slid(ctx, shown)
5360 case ports.PinchBegin:
5461 return c.gestureBegin(ctx, ports.GesturePinch, v.Fingers, v.Time)
5562 case ports.PinchUpdate:
@@ -342,26 +349,8 @@ func (c *Core) runBind(ctx context.Context, action Action) error {
342349 return ErrQuit
343350 }
344351 if effect.Spawn {
345- commands := effect.Commands
346- if commands == nil {
347- commands = [][]string{append([]string(nil), c.cfg.Terminal.Command...)}
348- }
349- switch {
350- case effect.Stash:
351- if err := c.spawnStash(ctx, before, commands, effect.Commands == nil); err != nil {
352- return err
353- }
354- default:
355- // Explicit actions are suppressed on a full launcher queue;
356- // never keep the input owner waiting across an epoch change.
357- for _, argv := range commands {
358- if !c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) {
359- break
360- }
361- }
362- }
363- if c.inputEpochChanged() {
364- return errSecurityChanged
352+ if err := c.runSpawn(ctx, before, effect); err != nil {
353+ return err
365354 }
366355 }
367356 if c.swipedWorkspace() != swiped {
@@ -379,6 +368,34 @@ func (c *Core) runBind(ctx context.Context, action Action) error {
379368 return c.publish(ctx)
380369 }
381370
371+// runSpawn starts the programs of a spawn effect of an action applied to w:
372+// the given commands, else the configured terminal. A stash effect opens the
373+// windows in the stash of w. It reports errSecurityChanged when the input
374+// epoch changed meanwhile.
375+func (c *Core) runSpawn(ctx context.Context, w *Workspace, effect Effect) error {
376+ commands := effect.Commands
377+ if commands == nil {
378+ commands = [][]string{append([]string(nil), c.cfg.Terminal.Command...)}
379+ }
380+ if effect.Stash {
381+ if err := c.spawnStash(ctx, w, commands, effect.Commands == nil); err != nil {
382+ return err
383+ }
384+ } else {
385+ // Explicit actions are suppressed on a full launcher queue;
386+ // never keep the input owner waiting across an epoch change.
387+ for _, argv := range commands {
388+ if !c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) {
389+ break
390+ }
391+ }
392+ }
393+ if c.inputEpochChanged() {
394+ return errSecurityChanged
395+ }
396+ return nil
397+}
398+
382399 // spawnStash runs commands whose windows open in the stash of w, the
383400 // workspace the bind acted on. Wayland reads the claim tokens only while it
384401 // knows slots are pending, so that is published before the spawn, as
@@ -49,7 +49,14 @@ func (c *Core) handleInput(ctx context.Context, ev ports.InputEvent) error {
49 if c.gesture.on && c.gesture.kind == ports.GestureSwipe {49 if c.gesture.on && c.gesture.kind == ports.GestureSwipe {
50 return c.gestureEnd(ctx, ports.GestureSwipe, v.Cancelled, v.Time)50 return c.gestureEnd(ctx, ports.GestureSwipe, v.Cancelled, v.Time)
51 }51 }
52- return c.slid(ctx, c.swipeEnd(v))52+ shown, spawn := c.swipeEnd(v)
53+ if spawn != nil {
54+ // As a bind: the terminal starts before the new state is published.
55+ if err := c.runSpawn(ctx, spawn, Effect{Spawn: true, Stash: true}); err != nil {
56+ return err
57+ }
58+ }
59+ return c.slid(ctx, shown)
53 case ports.PinchBegin:60 case ports.PinchBegin:
54 return c.gestureBegin(ctx, ports.GesturePinch, v.Fingers, v.Time)61 return c.gestureBegin(ctx, ports.GesturePinch, v.Fingers, v.Time)
55 case ports.PinchUpdate:62 case ports.PinchUpdate:
@@ -342,26 +349,8 @@ func (c *Core) runBind(ctx context.Context, action Action) error {
342 return ErrQuit349 return ErrQuit
343 }350 }
344 if effect.Spawn {351 if effect.Spawn {
345- commands := effect.Commands352+ if err := c.runSpawn(ctx, before, effect); err != nil {
346- if commands == nil {353+ return err
347- commands = [][]string{append([]string(nil), c.cfg.Terminal.Command...)}
348- }
349- switch {
350- case effect.Stash:
351- if err := c.spawnStash(ctx, before, commands, effect.Commands == nil); err != nil {
352- return err
353- }
354- default:
355- // Explicit actions are suppressed on a full launcher queue;
356- // never keep the input owner waiting across an epoch change.
357- for _, argv := range commands {
358- if !c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) {
359- break
360- }
361- }
362- }
363- if c.inputEpochChanged() {
364- return errSecurityChanged
365 }354 }
366 }355 }
367 if c.swipedWorkspace() != swiped {356 if c.swipedWorkspace() != swiped {
@@ -379,6 +368,34 @@ func (c *Core) runBind(ctx context.Context, action Action) error {
379 return c.publish(ctx)368 return c.publish(ctx)
380 }369 }
381 370
371+// runSpawn starts the programs of a spawn effect of an action applied to w:
372+// the given commands, else the configured terminal. A stash effect opens the
373+// windows in the stash of w. It reports errSecurityChanged when the input
374+// epoch changed meanwhile.
375+func (c *Core) runSpawn(ctx context.Context, w *Workspace, effect Effect) error {
376+ commands := effect.Commands
377+ if commands == nil {
378+ commands = [][]string{append([]string(nil), c.cfg.Terminal.Command...)}
379+ }
380+ if effect.Stash {
381+ if err := c.spawnStash(ctx, w, commands, effect.Commands == nil); err != nil {
382+ return err
383+ }
384+ } else {
385+ // Explicit actions are suppressed on a full launcher queue;
386+ // never keep the input owner waiting across an epoch change.
387+ for _, argv := range commands {
388+ if !c.trySpawn(ctx, ports.SpawnRequest{Argv: argv}) {
389+ break
390+ }
391+ }
392+ }
393+ if c.inputEpochChanged() {
394+ return errSecurityChanged
395+ }
396+ return nil
397+}
398+
382 // spawnStash runs commands whose windows open in the stash of w, the399 // spawnStash runs commands whose windows open in the stash of w, the
383 // workspace the bind acted on. Wayland reads the claim tokens only while it400 // workspace the bind acted on. Wayland reads the claim tokens only while it
384 // knows slots are pending, so that is published before the spawn, as401 // knows slots are pending, so that is published before the spawn, as
M internal/core/stash_spawn_test.go +32 -0
@@ -169,3 +169,35 @@ func TestToggleEmptyStashInOverviewDoesNotSpawn(t *testing.T) {
169169 t.Fatal("spawned from the overview")
170170 }
171171 }
172+
173+// A four-finger swipe down on an empty stash is toggle-stash-visible: with
174+// stash.spawn-terminal it opens the terminal in the stash, once while that
175+// one is awaited, and the window that maps lands in the stash.
176+func TestFourFingerSwipeEmptyStashOpensTerminal(t *testing.T) {
177+ r := startSwipe(t, func(c *ports.Config) { c.Stash.SpawnTerminal = true })
178+ r.four(t, 0, 40, 10)
179+ req := receive(t, r.spawn)
180+ if len(req.Argv) != 1 || req.Argv[0] != "foot" {
181+ t.Fatalf("argv %v", req.Argv)
182+ }
183+ token := slotToken(t, req)
184+ if pending, sent := pendingSent(r.multiRig); !sent || !pending {
185+ t.Fatal("SlotsPending{true} was not sent")
186+ }
187+ r.four(t, 0, 40, 10)
188+ if len(r.spawn) != 0 {
189+ t.Fatal("second terminal while the first is awaited")
190+ }
191+ r.client <- ports.WindowMapped{ID: 2, Slot: token}
192+ if w, ok := sceneWindow(receive(t, r.scenes), 2); !ok || w.Hidden || !w.Floating || !w.Focused {
193+ t.Fatalf("terminal %+v", w)
194+ }
195+}
196+
197+func TestFourFingerSwipeEmptyStashRespectsOption(t *testing.T) {
198+ r := startSwipe(t, func(c *ports.Config) { c.Stash.SpawnTerminal = false })
199+ r.four(t, 0, 40, 10)
200+ if len(r.spawn) != 0 {
201+ t.Fatal("spawned with stash.spawn-terminal off")
202+ }
203+}
@@ -169,3 +169,35 @@ func TestToggleEmptyStashInOverviewDoesNotSpawn(t *testing.T) {
169 t.Fatal("spawned from the overview")169 t.Fatal("spawned from the overview")
170 }170 }
171 }171 }
172+
173+// A four-finger swipe down on an empty stash is toggle-stash-visible: with
174+// stash.spawn-terminal it opens the terminal in the stash, once while that
175+// one is awaited, and the window that maps lands in the stash.
176+func TestFourFingerSwipeEmptyStashOpensTerminal(t *testing.T) {
177+ r := startSwipe(t, func(c *ports.Config) { c.Stash.SpawnTerminal = true })
178+ r.four(t, 0, 40, 10)
179+ req := receive(t, r.spawn)
180+ if len(req.Argv) != 1 || req.Argv[0] != "foot" {
181+ t.Fatalf("argv %v", req.Argv)
182+ }
183+ token := slotToken(t, req)
184+ if pending, sent := pendingSent(r.multiRig); !sent || !pending {
185+ t.Fatal("SlotsPending{true} was not sent")
186+ }
187+ r.four(t, 0, 40, 10)
188+ if len(r.spawn) != 0 {
189+ t.Fatal("second terminal while the first is awaited")
190+ }
191+ r.client <- ports.WindowMapped{ID: 2, Slot: token}
192+ if w, ok := sceneWindow(receive(t, r.scenes), 2); !ok || w.Hidden || !w.Floating || !w.Focused {
193+ t.Fatalf("terminal %+v", w)
194+ }
195+}
196+
197+func TestFourFingerSwipeEmptyStashRespectsOption(t *testing.T) {
198+ r := startSwipe(t, func(c *ports.Config) { c.Stash.SpawnTerminal = false })
199+ r.four(t, 0, 40, 10)
200+ if len(r.spawn) != 0 {
201+ t.Fatal("spawned with stash.spawn-terminal off")
202+ }
203+}
M internal/core/stash_swipe_internal_test.go +1 -1
@@ -210,7 +210,7 @@ func TestStashSwipeLandingShowsNoWorkspace(t *testing.T) {
210210 for range 10 {
211211 s.move(-80)
212212 }
213- if shown := s.c.swipeEnd(ports.SwipeEnd{Time: s.at}); shown {
213+ if shown, _ := s.c.swipeEnd(ports.SwipeEnd{Time: s.at}); shown {
214214 t.Fatal("a stash landing reported a workspace shown")
215215 }
216216 if w.stashAt != 1 {
@@ -210,7 +210,7 @@ func TestStashSwipeLandingShowsNoWorkspace(t *testing.T) {
210 for range 10 {210 for range 10 {
211 s.move(-80)211 s.move(-80)
212 }212 }
213- if shown := s.c.swipeEnd(ports.SwipeEnd{Time: s.at}); shown {213+ if shown, _ := s.c.swipeEnd(ports.SwipeEnd{Time: s.at}); shown {
214 t.Fatal("a stash landing reported a workspace shown")214 t.Fatal("a stash landing reported a workspace shown")
215 }215 }
216 if w.stashAt != 1 {216 if w.stashAt != 1 {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close