detached 2b8f235

feat(daemon): complete window lifecycle controls

commit
2b8f2351ad27e6a00f330f442dfbec0659418365
author
brice · · 2026-07-02 14:40 UTC
parent
c8fa13e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +381 -30
M internal/usecase/daemon/daemon.go +161 -25
@@ -102,19 +102,20 @@ type Daemon struct {
102102 connWg sync.WaitGroup // per-connection handler goroutines
103103 }
104104
105-// session is a single multiplexed session. For the MVP it owns exactly one
106-// window and at most one attached client.
105+// session is a single multiplexed session. It owns one or more full-screen
106+// windows and at most one attached client.
107107 type session struct {
108108 id domain.SessionID
109109 name string
110110 ephemeral bool
111- win *window
112111
113112 ctx context.Context
114113 cancel context.CancelFunc
115114
116- mu sync.Mutex // guards client
117- client *attachedClient
115+ mu sync.Mutex // guards windows, active, and client
116+ windows []*window
117+ active int
118+ client *attachedClient
118119 }
119120
120121 // window is one PTY-backed screen. dirty is a cap-1 channel so bursty output
@@ -348,11 +349,12 @@ func (d *Daemon) handleList(tr ports.Transport) {
348349 s.mu.Lock()
349350 attached := s.client != nil
350351 s.mu.Unlock()
352+ windows := len(s.windows)
351353 infos = append(infos, ports.SessionInfo{
352354 SessionID: string(s.id),
353355 Name: s.name,
354356 Ephemeral: s.ephemeral,
355- Windows: 1,
357+ Windows: uint16(windows),
356358 Attached: attached,
357359 })
358360 }
@@ -506,16 +508,38 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size
506508 id: id,
507509 name: name,
508510 ephemeral: ephemeral,
509- win: win,
510511 ctx: sctx,
511512 cancel: cancel,
513+ windows: []*window{win},
512514 }
513515 d.sessions[id] = sess
516+ d.startWindowGoroutines(sess, win)
517+ return sess, nil
518+}
514519
520+func (d *Daemon) createWindow(sess *session, sz domain.Size) error {
521+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), sz)
522+ if err != nil {
523+ return fmt.Errorf("daemon: spawning window for session %q: %w", sess.name, err)
524+ }
525+ win := &window{
526+ pty: pty,
527+ screen: vt.NewScreen(sz.Cols, sz.Rows),
528+ dirty: make(chan struct{}, 1),
529+ size: sz,
530+ }
531+ sess.mu.Lock()
532+ sess.windows = append(sess.windows, win)
533+ sess.active = len(sess.windows) - 1
534+ sess.mu.Unlock()
535+ d.startWindowGoroutines(sess, win)
536+ return nil
537+}
538+
539+func (d *Daemon) startWindowGoroutines(sess *session, win *window) {
515540 d.sessWg.Add(2)
516- go d.ptyReader(sess)
517- go d.scheduler(sess)
518- return sess, nil
541+ go d.ptyReader(sess, win)
542+ go d.scheduler(sess, win)
519543 }
520544
521545 // attachClient makes ac the session's current client, displacing any prior one
@@ -544,7 +568,10 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size)
544568 // the window size differs from the client's it resizes first (which paints a
545569 // full redraw), otherwise it forces a full paint against the fresh renderer.
546570 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
547- win := sess.win
571+ win := sess.activeWindow()
572+ if win == nil {
573+ return
574+ }
548575 win.mu.Lock()
549576 wsz := win.size
550577 win.mu.Unlock()
@@ -559,7 +586,6 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
559586 // runConnLoop is the per-connection input router: it pumps client messages
560587 // until detach, EOF, or a transport error.
561588 func (d *Daemon) runConnLoop(sess *session, ac *attachedClient) {
562- win := sess.win
563589 for {
564590 f, err := ac.tr.Recv()
565591 if err != nil {
@@ -569,7 +595,7 @@ func (d *Daemon) runConnLoop(sess *session, ac *attachedClient) {
569595 switch f.Type {
570596 case ports.MsgInput:
571597 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
572- _, _ = win.pty.Write(in.Data)
598+ d.handleInput(sess, ac, in.Data)
573599 }
574600 case ports.MsgResize:
575601 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {
@@ -619,12 +645,112 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool {
619645 return false
620646 }
621647
648+func (s *session) activeWindow() *window {
649+ s.mu.Lock()
650+ defer s.mu.Unlock()
651+ if s.active < 0 || s.active >= len(s.windows) {
652+ return nil
653+ }
654+ return s.windows[s.active]
655+}
656+
657+func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {
658+ switch string(data) {
659+ case "\x1bc":
660+ if err := d.createWindow(sess, ac.size); err != nil {
661+ d.log.Error("create window failed", "err", err, "session", sess.name)
662+ return
663+ }
664+ d.paint(sess, ac, true)
665+ return
666+ case "\x1b1", "\x1b2", "\x1b3", "\x1b4", "\x1b5", "\x1b6", "\x1b7", "\x1b8", "\x1b9":
667+ idx := int(data[1] - '1')
668+ if sess.switchWindow(idx) {
669+ d.paint(sess, ac, true)
670+ }
671+ return
672+ case "\x1bn":
673+ if sess.switchRelative(1) {
674+ d.paint(sess, ac, true)
675+ }
676+ return
677+ case "\x1bp":
678+ if sess.switchRelative(-1) {
679+ d.paint(sess, ac, true)
680+ }
681+ return
682+ case "\x1bx":
683+ win := sess.activeWindow()
684+ if win != nil {
685+ d.closeWindow(sess, win, true)
686+ }
687+ return
688+ }
689+ win := sess.activeWindow()
690+ if win == nil {
691+ return
692+ }
693+ _, _ = win.pty.Write(data)
694+}
695+
696+func (s *session) switchWindow(idx int) bool {
697+ s.mu.Lock()
698+ defer s.mu.Unlock()
699+ if idx < 0 || idx >= len(s.windows) || idx == s.active {
700+ return false
701+ }
702+ s.active = idx
703+ return true
704+}
705+
706+func (s *session) switchRelative(delta int) bool {
707+ s.mu.Lock()
708+ defer s.mu.Unlock()
709+ if len(s.windows) < 2 {
710+ return false
711+ }
712+ s.active = (s.active + delta + len(s.windows)) % len(s.windows)
713+ return true
714+}
715+
716+func (d *Daemon) closeWindow(sess *session, win *window, repaint bool) {
717+ sess.mu.Lock()
718+ idx := -1
719+ for i, w := range sess.windows {
720+ if w == win {
721+ idx = i
722+ break
723+ }
724+ }
725+ if idx == -1 {
726+ sess.mu.Unlock()
727+ return
728+ }
729+ if len(sess.windows) == 1 {
730+ sess.mu.Unlock()
731+ d.killSession(sess, ports.ReasonSessionKilled)
732+ return
733+ }
734+ sess.windows = append(sess.windows[:idx], sess.windows[idx+1:]...)
735+ if sess.active >= len(sess.windows) {
736+ sess.active = len(sess.windows) - 1
737+ } else if idx < sess.active {
738+ sess.active--
739+ }
740+ ac := sess.client
741+ sess.mu.Unlock()
742+
743+ _ = win.pty.Close()
744+ if repaint && ac != nil {
745+ d.paint(sess, ac, true)
746+ }
747+}
748+
622749 // ptyReader drains child output into the VT screen and pokes the dirty channel
623750 // (non-blocking: a full channel already means a render is pending). On any read
624751 // error (EOF when the child exits) it kills the session.
625-func (d *Daemon) ptyReader(sess *session) {
752+func (d *Daemon) ptyReader(sess *session, win *window) {
626753 defer d.sessWg.Done()
627- win := sess.win
628754 buf := make([]byte, ptyReadBufSize)
629755 for {
630756 n, err := win.pty.Read(buf)
@@ -638,7 +764,7 @@ func (d *Daemon) ptyReader(sess *session) {
638764 }
639765 }
640766 if err != nil {
641- d.killSession(sess, ports.ReasonSessionKilled)
767+ d.closeWindow(sess, win, false)
642768 return
643769 }
644770 }
@@ -646,9 +772,8 @@ func (d *Daemon) ptyReader(sess *session) {
646772
647773 // scheduler debounces dirty signals: the first opens a fixed window during
648774 // which further dirties are absorbed, then it renders exactly once.
649-func (d *Daemon) scheduler(sess *session) {
775+func (d *Daemon) scheduler(sess *session, win *window) {
650776 defer d.sessWg.Done()
651- win := sess.win
652777 for {
653778 select {
654779 case <-sess.ctx.Done():
@@ -669,19 +794,19 @@ func (d *Daemon) scheduler(sess *session) {
669794 break absorb
670795 }
671796 }
672- d.render(sess)
797+ d.render(sess, win)
673798 }
674799 }
675800
676801 // render paints the current client, or (when detached) just clears accumulated
677802 // damage so it never grows unbounded while headless.
678-func (d *Daemon) render(sess *session) {
679- win := sess.win
803+func (d *Daemon) render(sess *session, win *window) {
680804 sess.mu.Lock()
681805 ac := sess.client
806+ active := sess.active >= 0 && sess.active < len(sess.windows) && sess.windows[sess.active] == win
682807 sess.mu.Unlock()
683808
684- if ac == nil {
809+ if ac == nil || !active {
685810 win.mu.Lock()
686811 win.screen.ClearDamage()
687812 win.mu.Unlock()
@@ -694,7 +819,10 @@ func (d *Daemon) render(sess *session) {
694819 // shadow is read/written under win.mu; sendMu makes Draw→Send atomic per client
695820 // so the scheduler and a concurrent resize never interleave their output.
696821 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
697- win := sess.win
822+ win := sess.activeWindow()
823+ if win == nil {
824+ return
825+ }
698826
699827 ac.sendMu.Lock()
700828 win.mu.Lock()
@@ -727,7 +855,10 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
727855 if !sz.Valid() {
728856 return
729857 }
730- win := sess.win
858+ win := sess.activeWindow()
859+ if win == nil {
860+ return
861+ }
731862
732863 if err := win.pty.Resize(sz); err != nil {
733864 d.log.Warn("pty resize failed", "err", err, "session", sess.name)
@@ -800,7 +931,12 @@ func (d *Daemon) killSession(sess *session, reason uint8) {
800931 sess.mu.Unlock()
801932
802933 sess.cancel()
803- _ = sess.win.pty.Close()
934+ sess.mu.Lock()
935+ windows := append([]*window(nil), sess.windows...)
936+ sess.mu.Unlock()
937+ for _, win := range windows {
938+ _ = win.pty.Close()
939+ }
804940 if empty {
805941 d.doneOnce.Do(func() { close(d.done) })
806942 }
@@ -102,19 +102,20 @@ type Daemon struct {
102 connWg sync.WaitGroup // per-connection handler goroutines102 connWg sync.WaitGroup // per-connection handler goroutines
103 }103 }
104 104
105-// session is a single multiplexed session. For the MVP it owns exactly one105+// session is a single multiplexed session. It owns one or more full-screen
106-// window and at most one attached client.106+// windows and at most one attached client.
107 type session struct {107 type session struct {
108 id domain.SessionID108 id domain.SessionID
109 name string109 name string
110 ephemeral bool110 ephemeral bool
111- win *window
112 111
113 ctx context.Context112 ctx context.Context
114 cancel context.CancelFunc113 cancel context.CancelFunc
115 114
116- mu sync.Mutex // guards client115+ mu sync.Mutex // guards windows, active, and client
117- client *attachedClient116+ windows []*window
117+ active int
118+ client *attachedClient
118 }119 }
119 120
120 // window is one PTY-backed screen. dirty is a cap-1 channel so bursty output121 // window is one PTY-backed screen. dirty is a cap-1 channel so bursty output
@@ -348,11 +349,12 @@ func (d *Daemon) handleList(tr ports.Transport) {
348 s.mu.Lock()349 s.mu.Lock()
349 attached := s.client != nil350 attached := s.client != nil
350 s.mu.Unlock()351 s.mu.Unlock()
352+ windows := len(s.windows)
351 infos = append(infos, ports.SessionInfo{353 infos = append(infos, ports.SessionInfo{
352 SessionID: string(s.id),354 SessionID: string(s.id),
353 Name: s.name,355 Name: s.name,
354 Ephemeral: s.ephemeral,356 Ephemeral: s.ephemeral,
355- Windows: 1,357+ Windows: uint16(windows),
356 Attached: attached,358 Attached: attached,
357 })359 })
358 }360 }
@@ -506,16 +508,38 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size
506 id: id,508 id: id,
507 name: name,509 name: name,
508 ephemeral: ephemeral,510 ephemeral: ephemeral,
509- win: win,
510 ctx: sctx,511 ctx: sctx,
511 cancel: cancel,512 cancel: cancel,
513+ windows: []*window{win},
512 }514 }
513 d.sessions[id] = sess515 d.sessions[id] = sess
516+ d.startWindowGoroutines(sess, win)
517+ return sess, nil
518+}
514 519
520+func (d *Daemon) createWindow(sess *session, sz domain.Size) error {
521+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), sz)
522+ if err != nil {
523+ return fmt.Errorf("daemon: spawning window for session %q: %w", sess.name, err)
524+ }
525+ win := &window{
526+ pty: pty,
527+ screen: vt.NewScreen(sz.Cols, sz.Rows),
528+ dirty: make(chan struct{}, 1),
529+ size: sz,
530+ }
531+ sess.mu.Lock()
532+ sess.windows = append(sess.windows, win)
533+ sess.active = len(sess.windows) - 1
534+ sess.mu.Unlock()
535+ d.startWindowGoroutines(sess, win)
536+ return nil
537+}
538+
539+func (d *Daemon) startWindowGoroutines(sess *session, win *window) {
515 d.sessWg.Add(2)540 d.sessWg.Add(2)
516- go d.ptyReader(sess)541+ go d.ptyReader(sess, win)
517- go d.scheduler(sess)542+ go d.scheduler(sess, win)
518- return sess, nil
519 }543 }
520 544
521 // attachClient makes ac the session's current client, displacing any prior one545 // attachClient makes ac the session's current client, displacing any prior one
@@ -544,7 +568,10 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size)
544 // the window size differs from the client's it resizes first (which paints a568 // the window size differs from the client's it resizes first (which paints a
545 // full redraw), otherwise it forces a full paint against the fresh renderer.569 // full redraw), otherwise it forces a full paint against the fresh renderer.
546 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {570 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
547- win := sess.win571+ win := sess.activeWindow()
572+ if win == nil {
573+ return
574+ }
548 win.mu.Lock()575 win.mu.Lock()
549 wsz := win.size576 wsz := win.size
550 win.mu.Unlock()577 win.mu.Unlock()
@@ -559,7 +586,6 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
559 // runConnLoop is the per-connection input router: it pumps client messages586 // runConnLoop is the per-connection input router: it pumps client messages
560 // until detach, EOF, or a transport error.587 // until detach, EOF, or a transport error.
561 func (d *Daemon) runConnLoop(sess *session, ac *attachedClient) {588 func (d *Daemon) runConnLoop(sess *session, ac *attachedClient) {
562- win := sess.win
563 for {589 for {
564 f, err := ac.tr.Recv()590 f, err := ac.tr.Recv()
565 if err != nil {591 if err != nil {
@@ -569,7 +595,7 @@ func (d *Daemon) runConnLoop(sess *session, ac *attachedClient) {
569 switch f.Type {595 switch f.Type {
570 case ports.MsgInput:596 case ports.MsgInput:
571 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {597 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
572- _, _ = win.pty.Write(in.Data)598+ d.handleInput(sess, ac, in.Data)
573 }599 }
574 case ports.MsgResize:600 case ports.MsgResize:
575 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {601 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {
@@ -619,12 +645,112 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool {
619 return false645 return false
620 }646 }
621 647
648+func (s *session) activeWindow() *window {
649+ s.mu.Lock()
650+ defer s.mu.Unlock()
651+ if s.active < 0 || s.active >= len(s.windows) {
652+ return nil
653+ }
654+ return s.windows[s.active]
655+}
656+
657+func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {
658+ switch string(data) {
659+ case "\x1bc":
660+ if err := d.createWindow(sess, ac.size); err != nil {
661+ d.log.Error("create window failed", "err", err, "session", sess.name)
662+ return
663+ }
664+ d.paint(sess, ac, true)
665+ return
666+ case "\x1b1", "\x1b2", "\x1b3", "\x1b4", "\x1b5", "\x1b6", "\x1b7", "\x1b8", "\x1b9":
667+ idx := int(data[1] - '1')
668+ if sess.switchWindow(idx) {
669+ d.paint(sess, ac, true)
670+ }
671+ return
672+ case "\x1bn":
673+ if sess.switchRelative(1) {
674+ d.paint(sess, ac, true)
675+ }
676+ return
677+ case "\x1bp":
678+ if sess.switchRelative(-1) {
679+ d.paint(sess, ac, true)
680+ }
681+ return
682+ case "\x1bx":
683+ win := sess.activeWindow()
684+ if win != nil {
685+ d.closeWindow(sess, win, true)
686+ }
687+ return
688+ }
689+ win := sess.activeWindow()
690+ if win == nil {
691+ return
692+ }
693+ _, _ = win.pty.Write(data)
694+}
695+
696+func (s *session) switchWindow(idx int) bool {
697+ s.mu.Lock()
698+ defer s.mu.Unlock()
699+ if idx < 0 || idx >= len(s.windows) || idx == s.active {
700+ return false
701+ }
702+ s.active = idx
703+ return true
704+}
705+
706+func (s *session) switchRelative(delta int) bool {
707+ s.mu.Lock()
708+ defer s.mu.Unlock()
709+ if len(s.windows) < 2 {
710+ return false
711+ }
712+ s.active = (s.active + delta + len(s.windows)) % len(s.windows)
713+ return true
714+}
715+
716+func (d *Daemon) closeWindow(sess *session, win *window, repaint bool) {
717+ sess.mu.Lock()
718+ idx := -1
719+ for i, w := range sess.windows {
720+ if w == win {
721+ idx = i
722+ break
723+ }
724+ }
725+ if idx == -1 {
726+ sess.mu.Unlock()
727+ return
728+ }
729+ if len(sess.windows) == 1 {
730+ sess.mu.Unlock()
731+ d.killSession(sess, ports.ReasonSessionKilled)
732+ return
733+ }
734+ sess.windows = append(sess.windows[:idx], sess.windows[idx+1:]...)
735+ if sess.active >= len(sess.windows) {
736+ sess.active = len(sess.windows) - 1
737+ } else if idx < sess.active {
738+ sess.active--
739+ }
740+ ac := sess.client
741+ sess.mu.Unlock()
742+
743+ _ = win.pty.Close()
744+ if repaint && ac != nil {
745+ d.paint(sess, ac, true)
746+ }
747+}
748+
622 // ptyReader drains child output into the VT screen and pokes the dirty channel749 // ptyReader drains child output into the VT screen and pokes the dirty channel
623 // (non-blocking: a full channel already means a render is pending). On any read750 // (non-blocking: a full channel already means a render is pending). On any read
624 // error (EOF when the child exits) it kills the session.751 // error (EOF when the child exits) it kills the session.
625-func (d *Daemon) ptyReader(sess *session) {752+func (d *Daemon) ptyReader(sess *session, win *window) {
626 defer d.sessWg.Done()753 defer d.sessWg.Done()
627- win := sess.win
628 buf := make([]byte, ptyReadBufSize)754 buf := make([]byte, ptyReadBufSize)
629 for {755 for {
630 n, err := win.pty.Read(buf)756 n, err := win.pty.Read(buf)
@@ -638,7 +764,7 @@ func (d *Daemon) ptyReader(sess *session) {
638 }764 }
639 }765 }
640 if err != nil {766 if err != nil {
641- d.killSession(sess, ports.ReasonSessionKilled)767+ d.closeWindow(sess, win, false)
642 return768 return
643 }769 }
644 }770 }
@@ -646,9 +772,8 @@ func (d *Daemon) ptyReader(sess *session) {
646 772
647 // scheduler debounces dirty signals: the first opens a fixed window during773 // scheduler debounces dirty signals: the first opens a fixed window during
648 // which further dirties are absorbed, then it renders exactly once.774 // which further dirties are absorbed, then it renders exactly once.
649-func (d *Daemon) scheduler(sess *session) {775+func (d *Daemon) scheduler(sess *session, win *window) {
650 defer d.sessWg.Done()776 defer d.sessWg.Done()
651- win := sess.win
652 for {777 for {
653 select {778 select {
654 case <-sess.ctx.Done():779 case <-sess.ctx.Done():
@@ -669,19 +794,19 @@ func (d *Daemon) scheduler(sess *session) {
669 break absorb794 break absorb
670 }795 }
671 }796 }
672- d.render(sess)797+ d.render(sess, win)
673 }798 }
674 }799 }
675 800
676 // render paints the current client, or (when detached) just clears accumulated801 // render paints the current client, or (when detached) just clears accumulated
677 // damage so it never grows unbounded while headless.802 // damage so it never grows unbounded while headless.
678-func (d *Daemon) render(sess *session) {803+func (d *Daemon) render(sess *session, win *window) {
679- win := sess.win
680 sess.mu.Lock()804 sess.mu.Lock()
681 ac := sess.client805 ac := sess.client
806+ active := sess.active >= 0 && sess.active < len(sess.windows) && sess.windows[sess.active] == win
682 sess.mu.Unlock()807 sess.mu.Unlock()
683 808
684- if ac == nil {809+ if ac == nil || !active {
685 win.mu.Lock()810 win.mu.Lock()
686 win.screen.ClearDamage()811 win.screen.ClearDamage()
687 win.mu.Unlock()812 win.mu.Unlock()
@@ -694,7 +819,10 @@ func (d *Daemon) render(sess *session) {
694 // shadow is read/written under win.mu; sendMu makes Draw→Send atomic per client819 // shadow is read/written under win.mu; sendMu makes Draw→Send atomic per client
695 // so the scheduler and a concurrent resize never interleave their output.820 // so the scheduler and a concurrent resize never interleave their output.
696 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {821 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
697- win := sess.win822+ win := sess.activeWindow()
823+ if win == nil {
824+ return
825+ }
698 826
699 ac.sendMu.Lock()827 ac.sendMu.Lock()
700 win.mu.Lock()828 win.mu.Lock()
@@ -727,7 +855,10 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
727 if !sz.Valid() {855 if !sz.Valid() {
728 return856 return
729 }857 }
730- win := sess.win858+ win := sess.activeWindow()
859+ if win == nil {
860+ return
861+ }
731 862
732 if err := win.pty.Resize(sz); err != nil {863 if err := win.pty.Resize(sz); err != nil {
733 d.log.Warn("pty resize failed", "err", err, "session", sess.name)864 d.log.Warn("pty resize failed", "err", err, "session", sess.name)
@@ -800,7 +931,12 @@ func (d *Daemon) killSession(sess *session, reason uint8) {
800 sess.mu.Unlock()931 sess.mu.Unlock()
801 932
802 sess.cancel()933 sess.cancel()
803- _ = sess.win.pty.Close()934+ sess.mu.Lock()
935+ windows := append([]*window(nil), sess.windows...)
936+ sess.mu.Unlock()
937+ for _, win := range windows {
938+ _ = win.pty.Close()
939+ }
804 if empty {940 if empty {
805 d.doneOnce.Do(func() { close(d.done) })941 d.doneOnce.Do(func() { close(d.done) })
806 }942 }
M internal/usecase/daemon/daemon_test.go +220 -5
@@ -48,6 +48,11 @@ func newTestDaemon(t *testing.T, ptys ports.PTYFactory, clk ports.Clock) *Daemon
4848 // newBlockingPTY returns a MockPTY whose Read blocks until release is called,
4949 // then reports io.EOF (the "child exited" signal).
5050 func newBlockingPTY(t *testing.T) (*portsmocks.MockPTY, func()) {
51+ t.Helper()
52+ return newBlockingPTYWithWrites(t, nil)
53+}
54+
55+func newBlockingPTYWithWrites(t *testing.T, writes chan<- []byte) (*portsmocks.MockPTY, func()) {
5156 t.Helper()
5257 p := portsmocks.NewMockPTY(t)
5358 release := make(chan struct{})
@@ -57,7 +62,13 @@ func newBlockingPTY(t *testing.T) (*portsmocks.MockPTY, func()) {
5762 <-release
5863 return 0, io.EOF
5964 }).Maybe()
60- p.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) { return len(b), nil }).Maybe()
65+ p.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
66+ if writes != nil {
67+ cp := append([]byte(nil), b...)
68+ writes <- cp
69+ }
70+ return len(b), nil
71+ }).Maybe()
6172 p.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
6273 // Close unblocks a parked Read, exactly as the real PTY does (its Close
6374 // closes the master fd, failing the in-flight read).
@@ -73,6 +84,26 @@ func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory {
7384 return f
7485 }
7586
87+func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory {
88+ t.Helper()
89+ f := portsmocks.NewMockPTYFactory(t)
90+ var mu sync.Mutex
91+ next := 0
92+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
93+ func(string, []string, []string, domain.Size) (ports.PTY, error) {
94+ mu.Lock()
95+ defer mu.Unlock()
96+ if next >= len(ptys) {
97+ t.Fatalf("unexpected PTY open #%d", next+1)
98+ }
99+ p := ptys[next]
100+ next++
101+ return p, nil
102+ },
103+ ).Maybe()
104+ return f
105+}
106+
76107 // newConn scripts a MockTransport: Recv yields first then more (in order),
77108 // then blocks until the connection is released or Closed (returning io.EOF).
78109 // Every Send is captured on the returned channel.
@@ -111,6 +142,10 @@ func mustHello(intent uint8, name string, sz domain.Size) ports.Frame {
111142 })}
112143 }
113144
145+func frameInput(data []byte) ports.Frame {
146+ return ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})}
147+}
148+
114149 // awaitFrame waits for the next frame of type typ on ch, failing on timeout.
115150 func awaitFrame(t *testing.T, ch chan ports.Frame, typ ports.MsgType) ports.Frame {
116151 t.Helper()
@@ -142,6 +177,63 @@ func sessionCount(d *Daemon) int {
142177 return len(d.sessions)
143178 }
144179
180+func listSessions(t *testing.T, d *Daemon) ports.Sessions {
181+ t.Helper()
182+ tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})})
183+ d.handleList(tr)
184+ f := awaitFrame(t, sends, ports.MsgSessions)
185+ sessions, err := ports.UnmarshalSessions(f.Payload)
186+ require.NoError(t, err)
187+ return sessions
188+}
189+
190+func newCapturingTransport(t *testing.T) (*portsmocks.MockTransport, chan ports.Frame) {
191+ t.Helper()
192+ tr := portsmocks.NewMockTransport(t)
193+ sends := make(chan ports.Frame, 64)
194+ tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
195+ sends <- f
196+ return nil
197+ }).Maybe()
198+ tr.EXPECT().Close().Return(nil).Maybe()
199+ return tr, sends
200+}
201+
202+func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {
203+ t.Helper()
204+ d := newTestDaemon(t, nil, stubClock{})
205+ tr, sends := newCapturingTransport(t)
206+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
207+ sctx, cancel := context.WithCancel(d.serveCtx)
208+ windows := make([]*window, 0, len(ptys))
209+ for _, p := range ptys {
210+ windows = append(windows, &window{pty: p, screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}})
211+ }
212+ sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, windows: windows, client: ac}
213+ d.sessions[sess.id] = sess
214+ t.Cleanup(cancel)
215+ return d, sess, ac, sends
216+}
217+
218+func newManualWindowSession(t *testing.T, n int) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
219+ t.Helper()
220+ ptys := make([]ports.PTY, 0, n)
221+ releases := make([]func(), 0, n)
222+ for range n {
223+ p, release := newBlockingPTY(t)
224+ ptys = append(ptys, p)
225+ releases = append(releases, release)
226+ }
227+ d, sess, ac, sends := newManualSessionWithPTYs(t, ptys...)
228+ return d, sess, ac, sends, releases
229+}
230+
231+func activeWindowIndex(sess *session) int {
232+ sess.mu.Lock()
233+ defer sess.mu.Unlock()
234+ return sess.active
235+}
236+
145237 // --- handshake --------------------------------------------------------------
146238
147239 func TestHandshakeEphemeralHappy(t *testing.T) {
@@ -247,6 +339,129 @@ func TestEphemeralNumberingReuse(t *testing.T) {
247339
248340 // --- attach replace ---------------------------------------------------------
249341
342+func TestAltDigitSwitchesActiveWindow(t *testing.T) {
343+ writes1 := make(chan []byte, 1)
344+ writes2 := make(chan []byte, 1)
345+ p1, releasePTY1 := newBlockingPTYWithWrites(t, writes1)
346+ p2, releasePTY2 := newBlockingPTYWithWrites(t, writes2)
347+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
348+ tr, sends, releaseConn := newConn(t,
349+ mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
350+ frameInput([]byte("\x1bc")),
351+ frameInput([]byte("\x1b1")),
352+ frameInput([]byte("A")),
353+ frameInput([]byte("\x1b2")),
354+ frameInput([]byte("B")),
355+ )
356+
357+ var hg sync.WaitGroup
358+ hg.Go(func() { d.handleConn(tr) })
359+ awaitFrame(t, sends, ports.MsgWelcome)
360+ awaitFrame(t, sends, ports.MsgOutput)
361+
362+ require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond)
363+ require.Equal(t, []byte("A"), <-writes1)
364+ require.Eventually(t, func() bool { return len(writes2) == 1 }, 2*time.Second, 5*time.Millisecond)
365+ require.Equal(t, []byte("B"), <-writes2)
366+
367+ releaseConn()
368+ releasePTY1()
369+ releasePTY2()
370+ hg.Wait()
371+ d.sessWg.Wait()
372+}
373+
374+func TestAltCCreatesSecondActiveWindow(t *testing.T) {
375+ p1, releasePTY1 := newBlockingPTY(t)
376+ p2, releasePTY2 := newBlockingPTY(t)
377+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
378+ tr, sends, releaseConn := newConn(t,
379+ mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
380+ frameInput([]byte("\x1bc")),
381+ )
382+
383+ var hg sync.WaitGroup
384+ hg.Go(func() { d.handleConn(tr) })
385+ awaitFrame(t, sends, ports.MsgWelcome)
386+ awaitFrame(t, sends, ports.MsgOutput)
387+
388+ require.Eventually(t, func() bool {
389+ sessions := listSessions(t, d)
390+ return len(sessions.Sessions) == 1 && sessions.Sessions[0].Windows == 2
391+ }, 2*time.Second, 5*time.Millisecond)
392+
393+ releaseConn()
394+ releasePTY1()
395+ releasePTY2()
396+ hg.Wait()
397+ d.sessWg.Wait()
398+}
399+
400+func TestAltNextPreviousSwitchActiveWindow(t *testing.T) {
401+ cases := []struct {
402+ name string
403+ start int
404+ input []byte
405+ wantIndex int
406+ }{
407+ {name: "next advances", start: 0, input: []byte("\x1bn"), wantIndex: 1},
408+ {name: "next wraps", start: 2, input: []byte("\x1bn"), wantIndex: 0},
409+ {name: "previous moves back", start: 2, input: []byte("\x1bp"), wantIndex: 1},
410+ {name: "previous wraps", start: 0, input: []byte("\x1bp"), wantIndex: 2},
411+ }
412+
413+ for _, tc := range cases {
414+ t.Run(tc.name, func(t *testing.T) {
415+ d, sess, ac, _, releases := newManualWindowSession(t, 3)
416+ defer func() {
417+ for _, release := range releases {
418+ release()
419+ }
420+ }()
421+ sess.active = tc.start
422+
423+ d.handleInput(sess, ac, tc.input)
424+
425+ require.Equal(t, tc.wantIndex, activeWindowIndex(sess))
426+ })
427+ }
428+}
429+
430+func TestAltXClosesActiveWindowAndSelectsRemaining(t *testing.T) {
431+ writes := make(chan []byte, 1)
432+ p1, releasePTY1 := newBlockingPTY(t)
433+ p2, releasePTY2 := newBlockingPTY(t)
434+ p3, releasePTY3 := newBlockingPTYWithWrites(t, writes)
435+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2, p3)
436+ sess.active = 1
437+
438+ d.handleInput(sess, ac, []byte("\x1bx"))
439+
440+ require.Equal(t, 1, sessionCount(d))
441+ require.Len(t, sess.windows, 2)
442+ require.Equal(t, 1, activeWindowIndex(sess), "closing middle window selects the next remaining window")
443+ d.handleInput(sess, ac, []byte("Z"))
444+ require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond)
445+ require.Equal(t, []byte("Z"), <-writes)
446+
447+ releasePTY1()
448+ releasePTY2()
449+ releasePTY3()
450+}
451+
452+func TestAltXClosesFinalWindowAndDetaches(t *testing.T) {
453+ d, sess, ac, sends, releases := newManualWindowSession(t, 1)
454+ defer releases[0]()
455+
456+ d.handleInput(sess, ac, []byte("\x1bx"))
457+
458+ require.Equal(t, 0, sessionCount(d))
459+ f := awaitFrame(t, sends, ports.MsgDetached)
460+ det, err := ports.UnmarshalDetached(f.Payload)
461+ require.NoError(t, err)
462+ require.Equal(t, ports.ReasonSessionKilled, det.Reason)
463+}
464+
250465 func TestAttachReplaceDetachesOld(t *testing.T) {
251466 p, releasePTY := newBlockingPTY(t)
252467 d := newTestDaemon(t, newFactory(t, p), stubClock{})
@@ -368,14 +583,14 @@ func TestSchedulerDebounceCoalesces(t *testing.T) {
368583 sctx, cancel := context.WithCancel(context.Background())
369584 win := &window{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
370585 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
371- sess := &session{id: "s", name: "s", win: win, ctx: sctx, cancel: cancel, client: ac}
586+ sess := &session{id: "s", name: "s", windows: []*window{win}, ctx: sctx, cancel: cancel, client: ac}
372587
373588 win.mu.Lock()
374589 win.screen.Write([]byte("hi"))
375590 win.mu.Unlock()
376591
377592 d.sessWg.Add(1)
378- go d.scheduler(sess)
593+ go d.scheduler(sess, win)
379594
380595 // First dirty opens the debounce window.
381596 win.dirty <- struct{}{}
@@ -424,7 +639,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
424639
425640 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
426641 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
427- sess := &session{id: "s", name: "s", win: win, client: ac}
642+ sess := &session{id: "s", name: "s", windows: []*window{win}, client: ac}
428643
429644 d.resize(sess, ac, newSize)
430645
@@ -717,7 +932,7 @@ func TestSendErrorKillsEphemeral(t *testing.T) {
717932 win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
718933 sctx, cancel := context.WithCancel(context.Background())
719934 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
720- sess := &session{id: "e", name: "0", ephemeral: true, win: win, ctx: sctx, cancel: cancel, client: ac}
935+ sess := &session{id: "e", name: "0", ephemeral: true, windows: []*window{win}, ctx: sctx, cancel: cancel, client: ac}
721936 d.sessions[sess.id] = sess
722937
723938 win.mu.Lock()
@@ -48,6 +48,11 @@ func newTestDaemon(t *testing.T, ptys ports.PTYFactory, clk ports.Clock) *Daemon
48 // newBlockingPTY returns a MockPTY whose Read blocks until release is called,48 // newBlockingPTY returns a MockPTY whose Read blocks until release is called,
49 // then reports io.EOF (the "child exited" signal).49 // then reports io.EOF (the "child exited" signal).
50 func newBlockingPTY(t *testing.T) (*portsmocks.MockPTY, func()) {50 func newBlockingPTY(t *testing.T) (*portsmocks.MockPTY, func()) {
51+ t.Helper()
52+ return newBlockingPTYWithWrites(t, nil)
53+}
54+
55+func newBlockingPTYWithWrites(t *testing.T, writes chan<- []byte) (*portsmocks.MockPTY, func()) {
51 t.Helper()56 t.Helper()
52 p := portsmocks.NewMockPTY(t)57 p := portsmocks.NewMockPTY(t)
53 release := make(chan struct{})58 release := make(chan struct{})
@@ -57,7 +62,13 @@ func newBlockingPTY(t *testing.T) (*portsmocks.MockPTY, func()) {
57 <-release62 <-release
58 return 0, io.EOF63 return 0, io.EOF
59 }).Maybe()64 }).Maybe()
60- p.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) { return len(b), nil }).Maybe()65+ p.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
66+ if writes != nil {
67+ cp := append([]byte(nil), b...)
68+ writes <- cp
69+ }
70+ return len(b), nil
71+ }).Maybe()
61 p.EXPECT().Resize(mock.Anything).Return(nil).Maybe()72 p.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
62 // Close unblocks a parked Read, exactly as the real PTY does (its Close73 // Close unblocks a parked Read, exactly as the real PTY does (its Close
63 // closes the master fd, failing the in-flight read).74 // closes the master fd, failing the in-flight read).
@@ -73,6 +84,26 @@ func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory {
73 return f84 return f
74 }85 }
75 86
87+func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory {
88+ t.Helper()
89+ f := portsmocks.NewMockPTYFactory(t)
90+ var mu sync.Mutex
91+ next := 0
92+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
93+ func(string, []string, []string, domain.Size) (ports.PTY, error) {
94+ mu.Lock()
95+ defer mu.Unlock()
96+ if next >= len(ptys) {
97+ t.Fatalf("unexpected PTY open #%d", next+1)
98+ }
99+ p := ptys[next]
100+ next++
101+ return p, nil
102+ },
103+ ).Maybe()
104+ return f
105+}
106+
76 // newConn scripts a MockTransport: Recv yields first then more (in order),107 // newConn scripts a MockTransport: Recv yields first then more (in order),
77 // then blocks until the connection is released or Closed (returning io.EOF).108 // then blocks until the connection is released or Closed (returning io.EOF).
78 // Every Send is captured on the returned channel.109 // Every Send is captured on the returned channel.
@@ -111,6 +142,10 @@ func mustHello(intent uint8, name string, sz domain.Size) ports.Frame {
111 })}142 })}
112 }143 }
113 144
145+func frameInput(data []byte) ports.Frame {
146+ return ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})}
147+}
148+
114 // awaitFrame waits for the next frame of type typ on ch, failing on timeout.149 // awaitFrame waits for the next frame of type typ on ch, failing on timeout.
115 func awaitFrame(t *testing.T, ch chan ports.Frame, typ ports.MsgType) ports.Frame {150 func awaitFrame(t *testing.T, ch chan ports.Frame, typ ports.MsgType) ports.Frame {
116 t.Helper()151 t.Helper()
@@ -142,6 +177,63 @@ func sessionCount(d *Daemon) int {
142 return len(d.sessions)177 return len(d.sessions)
143 }178 }
144 179
180+func listSessions(t *testing.T, d *Daemon) ports.Sessions {
181+ t.Helper()
182+ tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgList, Payload: ports.MarshalList(ports.List{})})
183+ d.handleList(tr)
184+ f := awaitFrame(t, sends, ports.MsgSessions)
185+ sessions, err := ports.UnmarshalSessions(f.Payload)
186+ require.NoError(t, err)
187+ return sessions
188+}
189+
190+func newCapturingTransport(t *testing.T) (*portsmocks.MockTransport, chan ports.Frame) {
191+ t.Helper()
192+ tr := portsmocks.NewMockTransport(t)
193+ sends := make(chan ports.Frame, 64)
194+ tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
195+ sends <- f
196+ return nil
197+ }).Maybe()
198+ tr.EXPECT().Close().Return(nil).Maybe()
199+ return tr, sends
200+}
201+
202+func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {
203+ t.Helper()
204+ d := newTestDaemon(t, nil, stubClock{})
205+ tr, sends := newCapturingTransport(t)
206+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
207+ sctx, cancel := context.WithCancel(d.serveCtx)
208+ windows := make([]*window, 0, len(ptys))
209+ for _, p := range ptys {
210+ windows = append(windows, &window{pty: p, screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}})
211+ }
212+ sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, windows: windows, client: ac}
213+ d.sessions[sess.id] = sess
214+ t.Cleanup(cancel)
215+ return d, sess, ac, sends
216+}
217+
218+func newManualWindowSession(t *testing.T, n int) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
219+ t.Helper()
220+ ptys := make([]ports.PTY, 0, n)
221+ releases := make([]func(), 0, n)
222+ for range n {
223+ p, release := newBlockingPTY(t)
224+ ptys = append(ptys, p)
225+ releases = append(releases, release)
226+ }
227+ d, sess, ac, sends := newManualSessionWithPTYs(t, ptys...)
228+ return d, sess, ac, sends, releases
229+}
230+
231+func activeWindowIndex(sess *session) int {
232+ sess.mu.Lock()
233+ defer sess.mu.Unlock()
234+ return sess.active
235+}
236+
145 // --- handshake --------------------------------------------------------------237 // --- handshake --------------------------------------------------------------
146 238
147 func TestHandshakeEphemeralHappy(t *testing.T) {239 func TestHandshakeEphemeralHappy(t *testing.T) {
@@ -247,6 +339,129 @@ func TestEphemeralNumberingReuse(t *testing.T) {
247 339
248 // --- attach replace ---------------------------------------------------------340 // --- attach replace ---------------------------------------------------------
249 341
342+func TestAltDigitSwitchesActiveWindow(t *testing.T) {
343+ writes1 := make(chan []byte, 1)
344+ writes2 := make(chan []byte, 1)
345+ p1, releasePTY1 := newBlockingPTYWithWrites(t, writes1)
346+ p2, releasePTY2 := newBlockingPTYWithWrites(t, writes2)
347+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
348+ tr, sends, releaseConn := newConn(t,
349+ mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
350+ frameInput([]byte("\x1bc")),
351+ frameInput([]byte("\x1b1")),
352+ frameInput([]byte("A")),
353+ frameInput([]byte("\x1b2")),
354+ frameInput([]byte("B")),
355+ )
356+
357+ var hg sync.WaitGroup
358+ hg.Go(func() { d.handleConn(tr) })
359+ awaitFrame(t, sends, ports.MsgWelcome)
360+ awaitFrame(t, sends, ports.MsgOutput)
361+
362+ require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond)
363+ require.Equal(t, []byte("A"), <-writes1)
364+ require.Eventually(t, func() bool { return len(writes2) == 1 }, 2*time.Second, 5*time.Millisecond)
365+ require.Equal(t, []byte("B"), <-writes2)
366+
367+ releaseConn()
368+ releasePTY1()
369+ releasePTY2()
370+ hg.Wait()
371+ d.sessWg.Wait()
372+}
373+
374+func TestAltCCreatesSecondActiveWindow(t *testing.T) {
375+ p1, releasePTY1 := newBlockingPTY(t)
376+ p2, releasePTY2 := newBlockingPTY(t)
377+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
378+ tr, sends, releaseConn := newConn(t,
379+ mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}),
380+ frameInput([]byte("\x1bc")),
381+ )
382+
383+ var hg sync.WaitGroup
384+ hg.Go(func() { d.handleConn(tr) })
385+ awaitFrame(t, sends, ports.MsgWelcome)
386+ awaitFrame(t, sends, ports.MsgOutput)
387+
388+ require.Eventually(t, func() bool {
389+ sessions := listSessions(t, d)
390+ return len(sessions.Sessions) == 1 && sessions.Sessions[0].Windows == 2
391+ }, 2*time.Second, 5*time.Millisecond)
392+
393+ releaseConn()
394+ releasePTY1()
395+ releasePTY2()
396+ hg.Wait()
397+ d.sessWg.Wait()
398+}
399+
400+func TestAltNextPreviousSwitchActiveWindow(t *testing.T) {
401+ cases := []struct {
402+ name string
403+ start int
404+ input []byte
405+ wantIndex int
406+ }{
407+ {name: "next advances", start: 0, input: []byte("\x1bn"), wantIndex: 1},
408+ {name: "next wraps", start: 2, input: []byte("\x1bn"), wantIndex: 0},
409+ {name: "previous moves back", start: 2, input: []byte("\x1bp"), wantIndex: 1},
410+ {name: "previous wraps", start: 0, input: []byte("\x1bp"), wantIndex: 2},
411+ }
412+
413+ for _, tc := range cases {
414+ t.Run(tc.name, func(t *testing.T) {
415+ d, sess, ac, _, releases := newManualWindowSession(t, 3)
416+ defer func() {
417+ for _, release := range releases {
418+ release()
419+ }
420+ }()
421+ sess.active = tc.start
422+
423+ d.handleInput(sess, ac, tc.input)
424+
425+ require.Equal(t, tc.wantIndex, activeWindowIndex(sess))
426+ })
427+ }
428+}
429+
430+func TestAltXClosesActiveWindowAndSelectsRemaining(t *testing.T) {
431+ writes := make(chan []byte, 1)
432+ p1, releasePTY1 := newBlockingPTY(t)
433+ p2, releasePTY2 := newBlockingPTY(t)
434+ p3, releasePTY3 := newBlockingPTYWithWrites(t, writes)
435+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2, p3)
436+ sess.active = 1
437+
438+ d.handleInput(sess, ac, []byte("\x1bx"))
439+
440+ require.Equal(t, 1, sessionCount(d))
441+ require.Len(t, sess.windows, 2)
442+ require.Equal(t, 1, activeWindowIndex(sess), "closing middle window selects the next remaining window")
443+ d.handleInput(sess, ac, []byte("Z"))
444+ require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond)
445+ require.Equal(t, []byte("Z"), <-writes)
446+
447+ releasePTY1()
448+ releasePTY2()
449+ releasePTY3()
450+}
451+
452+func TestAltXClosesFinalWindowAndDetaches(t *testing.T) {
453+ d, sess, ac, sends, releases := newManualWindowSession(t, 1)
454+ defer releases[0]()
455+
456+ d.handleInput(sess, ac, []byte("\x1bx"))
457+
458+ require.Equal(t, 0, sessionCount(d))
459+ f := awaitFrame(t, sends, ports.MsgDetached)
460+ det, err := ports.UnmarshalDetached(f.Payload)
461+ require.NoError(t, err)
462+ require.Equal(t, ports.ReasonSessionKilled, det.Reason)
463+}
464+
250 func TestAttachReplaceDetachesOld(t *testing.T) {465 func TestAttachReplaceDetachesOld(t *testing.T) {
251 p, releasePTY := newBlockingPTY(t)466 p, releasePTY := newBlockingPTY(t)
252 d := newTestDaemon(t, newFactory(t, p), stubClock{})467 d := newTestDaemon(t, newFactory(t, p), stubClock{})
@@ -368,14 +583,14 @@ func TestSchedulerDebounceCoalesces(t *testing.T) {
368 sctx, cancel := context.WithCancel(context.Background())583 sctx, cancel := context.WithCancel(context.Background())
369 win := &window{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}584 win := &window{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
370 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}585 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
371- sess := &session{id: "s", name: "s", win: win, ctx: sctx, cancel: cancel, client: ac}586+ sess := &session{id: "s", name: "s", windows: []*window{win}, ctx: sctx, cancel: cancel, client: ac}
372 587
373 win.mu.Lock()588 win.mu.Lock()
374 win.screen.Write([]byte("hi"))589 win.screen.Write([]byte("hi"))
375 win.mu.Unlock()590 win.mu.Unlock()
376 591
377 d.sessWg.Add(1)592 d.sessWg.Add(1)
378- go d.scheduler(sess)593+ go d.scheduler(sess, win)
379 594
380 // First dirty opens the debounce window.595 // First dirty opens the debounce window.
381 win.dirty <- struct{}{}596 win.dirty <- struct{}{}
@@ -424,7 +639,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
424 639
425 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))640 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
426 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}641 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
427- sess := &session{id: "s", name: "s", win: win, client: ac}642+ sess := &session{id: "s", name: "s", windows: []*window{win}, client: ac}
428 643
429 d.resize(sess, ac, newSize)644 d.resize(sess, ac, newSize)
430 645
@@ -717,7 +932,7 @@ func TestSendErrorKillsEphemeral(t *testing.T) {
717 win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}932 win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
718 sctx, cancel := context.WithCancel(context.Background())933 sctx, cancel := context.WithCancel(context.Background())
719 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}934 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
720- sess := &session{id: "e", name: "0", ephemeral: true, win: win, ctx: sctx, cancel: cancel, client: ac}935+ sess := &session{id: "e", name: "0", ephemeral: true, windows: []*window{win}, ctx: sctx, cancel: cancel, client: ac}
721 d.sessions[sess.id] = sess936 d.sessions[sess.id] = sess
722 937
723 win.mu.Lock()938 win.mu.Lock()

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 12

Tags 11

↑↓ moveenter openesc close