feat(tabs): add session tab picker
- commit
- 00a245cc8e0184d7d46cc666b47c35d6f4790fca
- author
- brice · · 2026-07-02 21:10 UTC
- parent
- 2b3374c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +1695 -365
M README.md +6 -5
| @@ -23,7 +23,7 @@ vev --help show help | |||
| 23 | 23 | vev --version show version | |
| 24 | 24 | ``` | |
| 25 | 25 | ||
| 26 | - | Ephemeral sessions get a number (0, 1, 2...) and die when you detach. Named sessions survive detach and live until killed or their last window closes. The daemon starts on first use and exits when no sessions remain. | |
| 26 | + | Ephemeral sessions get a number (0, 1, 2...) and die when you detach. Named sessions survive detach and live until killed or their last tab closes. The daemon starts on first use and exits when no sessions remain. | |
| 27 | 27 | ||
| 28 | 28 | Remote attach runs `ssh user@host vev _stdio ...` under the hood, so vev must be installed on the remote host and reachable via your normal SSH config. Each remote attach opens one ssh connection. To make reconnects near-instant, enable OpenSSH connection reuse for your hosts in `~/.ssh/config`: | |
| 29 | 29 | ||
| @@ -42,12 +42,13 @@ All bindings use the Alt modifier directly, no prefix key. | |||
| 42 | 42 | ||
| 43 | 43 | | Key | Action | | |
| 44 | 44 | |---|---| | |
| 45 | - | | Alt+c | new window | | |
| 46 | - | | Alt+n / Alt+p | next / previous window | | |
| 47 | - | | Alt+1 .. Alt+9 | select window by number | | |
| 48 | - | | Alt+x | close window (last window ends the session) | | |
| 45 | + | | Alt+c | new tab | | |
| 46 | + | | Alt+n / Alt+p | next / previous tab | | |
| 47 | + | | Alt+1 .. Alt+9 | select tab by number | | |
| 48 | + | | Alt+x | close tab (last tab ends the session) | | |
| 49 | 49 | | Alt+d | detach | | |
| 50 | 50 | | Alt+r | promote the current ephemeral session to named | | |
| 51 | + | | Alt+t | open session/tab picker | | |
| 51 | 52 | | Alt+u | enter copy mode | | |
| 52 | 53 | ||
| 53 | 54 | ## Copy mode | |
| @@ -23,7 +23,7 @@ vev --help show help | |||||
| 23 | vev --version show version | 23 | vev --version show version | ||
| 24 | ``` | 24 | ``` | ||
| 25 | 25 | ||||
| 26 | - | Ephemeral sessions get a number (0, 1, 2...) and die when you detach. Named sessions survive detach and live until killed or their last window closes. The daemon starts on first use and exits when no sessions remain. | 26 | + | Ephemeral sessions get a number (0, 1, 2...) and die when you detach. Named sessions survive detach and live until killed or their last tab closes. The daemon starts on first use and exits when no sessions remain. |
| 27 | 27 | ||||
| 28 | Remote attach runs `ssh user@host vev _stdio ...` under the hood, so vev must be installed on the remote host and reachable via your normal SSH config. Each remote attach opens one ssh connection. To make reconnects near-instant, enable OpenSSH connection reuse for your hosts in `~/.ssh/config`: | 28 | Remote attach runs `ssh user@host vev _stdio ...` under the hood, so vev must be installed on the remote host and reachable via your normal SSH config. Each remote attach opens one ssh connection. To make reconnects near-instant, enable OpenSSH connection reuse for your hosts in `~/.ssh/config`: | ||
| 29 | 29 | ||||
| @@ -42,12 +42,13 @@ All bindings use the Alt modifier directly, no prefix key. | |||||
| 42 | 42 | ||||
| 43 | | Key | Action | | 43 | | Key | Action | | ||
| 44 | |---|---| | 44 | |---|---| | ||
| 45 | - | | Alt+c | new window | | 45 | + | | Alt+c | new tab | |
| 46 | - | | Alt+n / Alt+p | next / previous window | | 46 | + | | Alt+n / Alt+p | next / previous tab | |
| 47 | - | | Alt+1 .. Alt+9 | select window by number | | 47 | + | | Alt+1 .. Alt+9 | select tab by number | |
| 48 | - | | Alt+x | close window (last window ends the session) | | 48 | + | | Alt+x | close tab (last tab ends the session) | |
| 49 | | Alt+d | detach | | 49 | | Alt+d | detach | | ||
| 50 | | Alt+r | promote the current ephemeral session to named | | 50 | | Alt+r | promote the current ephemeral session to named | | ||
| 51 | + | | Alt+t | open session/tab picker | | |||
| 51 | | Alt+u | enter copy mode | | 52 | | Alt+u | enter copy mode | | ||
| 52 | 53 | ||||
| 53 | ## Copy mode | 54 | ## Copy mode | ||
M internal/app/run.go +2 -2
| @@ -317,13 +317,13 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) { | |||
| 317 | 317 | return | |
| 318 | 318 | } | |
| 319 | 319 | tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) | |
| 320 | - | _, _ = fmt.Fprintln(tw, "NAME\tWINDOWS\tATTACHED") | |
| 320 | + | _, _ = fmt.Fprintln(tw, "NAME\tTABS\tATTACHED") | |
| 321 | 321 | for _, s := range sessions { | |
| 322 | 322 | attached := "no" | |
| 323 | 323 | if s.Attached { | |
| 324 | 324 | attached = "yes" | |
| 325 | 325 | } | |
| 326 | - | _, _ = fmt.Fprintf(tw, "%s\t%d\t%s\n", s.Name, s.Windows, attached) | |
| 326 | + | _, _ = fmt.Fprintf(tw, "%s\t%d\t%s\n", s.Name, s.Tabs, attached) | |
| 327 | 327 | } | |
| 328 | 328 | _ = tw.Flush() | |
| 329 | 329 | } | |
| @@ -317,13 +317,13 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) { | |||||
| 317 | return | 317 | return | ||
| 318 | } | 318 | } | ||
| 319 | tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) | 319 | tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) | ||
| 320 | - | _, _ = fmt.Fprintln(tw, "NAME\tWINDOWS\tATTACHED") | 320 | + | _, _ = fmt.Fprintln(tw, "NAME\tTABS\tATTACHED") |
| 321 | for _, s := range sessions { | 321 | for _, s := range sessions { | ||
| 322 | attached := "no" | 322 | attached := "no" | ||
| 323 | if s.Attached { | 323 | if s.Attached { | ||
| 324 | attached = "yes" | 324 | attached = "yes" | ||
| 325 | } | 325 | } | ||
| 326 | - | _, _ = fmt.Fprintf(tw, "%s\t%d\t%s\n", s.Name, s.Windows, attached) | 326 | + | _, _ = fmt.Fprintf(tw, "%s\t%d\t%s\n", s.Name, s.Tabs, attached) |
| 327 | } | 327 | } | ||
| 328 | _ = tw.Flush() | 328 | _ = tw.Flush() | ||
| 329 | } | 329 | } | ||
M internal/domain/types.go +8 -8
| @@ -18,22 +18,22 @@ type Rect struct { | |||
| 18 | 18 | // SessionID uniquely identifies a session. | |
| 19 | 19 | type SessionID string | |
| 20 | 20 | ||
| 21 | - | // WindowID uniquely identifies a window within a session. | |
| 22 | - | type WindowID string | |
| 21 | + | // TabID uniquely identifies a tab within a session. | |
| 22 | + | type TabID string | |
| 23 | 23 | ||
| 24 | - | // Window is a single window (tab) within a session. | |
| 25 | - | type Window struct { | |
| 26 | - | ID WindowID | |
| 24 | + | // Tab is a single tab within a session. | |
| 25 | + | type Tab struct { | |
| 26 | + | ID TabID | |
| 27 | 27 | Index int // 0-based; drives Alt+1..9 and status-bar order | |
| 28 | 28 | Name string | |
| 29 | 29 | Active bool | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | - | // Session is a persistent multiplexer session containing one or more windows. | |
| 32 | + | // Session is a persistent multiplexer session containing one or more tabs. | |
| 33 | 33 | type Session struct { | |
| 34 | 34 | ID SessionID | |
| 35 | 35 | Name string // display name; numbered "0","1"... when ephemeral | |
| 36 | 36 | Ephemeral bool | |
| 37 | - | Windows []Window | |
| 38 | - | ActiveWin WindowID | |
| 37 | + | Tabs []Tab | |
| 38 | + | ActiveTab TabID | |
| 39 | 39 | } | |
| @@ -18,22 +18,22 @@ type Rect struct { | |||||
| 18 | // SessionID uniquely identifies a session. | 18 | // SessionID uniquely identifies a session. | ||
| 19 | type SessionID string | 19 | type SessionID string | ||
| 20 | 20 | ||||
| 21 | - | // WindowID uniquely identifies a window within a session. | 21 | + | // TabID uniquely identifies a tab within a session. |
| 22 | - | type WindowID string | 22 | + | type TabID string |
| 23 | 23 | ||||
| 24 | - | // Window is a single window (tab) within a session. | 24 | + | // Tab is a single tab within a session. |
| 25 | - | type Window struct { | 25 | + | type Tab struct { |
| 26 | - | ID WindowID | 26 | + | ID TabID |
| 27 | Index int // 0-based; drives Alt+1..9 and status-bar order | 27 | Index int // 0-based; drives Alt+1..9 and status-bar order | ||
| 28 | Name string | 28 | Name string | ||
| 29 | Active bool | 29 | Active bool | ||
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 32 | - | // Session is a persistent multiplexer session containing one or more windows. | 32 | + | // Session is a persistent multiplexer session containing one or more tabs. |
| 33 | type Session struct { | 33 | type Session struct { | ||
| 34 | ID SessionID | 34 | ID SessionID | ||
| 35 | Name string // display name; numbered "0","1"... when ephemeral | 35 | Name string // display name; numbered "0","1"... when ephemeral | ||
| 36 | Ephemeral bool | 36 | Ephemeral bool | ||
| 37 | - | Windows []Window | 37 | + | Tabs []Tab |
| 38 | - | ActiveWin WindowID | 38 | + | ActiveTab TabID |
| 39 | } | 39 | } | ||
M internal/ports/wire.go +3 -3
| @@ -111,7 +111,7 @@ type SessionInfo struct { | |||
| 111 | 111 | SessionID string | |
| 112 | 112 | Name string | |
| 113 | 113 | Ephemeral bool | |
| 114 | - | Windows uint16 | |
| 114 | + | Tabs uint16 | |
| 115 | 115 | Attached bool | |
| 116 | 116 | } | |
| 117 | 117 | ||
| @@ -456,7 +456,7 @@ func MarshalSessions(m Sessions) []byte { | |||
| 456 | 456 | } else { | |
| 457 | 457 | w.putUint8(0) | |
| 458 | 458 | } | |
| 459 | - | w.putUint16(s.Windows) | |
| 459 | + | w.putUint16(s.Tabs) | |
| 460 | 460 | if s.Attached { | |
| 461 | 461 | w.putUint8(1) | |
| 462 | 462 | } else { | |
| @@ -487,7 +487,7 @@ func UnmarshalSessions(b []byte) (Sessions, error) { | |||
| 487 | 487 | return Sessions{}, err | |
| 488 | 488 | } | |
| 489 | 489 | s.Ephemeral = eph != 0 | |
| 490 | - | if s.Windows, err = r.getUint16(); err != nil { | |
| 490 | + | if s.Tabs, err = r.getUint16(); err != nil { | |
| 491 | 491 | return Sessions{}, err | |
| 492 | 492 | } | |
| 493 | 493 | att, err := r.getUint8() | |
| @@ -111,7 +111,7 @@ type SessionInfo struct { | |||||
| 111 | SessionID string | 111 | SessionID string | ||
| 112 | Name string | 112 | Name string | ||
| 113 | Ephemeral bool | 113 | Ephemeral bool | ||
| 114 | - | Windows uint16 | 114 | + | Tabs uint16 |
| 115 | Attached bool | 115 | Attached bool | ||
| 116 | } | 116 | } | ||
| 117 | 117 | ||||
| @@ -456,7 +456,7 @@ func MarshalSessions(m Sessions) []byte { | |||||
| 456 | } else { | 456 | } else { | ||
| 457 | w.putUint8(0) | 457 | w.putUint8(0) | ||
| 458 | } | 458 | } | ||
| 459 | - | w.putUint16(s.Windows) | 459 | + | w.putUint16(s.Tabs) |
| 460 | if s.Attached { | 460 | if s.Attached { | ||
| 461 | w.putUint8(1) | 461 | w.putUint8(1) | ||
| 462 | } else { | 462 | } else { | ||
| @@ -487,7 +487,7 @@ func UnmarshalSessions(b []byte) (Sessions, error) { | |||||
| 487 | return Sessions{}, err | 487 | return Sessions{}, err | ||
| 488 | } | 488 | } | ||
| 489 | s.Ephemeral = eph != 0 | 489 | s.Ephemeral = eph != 0 | ||
| 490 | - | if s.Windows, err = r.getUint16(); err != nil { | 490 | + | if s.Tabs, err = r.getUint16(); err != nil { |
| 491 | return Sessions{}, err | 491 | return Sessions{}, err | ||
| 492 | } | 492 | } | ||
| 493 | att, err := r.getUint8() | 493 | att, err := r.getUint8() | ||
M internal/ports/wire_test.go +2 -2
| @@ -358,8 +358,8 @@ func TestSessionsGoldenAndRoundTrip(t *testing.T) { | |||
| 358 | 358 | { | |
| 359 | 359 | name: "two", | |
| 360 | 360 | msg: Sessions{Sessions: []SessionInfo{ | |
| 361 | - | {SessionID: "0", Name: "0", Ephemeral: true, Windows: 1, Attached: false}, | |
| 362 | - | {SessionID: "work", Name: "proj", Ephemeral: false, Windows: 5, Attached: true}, | |
| 361 | + | {SessionID: "0", Name: "0", Ephemeral: true, Tabs: 1, Attached: false}, | |
| 362 | + | {SessionID: "work", Name: "proj", Ephemeral: false, Tabs: 5, Attached: true}, | |
| 363 | 363 | }}, | |
| 364 | 364 | want: []byte{ | |
| 365 | 365 | 0x00, 0x02, | |
| @@ -358,8 +358,8 @@ func TestSessionsGoldenAndRoundTrip(t *testing.T) { | |||||
| 358 | { | 358 | { | ||
| 359 | name: "two", | 359 | name: "two", | ||
| 360 | msg: Sessions{Sessions: []SessionInfo{ | 360 | msg: Sessions{Sessions: []SessionInfo{ | ||
| 361 | - | {SessionID: "0", Name: "0", Ephemeral: true, Windows: 1, Attached: false}, | 361 | + | {SessionID: "0", Name: "0", Ephemeral: true, Tabs: 1, Attached: false}, |
| 362 | - | {SessionID: "work", Name: "proj", Ephemeral: false, Windows: 5, Attached: true}, | 362 | + | {SessionID: "work", Name: "proj", Ephemeral: false, Tabs: 5, Attached: true}, |
| 363 | }}, | 363 | }}, | ||
| 364 | want: []byte{ | 364 | want: []byte{ | ||
| 365 | 0x00, 0x02, | 365 | 0x00, 0x02, | ||
M internal/usecase/daemon/daemon.go +592 -220
| @@ -1,8 +1,8 @@ | |||
| 1 | 1 | // Package daemon holds vev's server-side session multiplexer use case: the | |
| 2 | - | // accept loop, the ephemeral/named session registry, the per-window PTY reader | |
| 2 | + | // accept loop, the ephemeral/named session registry, the per-tab PTY reader | |
| 3 | 3 | // and VT screen, and the per-client debounced render scheduler. | |
| 4 | 4 | // | |
| 5 | - | // Concurrency model (one window per session for the MVP; multi-window is M3): | |
| 5 | + | // Concurrency model (one tab per session for the MVP; multi-tab is M3): | |
| 6 | 6 | // | |
| 7 | 7 | // - Serve runs the accept loop. Each accepted connection is handled by its | |
| 8 | 8 | // own goroutine (handleConn): it reads the first frame and routes it to a | |
| @@ -18,10 +18,10 @@ | |||
| 18 | 18 | // attached clients with ReasonServerShutdown). | |
| 19 | 19 | // | |
| 20 | 20 | // Locking: a session's screen and per-client renderer shadow are both guarded | |
| 21 | - | // by window.mu; the attached-client pointer by session.mu; the registry by | |
| 21 | + | // by tab.mu; the attached-client pointer by session.mu; the registry by | |
| 22 | 22 | // Daemon.mu. When more than one is held the order is always | |
| 23 | 23 | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | |
| 24 | - | // window.mu — the PTY reader only ever takes window.mu, so it never blocks on | |
| 24 | + | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on | |
| 25 | 25 | // a slow client. | |
| 26 | 26 | package daemon | |
| 27 | 27 | ||
| @@ -43,6 +43,8 @@ import ( | |||
| 43 | 43 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 44 | 44 | "github.com/bnema/vev/internal/usecase/keys" | |
| 45 | 45 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 46 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 47 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 46 | 48 | "github.com/bnema/vev/pkg/renderer" | |
| 47 | 49 | "github.com/bnema/vev/pkg/vt" | |
| 48 | 50 | ) | |
| @@ -56,13 +58,15 @@ const ( | |||
| 56 | 58 | maxSyncUpdateDuration = 500 * time.Millisecond | |
| 57 | 59 | ) | |
| 58 | 60 | ||
| 61 | + | var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions "} | |
| 62 | + | ||
| 59 | 63 | // debounceInterval is kept as a test/back-compat alias for the initial delay. | |
| 60 | 64 | const debounceInterval = minDebounceInterval | |
| 61 | 65 | ||
| 62 | 66 | // ptyReadBufSize is the PTY reader's read buffer. | |
| 63 | 67 | const ptyReadBufSize = 32 * 1024 | |
| 64 | 68 | ||
| 65 | - | // defaultScrollbackRows is the per-window retained history size. | |
| 69 | + | // defaultScrollbackRows is the per-tab retained history size. | |
| 66 | 70 | const defaultScrollbackRows = 10_000 | |
| 67 | 71 | ||
| 68 | 72 | // detachNotifyTimeout bounds the best-effort Detached notification on the | |
| @@ -117,7 +121,7 @@ type Daemon struct { | |||
| 117 | 121 | } | |
| 118 | 122 | ||
| 119 | 123 | // session is a single multiplexed session. It owns one or more full-screen | |
| 120 | - | // windows and at most one attached client. | |
| 124 | + | // tabs and at most one attached client. | |
| 121 | 125 | type session struct { | |
| 122 | 126 | id domain.SessionID | |
| 123 | 127 | name string | |
| @@ -126,16 +130,16 @@ type session struct { | |||
| 126 | 130 | ctx context.Context | |
| 127 | 131 | cancel context.CancelFunc | |
| 128 | 132 | ||
| 129 | - | mu sync.Mutex // guards windows, active, and client | |
| 130 | - | windows []*window | |
| 131 | - | active int | |
| 132 | - | client *attachedClient | |
| 133 | + | mu sync.Mutex // guards tabs, active, and client | |
| 134 | + | tabs []*tab | |
| 135 | + | active int | |
| 136 | + | client *attachedClient | |
| 133 | 137 | } | |
| 134 | 138 | ||
| 135 | - | // window is one PTY-backed screen. dirty is a cap-1 channel so bursty output | |
| 139 | + | // tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output | |
| 136 | 140 | // applies back-pressure by collapsing (a full buffer means "a render is | |
| 137 | 141 | // already pending"), never by blocking the reader. | |
| 138 | - | type window struct { | |
| 142 | + | type tab struct { | |
| 139 | 143 | pty ports.PTY | |
| 140 | 144 | mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow | |
| 141 | 145 | screen *vt.Screen | |
| @@ -143,27 +147,36 @@ type window struct { | |||
| 143 | 147 | dirty chan struct{} | |
| 144 | 148 | flush chan struct{} | |
| 145 | 149 | syncGen uint64 | |
| 146 | - | size domain.Size | |
| 147 | - | ctx context.Context | |
| 148 | - | cancel context.CancelFunc | |
| 150 | + | // previewClient tracks the one client currently previewing this tab in the picker. | |
| 151 | + | // v1 is last-writer-wins: multiple clients previewing the same tab are not supported. | |
| 152 | + | previewClient *attachedClient | |
| 153 | + | size domain.Size | |
| 154 | + | ctx context.Context | |
| 155 | + | cancel context.CancelFunc | |
| 149 | 156 | } | |
| 150 | 157 | ||
| 151 | - | // attachedClient is a client currently attached to a session's window. rend is | |
| 158 | + | // attachedClient is a client currently attached to a session's tab. rend is | |
| 152 | 159 | // its private renderer shadow (so each client gets output minimised against | |
| 153 | 160 | // what it has actually seen). sendMu serialises the two senders — the render | |
| 154 | 161 | // scheduler and the connection handler — so the transport's single-writer | |
| 155 | 162 | // contract holds and Draw→Send stays atomic (correct output ordering). | |
| 156 | 163 | type attachedClient struct { | |
| 157 | - | tr ports.Transport | |
| 158 | - | rend *renderer.Renderer | |
| 159 | - | size domain.Size | |
| 160 | - | keys *keys.Router | |
| 161 | - | copyMu sync.Mutex | |
| 162 | - | copyMode *scopy.Mode | |
| 163 | - | copyPending []byte | |
| 164 | - | mouseScan mouse.Scanner | |
| 165 | - | lastCursor cursorOut | |
| 166 | - | sendMu sync.Mutex | |
| 164 | + | tr ports.Transport | |
| 165 | + | rend *renderer.Renderer | |
| 166 | + | size domain.Size | |
| 167 | + | keys *keys.Router | |
| 168 | + | sessMu sync.Mutex | |
| 169 | + | sess *session | |
| 170 | + | copyMu sync.Mutex | |
| 171 | + | copyMode *scopy.Mode | |
| 172 | + | copyPending []byte | |
| 173 | + | pickerMu sync.Mutex | |
| 174 | + | picker *picker.Model | |
| 175 | + | pickerPreview *tab | |
| 176 | + | pickerPending []byte | |
| 177 | + | mouseScan mouse.Scanner | |
| 178 | + | lastCursor cursorOut | |
| 179 | + | sendMu sync.Mutex | |
| 167 | 180 | } | |
| 168 | 181 | ||
| 169 | 182 | type cursorOut struct { | |
| @@ -175,12 +188,30 @@ type cursorOut struct { | |||
| 175 | 188 | hasStyle bool | |
| 176 | 189 | } | |
| 177 | 190 | ||
| 191 | + | func (ac *attachedClient) currentSession() *session { | |
| 192 | + | ac.sessMu.Lock() | |
| 193 | + | defer ac.sessMu.Unlock() | |
| 194 | + | return ac.sess | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func (ac *attachedClient) setSession(sess *session) { | |
| 198 | + | ac.sessMu.Lock() | |
| 199 | + | defer ac.sessMu.Unlock() | |
| 200 | + | ac.sess = sess | |
| 201 | + | } | |
| 202 | + | ||
| 178 | 203 | func (ac *attachedClient) copyModeActive() bool { | |
| 179 | 204 | ac.copyMu.Lock() | |
| 180 | 205 | defer ac.copyMu.Unlock() | |
| 181 | 206 | return ac.copyMode != nil | |
| 182 | 207 | } | |
| 183 | 208 | ||
| 209 | + | func (ac *attachedClient) pickerActive() bool { | |
| 210 | + | ac.pickerMu.Lock() | |
| 211 | + | defer ac.pickerMu.Unlock() | |
| 212 | + | return ac.picker != nil | |
| 213 | + | } | |
| 214 | + | ||
| 184 | 215 | // send serialises a frame onto the client's transport. | |
| 185 | 216 | func (ac *attachedClient) send(f ports.Frame) error { | |
| 186 | 217 | ac.sendMu.Lock() | |
| @@ -391,7 +422,7 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||
| 391 | 422 | SessionID: string(s.id), | |
| 392 | 423 | Name: s.name, | |
| 393 | 424 | Ephemeral: s.ephemeral, | |
| 394 | - | Windows: uint16(len(s.windows)), | |
| 425 | + | Tabs: uint16(len(s.tabs)), | |
| 395 | 426 | Attached: s.client != nil, | |
| 396 | 427 | } | |
| 397 | 428 | s.mu.Unlock() | |
| @@ -528,8 +559,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 528 | 559 | // createSessionLocked spawns a PTY-backed session and starts its reader and | |
| 529 | 560 | // scheduler goroutines. Caller holds d.mu. | |
| 530 | 561 | func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size) (*session, error) { | |
| 531 | - | winSize := windowSize(sz) | |
| 532 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), winSize) | |
| 562 | + | tbSize := tabSize(sz) | |
| 563 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), tbSize) | |
| 533 | 564 | if err != nil { | |
| 534 | 565 | return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err) | |
| 535 | 566 | } | |
| @@ -537,43 +568,43 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size | |||
| 537 | 568 | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |
| 538 | 569 | d.nextID++ | |
| 539 | 570 | ||
| 540 | - | win := newWindow(pty, winSize) | |
| 571 | + | tb := newTab(pty, tbSize) | |
| 541 | 572 | sctx, cancel := context.WithCancel(d.serveCtx) | |
| 542 | - | win.ctx, win.cancel = context.WithCancel(sctx) | |
| 573 | + | tb.ctx, tb.cancel = context.WithCancel(sctx) | |
| 543 | 574 | sess := &session{ | |
| 544 | 575 | id: id, | |
| 545 | 576 | name: name, | |
| 546 | 577 | ephemeral: ephemeral, | |
| 547 | 578 | ctx: sctx, | |
| 548 | 579 | cancel: cancel, | |
| 549 | - | windows: []*window{win}, | |
| 580 | + | tabs: []*tab{tb}, | |
| 550 | 581 | } | |
| 551 | 582 | d.sessions[id] = sess | |
| 552 | - | d.startWindowGoroutines(sess, win) | |
| 583 | + | d.startTabGoroutines(sess, tb) | |
| 553 | 584 | return sess, nil | |
| 554 | 585 | } | |
| 555 | 586 | ||
| 556 | - | func (d *Daemon) createWindow(sess *session, sz domain.Size) error { | |
| 557 | - | winSize := windowSize(sz) | |
| 558 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), winSize) | |
| 587 | + | func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |
| 588 | + | tbSize := tabSize(sz) | |
| 589 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), tbSize) | |
| 559 | 590 | if err != nil { | |
| 560 | - | return fmt.Errorf("daemon: spawning window for session %q: %w", sess.name, err) | |
| 591 | + | return fmt.Errorf("daemon: spawning tab for session %q: %w", sess.name, err) | |
| 561 | 592 | } | |
| 562 | - | win := newWindow(pty, winSize) | |
| 563 | - | win.ctx, win.cancel = context.WithCancel(sess.ctx) | |
| 593 | + | tb := newTab(pty, tbSize) | |
| 594 | + | tb.ctx, tb.cancel = context.WithCancel(sess.ctx) | |
| 564 | 595 | sess.mu.Lock() | |
| 565 | - | sess.windows = append(sess.windows, win) | |
| 566 | - | sess.active = len(sess.windows) - 1 | |
| 596 | + | sess.tabs = append(sess.tabs, tb) | |
| 597 | + | sess.active = len(sess.tabs) - 1 | |
| 567 | 598 | sess.mu.Unlock() | |
| 568 | - | d.startWindowGoroutines(sess, win) | |
| 599 | + | d.startTabGoroutines(sess, tb) | |
| 569 | 600 | return nil | |
| 570 | 601 | } | |
| 571 | 602 | ||
| 572 | - | func newWindow(pty ports.PTY, sz domain.Size) *window { | |
| 603 | + | func newTab(pty ports.PTY, sz domain.Size) *tab { | |
| 573 | 604 | sb := scopy.NewScrollback(defaultScrollbackRows) | |
| 574 | 605 | screen := vt.NewScreen(sz.Cols, sz.Rows) | |
| 575 | 606 | screen.OnLineEvicted = sb.Append | |
| 576 | - | return &window{ | |
| 607 | + | return &tab{ | |
| 577 | 608 | pty: pty, | |
| 578 | 609 | screen: screen, | |
| 579 | 610 | scrollback: sb, | |
| @@ -583,7 +614,7 @@ func newWindow(pty ports.PTY, sz domain.Size) *window { | |||
| 583 | 614 | } | |
| 584 | 615 | } | |
| 585 | 616 | ||
| 586 | - | func windowSize(clientSize domain.Size) domain.Size { | |
| 617 | + | func tabSize(clientSize domain.Size) domain.Size { | |
| 587 | 618 | if !clientSize.Valid() { | |
| 588 | 619 | clientSize = defaultSize | |
| 589 | 620 | } | |
| @@ -591,10 +622,10 @@ func windowSize(clientSize domain.Size) domain.Size { | |||
| 591 | 622 | return domain.Size{Cols: clientSize.Cols, Rows: rows} | |
| 592 | 623 | } | |
| 593 | 624 | ||
| 594 | - | func (d *Daemon) startWindowGoroutines(sess *session, win *window) { | |
| 625 | + | func (d *Daemon) startTabGoroutines(sess *session, tb *tab) { | |
| 595 | 626 | d.sessWg.Add(2) | |
| 596 | - | go d.ptyReader(sess, win) | |
| 597 | - | go d.scheduler(sess, win) | |
| 627 | + | go d.ptyReader(sess, tb) | |
| 628 | + | go d.scheduler(sess, tb) | |
| 598 | 629 | } | |
| 599 | 630 | ||
| 600 | 631 | // attachClient makes ac the session's current client, displacing any prior one | |
| @@ -606,13 +637,16 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) | |||
| 606 | 637 | rend: renderer.New(renderer.Capabilities{}), | |
| 607 | 638 | size: sz, | |
| 608 | 639 | } | |
| 609 | - | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac}) | |
| 640 | + | ac.setSession(sess) | |
| 641 | + | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}) | |
| 610 | 642 | sess.mu.Lock() | |
| 611 | 643 | old := sess.client | |
| 612 | 644 | sess.client = ac | |
| 613 | 645 | sess.mu.Unlock() | |
| 614 | 646 | ||
| 615 | 647 | if old != nil { | |
| 648 | + | d.unregisterPreview(old) | |
| 649 | + | old.setSession(nil) | |
| 616 | 650 | // Async + bounded: a dead or wedged old client must not stall the new | |
| 617 | 651 | // client's handshake. | |
| 618 | 652 | d.notifyDetachedAsync(old, ports.ReasonDetach) | |
| @@ -621,18 +655,18 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) | |||
| 621 | 655 | } | |
| 622 | 656 | ||
| 623 | 657 | // firstPaint guarantees the freshly attached client sees the full screen: if | |
| 624 | - | // the window size differs from the client's it resizes first (which paints a | |
| 658 | + | // the tab size differs from the client's it resizes first (which paints a | |
| 625 | 659 | // full redraw), otherwise it forces a full paint against the fresh renderer. | |
| 626 | 660 | func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) { | |
| 627 | - | win := sess.activeWindow() | |
| 628 | - | if win == nil { | |
| 661 | + | tb := sess.activeTab() | |
| 662 | + | if tb == nil { | |
| 629 | 663 | return | |
| 630 | 664 | } | |
| 631 | - | win.mu.Lock() | |
| 632 | - | wsz := win.size | |
| 633 | - | win.mu.Unlock() | |
| 665 | + | tb.mu.Lock() | |
| 666 | + | wsz := tb.size | |
| 667 | + | tb.mu.Unlock() | |
| 634 | 668 | ||
| 635 | - | if clientSize.Valid() && wsz != windowSize(clientSize) { | |
| 669 | + | if clientSize.Valid() && wsz != tabSize(clientSize) { | |
| 636 | 670 | d.resize(sess, ac, clientSize) | |
| 637 | 671 | return | |
| 638 | 672 | } | |
| @@ -643,11 +677,19 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||
| 643 | 677 | // until detach, EOF, or a transport error. | |
| 644 | 678 | func (d *Daemon) runConnLoop(sess *session, ac *attachedClient) { | |
| 645 | 679 | for { | |
| 680 | + | sess = ac.currentSession() | |
| 681 | + | if sess == nil { | |
| 682 | + | return | |
| 683 | + | } | |
| 646 | 684 | f, err := ac.tr.Recv() | |
| 647 | 685 | if err != nil { | |
| 648 | 686 | d.clientGone(sess, ac, false) | |
| 649 | 687 | return | |
| 650 | 688 | } | |
| 689 | + | sess = ac.currentSession() | |
| 690 | + | if sess == nil { | |
| 691 | + | return | |
| 692 | + | } | |
| 651 | 693 | switch f.Type { | |
| 652 | 694 | case ports.MsgInput: | |
| 653 | 695 | if in, derr := ports.UnmarshalInput(f.Payload); derr == nil { | |
| @@ -676,6 +718,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) { | |||
| 676 | 718 | if !sess.detachIfCurrent(ac) { | |
| 677 | 719 | return // already displaced by a newer client; nothing to do | |
| 678 | 720 | } | |
| 721 | + | d.unregisterPreview(ac) | |
| 679 | 722 | if explicit { | |
| 680 | 723 | // Synchronous so the ack is delivered before the transport closes | |
| 681 | 724 | // (the client is actively awaiting it), but deadline-bounded so a | |
| @@ -696,24 +739,29 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool { | |||
| 696 | 739 | defer s.mu.Unlock() | |
| 697 | 740 | if s.client == ac { | |
| 698 | 741 | s.client = nil | |
| 742 | + | ac.setSession(nil) | |
| 699 | 743 | return true | |
| 700 | 744 | } | |
| 701 | 745 | return false | |
| 702 | 746 | } | |
| 703 | 747 | ||
| 704 | - | func (s *session) activeWindow() *window { | |
| 748 | + | func (s *session) activeTab() *tab { | |
| 705 | 749 | s.mu.Lock() | |
| 706 | 750 | defer s.mu.Unlock() | |
| 707 | - | if s.active < 0 || s.active >= len(s.windows) { | |
| 751 | + | if s.active < 0 || s.active >= len(s.tabs) { | |
| 708 | 752 | return nil | |
| 709 | 753 | } | |
| 710 | - | return s.windows[s.active] | |
| 754 | + | return s.tabs[s.active] | |
| 711 | 755 | } | |
| 712 | 756 | ||
| 713 | 757 | func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | |
| 714 | 758 | ac.mouseScan.Scan(data, | |
| 715 | 759 | func(ev mouse.Event) { d.handleMouse(sess, ac, ev) }, | |
| 716 | 760 | func(b []byte) { | |
| 761 | + | if ac.pickerActive() { | |
| 762 | + | d.handlePickerInput(sess, ac, b) | |
| 763 | + | return | |
| 764 | + | } | |
| 717 | 765 | if ac.copyModeActive() { | |
| 718 | 766 | d.handleCopyInput(sess, ac, b) | |
| 719 | 767 | return | |
| @@ -724,8 +772,8 @@ func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | |||
| 724 | 772 | } | |
| 725 | 773 | ||
| 726 | 774 | func (d *Daemon) handleMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |
| 727 | - | win := sess.activeWindow() | |
| 728 | - | if win == nil { | |
| 775 | + | tb := sess.activeTab() | |
| 776 | + | if tb == nil { | |
| 729 | 777 | return | |
| 730 | 778 | } | |
| 731 | 779 | ||
| @@ -739,53 +787,53 @@ func (d *Daemon) handleMouse(sess *session, ac *attachedClient, ev mouse.Event) | |||
| 739 | 787 | return | |
| 740 | 788 | } | |
| 741 | 789 | ||
| 742 | - | win.mu.Lock() | |
| 743 | - | childRows := win.screen.Frame.Height | |
| 744 | - | mouseMode, mouseSGR := win.screen.MouseMode() | |
| 745 | - | altScreen := win.screen.AltScreenActive() | |
| 746 | - | win.mu.Unlock() | |
| 790 | + | tb.mu.Lock() | |
| 791 | + | childRows := tb.screen.Frame.Height | |
| 792 | + | mouseMode, mouseSGR := tb.screen.MouseMode() | |
| 793 | + | altScreen := tb.screen.AltScreenActive() | |
| 794 | + | tb.mu.Unlock() | |
| 747 | 795 | ||
| 748 | 796 | if mouseMode != 0 { | |
| 749 | 797 | if !mouseSGR || ev.Row >= childRows { | |
| 750 | 798 | return | |
| 751 | 799 | } | |
| 752 | - | daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward(ev.Raw) | |
| 800 | + | daemonKeyHandler{d: d, ac: ac}.Forward(ev.Raw) | |
| 753 | 801 | return | |
| 754 | 802 | } | |
| 755 | 803 | ||
| 756 | 804 | switch ev.Button { | |
| 757 | 805 | case mouse.WheelUp: | |
| 758 | 806 | if altScreen { | |
| 759 | - | daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A")) | |
| 807 | + | daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A")) | |
| 760 | 808 | return | |
| 761 | 809 | } | |
| 762 | 810 | d.enterCopyMode(sess, ac) | |
| 763 | 811 | d.copyWheel(sess, ac, -3) | |
| 764 | 812 | case mouse.WheelDown: | |
| 765 | 813 | if altScreen { | |
| 766 | - | daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B")) | |
| 814 | + | daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B")) | |
| 767 | 815 | } | |
| 768 | 816 | } | |
| 769 | 817 | } | |
| 770 | 818 | ||
| 771 | 819 | func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |
| 772 | - | win := sess.activeWindow() | |
| 773 | - | if win == nil { | |
| 820 | + | tb := sess.activeTab() | |
| 821 | + | if tb == nil { | |
| 774 | 822 | return | |
| 775 | 823 | } | |
| 776 | 824 | ||
| 777 | - | win.mu.Lock() | |
| 825 | + | tb.mu.Lock() | |
| 778 | 826 | ac.copyMu.Lock() | |
| 779 | 827 | if ac.copyMode == nil { | |
| 780 | 828 | ac.copyMu.Unlock() | |
| 781 | - | win.mu.Unlock() | |
| 829 | + | tb.mu.Unlock() | |
| 782 | 830 | return | |
| 783 | 831 | } | |
| 784 | - | snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame) | |
| 832 | + | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 785 | 833 | if delta > 0 && ac.copyMode.AtBottom(snap) { | |
| 786 | 834 | ac.copyMode = nil | |
| 787 | 835 | ac.copyMu.Unlock() | |
| 788 | - | win.mu.Unlock() | |
| 836 | + | tb.mu.Unlock() | |
| 789 | 837 | d.paint(sess, ac, true) | |
| 790 | 838 | return | |
| 791 | 839 | } | |
| @@ -795,19 +843,19 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||
| 795 | 843 | ac.copyMode = nil | |
| 796 | 844 | } | |
| 797 | 845 | ac.copyMu.Unlock() | |
| 798 | - | win.mu.Unlock() | |
| 846 | + | tb.mu.Unlock() | |
| 799 | 847 | ||
| 800 | 848 | d.paint(sess, ac, true) | |
| 801 | 849 | } | |
| 802 | 850 | ||
| 803 | 851 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |
| 804 | - | win := sess.activeWindow() | |
| 805 | - | if win == nil { | |
| 852 | + | tb := sess.activeTab() | |
| 853 | + | if tb == nil { | |
| 806 | 854 | return | |
| 807 | 855 | } | |
| 808 | - | win.mu.Lock() | |
| 809 | - | snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame) | |
| 810 | - | win.mu.Unlock() | |
| 856 | + | tb.mu.Lock() | |
| 857 | + | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 858 | + | tb.mu.Unlock() | |
| 811 | 859 | ac.copyMu.Lock() | |
| 812 | 860 | ac.copyMode = scopy.NewMode(snap) | |
| 813 | 861 | ac.copyMu.Unlock() | |
| @@ -815,17 +863,17 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||
| 815 | 863 | } | |
| 816 | 864 | ||
| 817 | 865 | func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) { | |
| 818 | - | win := sess.activeWindow() | |
| 819 | - | if win == nil { | |
| 866 | + | tb := sess.activeTab() | |
| 867 | + | if tb == nil { | |
| 820 | 868 | return | |
| 821 | 869 | } | |
| 822 | 870 | ||
| 823 | - | win.mu.Lock() | |
| 871 | + | tb.mu.Lock() | |
| 824 | 872 | ac.copyMu.Lock() | |
| 825 | 873 | if ac.copyMode == nil { | |
| 826 | 874 | ac.copyPending = nil | |
| 827 | 875 | ac.copyMu.Unlock() | |
| 828 | - | win.mu.Unlock() | |
| 876 | + | tb.mu.Unlock() | |
| 829 | 877 | return | |
| 830 | 878 | } | |
| 831 | 879 | if len(ac.copyPending) > 0 { | |
| @@ -835,7 +883,7 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) | |||
| 835 | 883 | data = combined | |
| 836 | 884 | ac.copyPending = nil | |
| 837 | 885 | } | |
| 838 | - | snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame) | |
| 886 | + | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 839 | 887 | changed := false | |
| 840 | 888 | copyOut := false | |
| 841 | 889 | exit := false | |
| @@ -884,7 +932,7 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) | |||
| 884 | 932 | ac.copyMode = nil | |
| 885 | 933 | } | |
| 886 | 934 | ac.copyMu.Unlock() | |
| 887 | - | win.mu.Unlock() | |
| 935 | + | tb.mu.Unlock() | |
| 888 | 936 | ||
| 889 | 937 | if copyOut && text != "" { | |
| 890 | 938 | for _, chunk := range scopy.OSC52(text) { | |
| @@ -932,64 +980,323 @@ func isCopyEscapePrefix(data []byte) bool { | |||
| 932 | 980 | len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6') | |
| 933 | 981 | } | |
| 934 | 982 | ||
| 983 | + | func (d *Daemon) enterPicker(sess *session, ac *attachedClient) { | |
| 984 | + | views, curTab := d.pickerViews(sess) | |
| 985 | + | ac.pickerMu.Lock() | |
| 986 | + | ac.picker = picker.New(views, sess.id, curTab) | |
| 987 | + | ac.pickerPending = nil | |
| 988 | + | ac.pickerPreview = nil | |
| 989 | + | ac.pickerMu.Unlock() | |
| 990 | + | d.registerPreviewForSelection(ac) | |
| 991 | + | d.paint(sess, ac, true) | |
| 992 | + | } | |
| 993 | + | ||
| 994 | + | func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |
| 995 | + | d.mu.Lock() | |
| 996 | + | sessions := make([]*session, 0, len(d.sessions)) | |
| 997 | + | for _, s := range d.sessions { | |
| 998 | + | sessions = append(sessions, s) | |
| 999 | + | } | |
| 1000 | + | d.mu.Unlock() | |
| 1001 | + | sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name }) | |
| 1002 | + | ||
| 1003 | + | views := make([]picker.SessionView, 0, len(sessions)) | |
| 1004 | + | curTab := 0 | |
| 1005 | + | for _, s := range sessions { | |
| 1006 | + | s.mu.Lock() | |
| 1007 | + | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))} | |
| 1008 | + | for i := range s.tabs { | |
| 1009 | + | view.Tabs[i] = strconv.Itoa(i + 1) | |
| 1010 | + | } | |
| 1011 | + | if s == cur { | |
| 1012 | + | curTab = s.active | |
| 1013 | + | } | |
| 1014 | + | s.mu.Unlock() | |
| 1015 | + | views = append(views, view) | |
| 1016 | + | } | |
| 1017 | + | return views, curTab | |
| 1018 | + | } | |
| 1019 | + | ||
| 1020 | + | func (d *Daemon) handlePickerInput(sess *session, ac *attachedClient, data []byte) { | |
| 1021 | + | ac.pickerMu.Lock() | |
| 1022 | + | if ac.picker == nil { | |
| 1023 | + | ac.pickerPending = nil | |
| 1024 | + | ac.pickerMu.Unlock() | |
| 1025 | + | return | |
| 1026 | + | } | |
| 1027 | + | if len(ac.pickerPending) > 0 { | |
| 1028 | + | combined := make([]byte, 0, len(ac.pickerPending)+len(data)) | |
| 1029 | + | combined = append(combined, ac.pickerPending...) | |
| 1030 | + | combined = append(combined, data...) | |
| 1031 | + | data = combined | |
| 1032 | + | ac.pickerPending = nil | |
| 1033 | + | } | |
| 1034 | + | changed := false | |
| 1035 | + | exit := false | |
| 1036 | + | switchTarget := false | |
| 1037 | + | for i := 0; i < len(data); i++ { | |
| 1038 | + | switch data[i] { | |
| 1039 | + | case 'j': | |
| 1040 | + | ac.picker.Down() | |
| 1041 | + | changed = true | |
| 1042 | + | case 'k': | |
| 1043 | + | ac.picker.Up() | |
| 1044 | + | changed = true | |
| 1045 | + | case '\r', '\n': | |
| 1046 | + | switchTarget = true | |
| 1047 | + | exit = true | |
| 1048 | + | case 'q', 0x03, 0x1b: | |
| 1049 | + | if data[i] == 0x1b { | |
| 1050 | + | tail := data[i:] | |
| 1051 | + | consumed, ok := routePickerEscape(ac.picker, tail) | |
| 1052 | + | if ok { | |
| 1053 | + | i += consumed - 1 | |
| 1054 | + | changed = true | |
| 1055 | + | continue | |
| 1056 | + | } | |
| 1057 | + | if isPickerEscapePrefix(tail) { | |
| 1058 | + | ac.pickerPending = append(ac.pickerPending[:0], tail...) | |
| 1059 | + | break | |
| 1060 | + | } | |
| 1061 | + | } | |
| 1062 | + | exit = true | |
| 1063 | + | } | |
| 1064 | + | } | |
| 1065 | + | var target picker.Target | |
| 1066 | + | var ok bool | |
| 1067 | + | if switchTarget { | |
| 1068 | + | target, ok = ac.picker.Selected() | |
| 1069 | + | } | |
| 1070 | + | ac.pickerMu.Unlock() | |
| 1071 | + | ||
| 1072 | + | if changed { | |
| 1073 | + | d.registerPreviewForSelection(ac) | |
| 1074 | + | } | |
| 1075 | + | if exit { | |
| 1076 | + | d.closePicker(ac) | |
| 1077 | + | } | |
| 1078 | + | if switchTarget && ok { | |
| 1079 | + | d.switchToTarget(sess, ac, target) | |
| 1080 | + | return | |
| 1081 | + | } | |
| 1082 | + | if exit || changed { | |
| 1083 | + | d.paint(sess, ac, true) | |
| 1084 | + | } | |
| 1085 | + | } | |
| 1086 | + | ||
| 1087 | + | func routePickerEscape(m *picker.Model, data []byte) (int, bool) { | |
| 1088 | + | if len(data) >= 3 && data[1] == '[' { | |
| 1089 | + | switch data[2] { | |
| 1090 | + | case 'A': | |
| 1091 | + | m.Up() | |
| 1092 | + | return 3, true | |
| 1093 | + | case 'B': | |
| 1094 | + | m.Down() | |
| 1095 | + | return 3, true | |
| 1096 | + | } | |
| 1097 | + | } | |
| 1098 | + | return 0, false | |
| 1099 | + | } | |
| 1100 | + | ||
| 1101 | + | func isPickerEscapePrefix(data []byte) bool { | |
| 1102 | + | return len(data) == 2 && data[0] == 0x1b && data[1] == '[' | |
| 1103 | + | } | |
| 1104 | + | ||
| 1105 | + | func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |
| 1106 | + | ac.pickerMu.Lock() | |
| 1107 | + | var target picker.Target | |
| 1108 | + | var ok bool | |
| 1109 | + | if ac.picker != nil { | |
| 1110 | + | target, ok = ac.picker.Selected() | |
| 1111 | + | } | |
| 1112 | + | ac.pickerMu.Unlock() | |
| 1113 | + | ||
| 1114 | + | var next *tab | |
| 1115 | + | if ok { | |
| 1116 | + | next = d.tabByTarget(target) | |
| 1117 | + | } | |
| 1118 | + | ||
| 1119 | + | ac.pickerMu.Lock() | |
| 1120 | + | old := ac.pickerPreview | |
| 1121 | + | if ac.picker == nil { | |
| 1122 | + | next = nil | |
| 1123 | + | } | |
| 1124 | + | ac.pickerPreview = next | |
| 1125 | + | ac.pickerMu.Unlock() | |
| 1126 | + | ||
| 1127 | + | if old != nil && old != next { | |
| 1128 | + | old.mu.Lock() | |
| 1129 | + | if old.previewClient == ac { | |
| 1130 | + | old.previewClient = nil | |
| 1131 | + | } | |
| 1132 | + | old.mu.Unlock() | |
| 1133 | + | } | |
| 1134 | + | if next != nil { | |
| 1135 | + | next.mu.Lock() | |
| 1136 | + | next.previewClient = ac | |
| 1137 | + | next.mu.Unlock() | |
| 1138 | + | ||
| 1139 | + | ac.pickerMu.Lock() | |
| 1140 | + | keep := ac.pickerPreview == next | |
| 1141 | + | ac.pickerMu.Unlock() | |
| 1142 | + | if !keep { | |
| 1143 | + | next.mu.Lock() | |
| 1144 | + | if next.previewClient == ac { | |
| 1145 | + | next.previewClient = nil | |
| 1146 | + | } | |
| 1147 | + | next.mu.Unlock() | |
| 1148 | + | } | |
| 1149 | + | } | |
| 1150 | + | } | |
| 1151 | + | ||
| 1152 | + | func (d *Daemon) unregisterPreview(ac *attachedClient) { | |
| 1153 | + | ac.pickerMu.Lock() | |
| 1154 | + | old := ac.pickerPreview | |
| 1155 | + | ac.pickerPreview = nil | |
| 1156 | + | ac.pickerMu.Unlock() | |
| 1157 | + | ||
| 1158 | + | if old != nil { | |
| 1159 | + | old.mu.Lock() | |
| 1160 | + | if old.previewClient == ac { | |
| 1161 | + | old.previewClient = nil | |
| 1162 | + | } | |
| 1163 | + | old.mu.Unlock() | |
| 1164 | + | } | |
| 1165 | + | } | |
| 1166 | + | ||
| 1167 | + | func (d *Daemon) closePicker(ac *attachedClient) { | |
| 1168 | + | ac.pickerMu.Lock() | |
| 1169 | + | ac.picker = nil | |
| 1170 | + | ac.pickerPending = nil | |
| 1171 | + | ac.pickerMu.Unlock() | |
| 1172 | + | d.unregisterPreview(ac) | |
| 1173 | + | } | |
| 1174 | + | ||
| 1175 | + | func (d *Daemon) tabByTarget(target picker.Target) *tab { | |
| 1176 | + | d.mu.Lock() | |
| 1177 | + | sess := d.sessions[target.Session] | |
| 1178 | + | d.mu.Unlock() | |
| 1179 | + | if sess == nil { | |
| 1180 | + | return nil | |
| 1181 | + | } | |
| 1182 | + | sess.mu.Lock() | |
| 1183 | + | defer sess.mu.Unlock() | |
| 1184 | + | if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) { | |
| 1185 | + | return nil | |
| 1186 | + | } | |
| 1187 | + | return sess.tabs[target.TabIndex] | |
| 1188 | + | } | |
| 1189 | + | ||
| 1190 | + | func (d *Daemon) sessionByID(id domain.SessionID) *session { | |
| 1191 | + | d.mu.Lock() | |
| 1192 | + | defer d.mu.Unlock() | |
| 1193 | + | return d.sessions[id] | |
| 1194 | + | } | |
| 1195 | + | ||
| 1196 | + | func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) { | |
| 1197 | + | targetSess := d.sessionByID(target.Session) | |
| 1198 | + | if targetSess == nil { | |
| 1199 | + | d.paint(sess, ac, true) | |
| 1200 | + | return | |
| 1201 | + | } | |
| 1202 | + | if targetSess == sess { | |
| 1203 | + | if sess.switchTab(target.TabIndex) { | |
| 1204 | + | d.paint(sess, ac, true) | |
| 1205 | + | } else { | |
| 1206 | + | d.paint(sess, ac, true) | |
| 1207 | + | } | |
| 1208 | + | return | |
| 1209 | + | } | |
| 1210 | + | if !sess.detachIfCurrent(ac) { | |
| 1211 | + | return | |
| 1212 | + | } | |
| 1213 | + | targetSess.stealClient(d, ac, target.TabIndex) | |
| 1214 | + | ac.setSession(targetSess) | |
| 1215 | + | d.firstPaint(targetSess, ac, ac.size) | |
| 1216 | + | } | |
| 1217 | + | ||
| 1218 | + | func (s *session) stealClient(d *Daemon, ac *attachedClient, idx int) { | |
| 1219 | + | s.mu.Lock() | |
| 1220 | + | old := s.client | |
| 1221 | + | s.client = ac | |
| 1222 | + | if idx >= 0 && idx < len(s.tabs) { | |
| 1223 | + | s.active = idx | |
| 1224 | + | } | |
| 1225 | + | s.mu.Unlock() | |
| 1226 | + | if old != nil && old != ac { | |
| 1227 | + | d.unregisterPreview(old) | |
| 1228 | + | old.setSession(nil) | |
| 1229 | + | d.notifyDetachedAsync(old, ports.ReasonDetach) | |
| 1230 | + | } | |
| 1231 | + | } | |
| 1232 | + | ||
| 935 | 1233 | type daemonKeyHandler struct { | |
| 936 | - | d *Daemon | |
| 937 | - | sess *session | |
| 938 | - | ac *attachedClient | |
| 1234 | + | d *Daemon | |
| 1235 | + | ac *attachedClient | |
| 939 | 1236 | } | |
| 940 | 1237 | ||
| 941 | 1238 | func (h daemonKeyHandler) Forward(data []byte) { | |
| 942 | - | win := h.sess.activeWindow() | |
| 943 | - | if win == nil { | |
| 1239 | + | sess := h.ac.currentSession() | |
| 1240 | + | if sess == nil { | |
| 944 | 1241 | return | |
| 945 | 1242 | } | |
| 946 | - | if _, err := win.pty.Write(data); err != nil { | |
| 947 | - | h.d.log.Error("pty write failed", "err", err, "session", h.sess.name) | |
| 1243 | + | tb := sess.activeTab() | |
| 1244 | + | if tb == nil { | |
| 1245 | + | return | |
| 1246 | + | } | |
| 1247 | + | if _, err := tb.pty.Write(data); err != nil { | |
| 1248 | + | h.d.log.Error("pty write failed", "err", err, "session", sess.name) | |
| 948 | 1249 | } | |
| 949 | 1250 | } | |
| 950 | 1251 | ||
| 951 | 1252 | func (h daemonKeyHandler) Action(action keys.Action) { | |
| 1253 | + | sess := h.ac.currentSession() | |
| 1254 | + | if sess == nil { | |
| 1255 | + | return | |
| 1256 | + | } | |
| 952 | 1257 | switch action { | |
| 953 | - | case keys.ActionCreateWindow: | |
| 954 | - | if err := h.d.createWindow(h.sess, h.ac.size); err != nil { | |
| 955 | - | h.d.log.Error("create window failed", "err", err, "session", h.sess.name) | |
| 1258 | + | case keys.ActionCreateTab: | |
| 1259 | + | if err := h.d.createTab(sess, h.ac.size); err != nil { | |
| 1260 | + | h.d.log.Error("create tab failed", "err", err, "session", sess.name) | |
| 956 | 1261 | return | |
| 957 | 1262 | } | |
| 958 | - | h.d.paint(h.sess, h.ac, true) | |
| 959 | - | case keys.ActionNextWindow: | |
| 960 | - | if h.sess.switchRelative(1) { | |
| 961 | - | h.d.paint(h.sess, h.ac, true) | |
| 1263 | + | h.d.paint(sess, h.ac, true) | |
| 1264 | + | case keys.ActionNextTab: | |
| 1265 | + | if sess.switchRelative(1) { | |
| 1266 | + | h.d.paint(sess, h.ac, true) | |
| 962 | 1267 | } | |
| 963 | - | case keys.ActionPreviousWindow: | |
| 964 | - | if h.sess.switchRelative(-1) { | |
| 965 | - | h.d.paint(h.sess, h.ac, true) | |
| 1268 | + | case keys.ActionPreviousTab: | |
| 1269 | + | if sess.switchRelative(-1) { | |
| 1270 | + | h.d.paint(sess, h.ac, true) | |
| 966 | 1271 | } | |
| 967 | 1272 | case keys.ActionDetach: | |
| 968 | - | h.d.clientGone(h.sess, h.ac, true) | |
| 969 | - | case keys.ActionCloseWindow: | |
| 970 | - | win := h.sess.activeWindow() | |
| 971 | - | if win != nil { | |
| 972 | - | h.d.closeWindow(h.sess, win, true) | |
| 1273 | + | h.d.clientGone(sess, h.ac, true) | |
| 1274 | + | case keys.ActionCloseTab: | |
| 1275 | + | tb := sess.activeTab() | |
| 1276 | + | if tb != nil { | |
| 1277 | + | h.d.closeTab(sess, tb, true) | |
| 973 | 1278 | } | |
| 974 | 1279 | case keys.ActionCopyMode: | |
| 975 | - | h.d.enterCopyMode(h.sess, h.ac) | |
| 1280 | + | h.d.enterCopyMode(sess, h.ac) | |
| 976 | 1281 | case keys.ActionRenameSession: | |
| 977 | - | h.sess.promoteEphemeral() | |
| 978 | - | h.d.paint(h.sess, h.ac, true) | |
| 979 | - | case keys.ActionSwitchWindow1, keys.ActionSwitchWindow2, keys.ActionSwitchWindow3, | |
| 980 | - | keys.ActionSwitchWindow4, keys.ActionSwitchWindow5, keys.ActionSwitchWindow6, | |
| 981 | - | keys.ActionSwitchWindow7, keys.ActionSwitchWindow8, keys.ActionSwitchWindow9: | |
| 982 | - | idx := int(action - keys.ActionSwitchWindow1) | |
| 983 | - | if h.sess.switchWindow(idx) { | |
| 984 | - | h.d.paint(h.sess, h.ac, true) | |
| 1282 | + | sess.promoteEphemeral() | |
| 1283 | + | h.d.paint(sess, h.ac, true) | |
| 1284 | + | case keys.ActionOpenPicker: | |
| 1285 | + | h.d.enterPicker(sess, h.ac) | |
| 1286 | + | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | |
| 1287 | + | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | |
| 1288 | + | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | |
| 1289 | + | idx := int(action - keys.ActionSwitchTab1) | |
| 1290 | + | if sess.switchTab(idx) { | |
| 1291 | + | h.d.paint(sess, h.ac, true) | |
| 985 | 1292 | } | |
| 986 | 1293 | } | |
| 987 | 1294 | } | |
| 988 | 1295 | ||
| 989 | - | func (s *session) switchWindow(idx int) bool { | |
| 1296 | + | func (s *session) switchTab(idx int) bool { | |
| 990 | 1297 | s.mu.Lock() | |
| 991 | 1298 | defer s.mu.Unlock() | |
| 992 | - | if idx < 0 || idx >= len(s.windows) || idx == s.active { | |
| 1299 | + | if idx < 0 || idx >= len(s.tabs) || idx == s.active { | |
| 993 | 1300 | return false | |
| 994 | 1301 | } | |
| 995 | 1302 | s.active = idx | |
| @@ -999,10 +1306,10 @@ func (s *session) switchWindow(idx int) bool { | |||
| 999 | 1306 | func (s *session) switchRelative(delta int) bool { | |
| 1000 | 1307 | s.mu.Lock() | |
| 1001 | 1308 | defer s.mu.Unlock() | |
| 1002 | - | if len(s.windows) < 2 { | |
| 1309 | + | if len(s.tabs) < 2 { | |
| 1003 | 1310 | return false | |
| 1004 | 1311 | } | |
| 1005 | - | s.active = (s.active + delta + len(s.windows)) % len(s.windows) | |
| 1312 | + | s.active = (s.active + delta + len(s.tabs)) % len(s.tabs) | |
| 1006 | 1313 | return true | |
| 1007 | 1314 | } | |
| 1008 | 1315 | ||
| @@ -1014,11 +1321,11 @@ func (s *session) promoteEphemeral() { | |||
| 1014 | 1321 | s.ephemeral = false | |
| 1015 | 1322 | } | |
| 1016 | 1323 | ||
| 1017 | - | func (d *Daemon) closeWindow(sess *session, win *window, repaint bool) { | |
| 1324 | + | func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |
| 1018 | 1325 | sess.mu.Lock() | |
| 1019 | 1326 | idx := -1 | |
| 1020 | - | for i, w := range sess.windows { | |
| 1021 | - | if w == win { | |
| 1327 | + | for i, w := range sess.tabs { | |
| 1328 | + | if w == tb { | |
| 1022 | 1329 | idx = i | |
| 1023 | 1330 | break | |
| 1024 | 1331 | } | |
| @@ -1027,24 +1334,24 @@ func (d *Daemon) closeWindow(sess *session, win *window, repaint bool) { | |||
| 1027 | 1334 | sess.mu.Unlock() | |
| 1028 | 1335 | return | |
| 1029 | 1336 | } | |
| 1030 | - | if len(sess.windows) == 1 { | |
| 1337 | + | if len(sess.tabs) == 1 { | |
| 1031 | 1338 | sess.mu.Unlock() | |
| 1032 | 1339 | d.killSession(sess, ports.ReasonSessionKilled) | |
| 1033 | 1340 | return | |
| 1034 | 1341 | } | |
| 1035 | - | sess.windows = append(sess.windows[:idx], sess.windows[idx+1:]...) | |
| 1036 | - | if sess.active >= len(sess.windows) { | |
| 1037 | - | sess.active = len(sess.windows) - 1 | |
| 1342 | + | sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...) | |
| 1343 | + | if sess.active >= len(sess.tabs) { | |
| 1344 | + | sess.active = len(sess.tabs) - 1 | |
| 1038 | 1345 | } else if idx < sess.active { | |
| 1039 | 1346 | sess.active-- | |
| 1040 | 1347 | } | |
| 1041 | 1348 | ac := sess.client | |
| 1042 | 1349 | sess.mu.Unlock() | |
| 1043 | 1350 | ||
| 1044 | - | if win.cancel != nil { | |
| 1045 | - | win.cancel() | |
| 1351 | + | if tb.cancel != nil { | |
| 1352 | + | tb.cancel() | |
| 1046 | 1353 | } | |
| 1047 | - | _ = win.pty.Close() | |
| 1354 | + | _ = tb.pty.Close() | |
| 1048 | 1355 | if repaint && ac != nil { | |
| 1049 | 1356 | d.paint(sess, ac, true) | |
| 1050 | 1357 | } | |
| @@ -1060,57 +1367,57 @@ func signal(ch chan struct{}) { | |||
| 1060 | 1367 | } | |
| 1061 | 1368 | } | |
| 1062 | 1369 | ||
| 1063 | - | func (d *Daemon) ptyReader(sess *session, win *window) { | |
| 1370 | + | func (d *Daemon) ptyReader(sess *session, tb *tab) { | |
| 1064 | 1371 | defer d.sessWg.Done() | |
| 1065 | 1372 | buf := make([]byte, ptyReadBufSize) | |
| 1066 | 1373 | var resp []byte | |
| 1067 | - | win.mu.Lock() | |
| 1068 | - | win.screen.OnResponse = func(b []byte) { resp = append(resp, b...) } | |
| 1069 | - | win.mu.Unlock() | |
| 1374 | + | tb.mu.Lock() | |
| 1375 | + | tb.screen.OnResponse = func(b []byte) { resp = append(resp, b...) } | |
| 1376 | + | tb.mu.Unlock() | |
| 1070 | 1377 | for { | |
| 1071 | - | n, err := win.pty.Read(buf) | |
| 1378 | + | n, err := tb.pty.Read(buf) | |
| 1072 | 1379 | if n > 0 { | |
| 1073 | 1380 | data := buf[:n] | |
| 1074 | - | win.mu.Lock() | |
| 1075 | - | wasSyncing := win.screen.SyncUpdateActive() | |
| 1076 | - | win.screen.Write(data) | |
| 1077 | - | isSyncing := win.screen.SyncUpdateActive() | |
| 1078 | - | win.mu.Unlock() | |
| 1381 | + | tb.mu.Lock() | |
| 1382 | + | wasSyncing := tb.screen.SyncUpdateActive() | |
| 1383 | + | tb.screen.Write(data) | |
| 1384 | + | isSyncing := tb.screen.SyncUpdateActive() | |
| 1385 | + | tb.mu.Unlock() | |
| 1079 | 1386 | if len(resp) > 0 { | |
| 1080 | - | if _, writeErr := win.pty.Write(resp); writeErr != nil { | |
| 1387 | + | if _, writeErr := tb.pty.Write(resp); writeErr != nil { | |
| 1081 | 1388 | d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name) | |
| 1082 | 1389 | } | |
| 1083 | 1390 | resp = resp[:0] | |
| 1084 | 1391 | } | |
| 1085 | 1392 | if wasSyncing != isSyncing { | |
| 1086 | - | win.mu.Lock() | |
| 1087 | - | win.syncGen++ | |
| 1088 | - | gen := win.syncGen | |
| 1089 | - | win.mu.Unlock() | |
| 1393 | + | tb.mu.Lock() | |
| 1394 | + | tb.syncGen++ | |
| 1395 | + | gen := tb.syncGen | |
| 1396 | + | tb.mu.Unlock() | |
| 1090 | 1397 | if isSyncing { | |
| 1091 | - | go d.syncWatchdog(win, gen) | |
| 1398 | + | go d.syncWatchdog(tb, gen) | |
| 1092 | 1399 | } | |
| 1093 | 1400 | } | |
| 1094 | 1401 | if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) { | |
| 1095 | - | signal(win.flush) | |
| 1402 | + | signal(tb.flush) | |
| 1096 | 1403 | continue | |
| 1097 | 1404 | } | |
| 1098 | 1405 | if isSyncing { | |
| 1099 | 1406 | continue | |
| 1100 | 1407 | } | |
| 1101 | - | signal(win.dirty) | |
| 1408 | + | signal(tb.dirty) | |
| 1102 | 1409 | } | |
| 1103 | 1410 | if err != nil { | |
| 1104 | - | d.closeWindow(sess, win, true) | |
| 1411 | + | d.closeTab(sess, tb, true) | |
| 1105 | 1412 | return | |
| 1106 | 1413 | } | |
| 1107 | 1414 | } | |
| 1108 | 1415 | } | |
| 1109 | 1416 | ||
| 1110 | - | // scheduler debounces dirty signals. The first dirty opens a short window; | |
| 1111 | - | // sustained floods progressively widen that window, while isolated updates | |
| 1417 | + | // scheduler debounces dirty signals. The first dirty opens a short tab; | |
| 1418 | + | // sustained floods progressively widen that tab, while isolated updates | |
| 1112 | 1419 | // return to the minimum delay for interactive latency. | |
| 1113 | - | func (d *Daemon) scheduler(sess *session, win *window) { | |
| 1420 | + | func (d *Daemon) scheduler(sess *session, tb *tab) { | |
| 1114 | 1421 | defer d.sessWg.Done() | |
| 1115 | 1422 | delay := minDebounceInterval | |
| 1116 | 1423 | lastRender := d.clock.Now() | |
| @@ -1119,13 +1426,13 @@ outer: | |||
| 1119 | 1426 | select { | |
| 1120 | 1427 | case <-sess.ctx.Done(): | |
| 1121 | 1428 | return | |
| 1122 | - | case <-windowDone(win): | |
| 1429 | + | case <-tabDone(tb): | |
| 1123 | 1430 | return | |
| 1124 | - | case <-win.flush: | |
| 1125 | - | d.render(sess, win) | |
| 1431 | + | case <-tb.flush: | |
| 1432 | + | d.render(sess, tb) | |
| 1126 | 1433 | lastRender = d.clock.Now() | |
| 1127 | 1434 | continue | |
| 1128 | - | case <-win.dirty: | |
| 1435 | + | case <-tb.dirty: | |
| 1129 | 1436 | if d.clock.Now().Sub(lastRender) >= maxDebounceInterval { | |
| 1130 | 1437 | delay = minDebounceInterval | |
| 1131 | 1438 | } | |
| @@ -1139,27 +1446,27 @@ outer: | |||
| 1139 | 1446 | case <-sess.ctx.Done(): | |
| 1140 | 1447 | timer.Stop() | |
| 1141 | 1448 | return | |
| 1142 | - | case <-windowDone(win): | |
| 1449 | + | case <-tabDone(tb): | |
| 1143 | 1450 | timer.Stop() | |
| 1144 | 1451 | return | |
| 1145 | - | case <-win.flush: | |
| 1452 | + | case <-tb.flush: | |
| 1146 | 1453 | if !timer.Stop() { | |
| 1147 | 1454 | select { | |
| 1148 | 1455 | case <-timer.C(): | |
| 1149 | 1456 | default: | |
| 1150 | 1457 | } | |
| 1151 | 1458 | } | |
| 1152 | - | d.render(sess, win) | |
| 1459 | + | d.render(sess, tb) | |
| 1153 | 1460 | lastRender = d.clock.Now() | |
| 1154 | 1461 | continue outer | |
| 1155 | - | case <-win.dirty: | |
| 1462 | + | case <-tb.dirty: | |
| 1156 | 1463 | coalesced++ | |
| 1157 | 1464 | case <-timer.C(): | |
| 1158 | 1465 | break absorb | |
| 1159 | 1466 | } | |
| 1160 | 1467 | } | |
| 1161 | 1468 | delay = nextDebounceDelay(delay, coalesced) | |
| 1162 | - | d.render(sess, win) | |
| 1469 | + | d.render(sess, tb) | |
| 1163 | 1470 | lastRender = d.clock.Now() | |
| 1164 | 1471 | } | |
| 1165 | 1472 | } | |
| @@ -1178,30 +1485,30 @@ func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration { | |||
| 1178 | 1485 | return delay | |
| 1179 | 1486 | } | |
| 1180 | 1487 | ||
| 1181 | - | func (d *Daemon) syncWatchdog(win *window, gen uint64) { | |
| 1488 | + | func (d *Daemon) syncWatchdog(tb *tab, gen uint64) { | |
| 1182 | 1489 | timer := d.clock.NewTimer(maxSyncUpdateDuration) | |
| 1183 | 1490 | select { | |
| 1184 | - | case <-windowDone(win): | |
| 1491 | + | case <-tabDone(tb): | |
| 1185 | 1492 | timer.Stop() | |
| 1186 | 1493 | return | |
| 1187 | 1494 | case <-timer.C(): | |
| 1188 | 1495 | } | |
| 1189 | 1496 | ||
| 1190 | - | win.mu.Lock() | |
| 1191 | - | if win.syncGen != gen || !win.screen.SyncUpdateActive() { | |
| 1192 | - | win.mu.Unlock() | |
| 1497 | + | tb.mu.Lock() | |
| 1498 | + | if tb.syncGen != gen || !tb.screen.SyncUpdateActive() { | |
| 1499 | + | tb.mu.Unlock() | |
| 1193 | 1500 | return | |
| 1194 | 1501 | } | |
| 1195 | - | win.screen.ForceSyncEnd() | |
| 1196 | - | win.mu.Unlock() | |
| 1197 | - | signal(win.flush) | |
| 1502 | + | tb.screen.ForceSyncEnd() | |
| 1503 | + | tb.mu.Unlock() | |
| 1504 | + | signal(tb.flush) | |
| 1198 | 1505 | } | |
| 1199 | 1506 | ||
| 1200 | - | func windowDone(win *window) <-chan struct{} { | |
| 1201 | - | if win.ctx == nil { | |
| 1507 | + | func tabDone(tb *tab) <-chan struct{} { | |
| 1508 | + | if tb.ctx == nil { | |
| 1202 | 1509 | return nil | |
| 1203 | 1510 | } | |
| 1204 | - | return win.ctx.Done() | |
| 1511 | + | return tb.ctx.Done() | |
| 1205 | 1512 | } | |
| 1206 | 1513 | ||
| 1207 | 1514 | func syncUpdateEndIn(data []byte) bool { | |
| @@ -1210,60 +1517,92 @@ func syncUpdateEndIn(data []byte) bool { | |||
| 1210 | 1517 | ||
| 1211 | 1518 | // render paints the current client, or (when detached) just clears accumulated | |
| 1212 | 1519 | // damage so it never grows unbounded while headless. | |
| 1213 | - | func (d *Daemon) render(sess *session, win *window) { | |
| 1214 | - | win.mu.Lock() | |
| 1215 | - | if win.screen.SyncUpdateActive() { | |
| 1216 | - | win.mu.Unlock() | |
| 1520 | + | func (d *Daemon) render(sess *session, tb *tab) { | |
| 1521 | + | tb.mu.Lock() | |
| 1522 | + | if tb.screen.SyncUpdateActive() { | |
| 1523 | + | tb.mu.Unlock() | |
| 1217 | 1524 | return | |
| 1218 | 1525 | } | |
| 1219 | - | win.mu.Unlock() | |
| 1526 | + | tb.mu.Unlock() | |
| 1527 | + | ||
| 1528 | + | tb.mu.Lock() | |
| 1529 | + | previewer := tb.previewClient | |
| 1530 | + | tb.mu.Unlock() | |
| 1220 | 1531 | ||
| 1221 | 1532 | sess.mu.Lock() | |
| 1222 | 1533 | ac := sess.client | |
| 1223 | - | active := sess.active >= 0 && sess.active < len(sess.windows) && sess.windows[sess.active] == win | |
| 1534 | + | active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb | |
| 1224 | 1535 | sess.mu.Unlock() | |
| 1225 | 1536 | ||
| 1537 | + | if previewer != nil { | |
| 1538 | + | if previewSess := previewer.currentSession(); previewSess != nil { | |
| 1539 | + | d.paint(previewSess, previewer, false) | |
| 1540 | + | } | |
| 1541 | + | if ac != nil && active && ac != previewer { | |
| 1542 | + | d.paint(sess, ac, false) | |
| 1543 | + | return | |
| 1544 | + | } | |
| 1545 | + | tb.mu.Lock() | |
| 1546 | + | tb.screen.ClearDamage() | |
| 1547 | + | tb.mu.Unlock() | |
| 1548 | + | return | |
| 1549 | + | } | |
| 1550 | + | ||
| 1226 | 1551 | if ac == nil || !active { | |
| 1227 | - | win.mu.Lock() | |
| 1228 | - | win.screen.ClearDamage() | |
| 1229 | - | win.mu.Unlock() | |
| 1552 | + | tb.mu.Lock() | |
| 1553 | + | tb.screen.ClearDamage() | |
| 1554 | + | tb.mu.Unlock() | |
| 1230 | 1555 | return | |
| 1231 | 1556 | } | |
| 1232 | 1557 | d.paint(sess, ac, false) | |
| 1233 | 1558 | } | |
| 1234 | 1559 | ||
| 1235 | - | // paint draws the composed client frame (active window plus status bar) and | |
| 1560 | + | // paint draws the composed client frame (active tab plus status bar) and | |
| 1236 | 1561 | // sends the resulting bytes. The renderer shadow is reset on explicit invalidations | |
| 1237 | 1562 | // such as switch/create/close/rename/resize so the repaint is complete. | |
| 1238 | 1563 | func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |
| 1239 | - | win := sess.activeWindow() | |
| 1240 | - | if win == nil { | |
| 1564 | + | tb := sess.activeTab() | |
| 1565 | + | if tb == nil { | |
| 1241 | 1566 | return | |
| 1242 | 1567 | } | |
| 1243 | 1568 | ||
| 1244 | 1569 | ac.sendMu.Lock() | |
| 1245 | - | win.mu.Lock() | |
| 1246 | 1570 | ac.copyMu.Lock() | |
| 1247 | 1571 | copyActive := ac.copyMode != nil | |
| 1248 | - | if reset || copyActive { | |
| 1572 | + | copyMode := ac.copyMode | |
| 1573 | + | ac.copyMu.Unlock() | |
| 1574 | + | ac.pickerMu.Lock() | |
| 1575 | + | pickerActive := ac.picker != nil | |
| 1576 | + | pickerModel := ac.picker | |
| 1577 | + | previewTab := ac.pickerPreview | |
| 1578 | + | preview := snapshotPickerPreview(previewTab) | |
| 1579 | + | ||
| 1580 | + | tb.mu.Lock() | |
| 1581 | + | if reset || copyActive || pickerActive { | |
| 1249 | 1582 | ac.rend.Reset() | |
| 1250 | 1583 | } | |
| 1251 | - | if reset { | |
| 1584 | + | if reset || pickerActive { | |
| 1252 | 1585 | ac.lastCursor.valid = false | |
| 1253 | 1586 | } | |
| 1254 | - | frame, damage := composeClientFrame(sess, win, reset) | |
| 1587 | + | frame, damage := composeClientFrame(sess, tb, reset) | |
| 1255 | 1588 | if copyActive { | |
| 1256 | - | frame, damage = composeCopyClientFrame(ac.copyMode, win) | |
| 1589 | + | frame, damage = composeCopyClientFrame(copyMode, tb) | |
| 1257 | 1590 | } | |
| 1258 | - | desiredCursor := desiredCursorOut(win.screen, copyActive) | |
| 1259 | - | ac.copyMu.Unlock() | |
| 1591 | + | if pickerActive { | |
| 1592 | + | if previewTab == tb { | |
| 1593 | + | preview = pickerPreviewFromLockedTab(tb) | |
| 1594 | + | } | |
| 1595 | + | frame, damage = composePickerClientFrame(pickerModel, preview, frame) | |
| 1596 | + | } | |
| 1597 | + | ac.pickerMu.Unlock() | |
| 1598 | + | desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive) | |
| 1260 | 1599 | data, err := ac.rend.Draw(frame, damage) | |
| 1261 | 1600 | var cursorTail []byte | |
| 1262 | 1601 | if err == nil { | |
| 1263 | 1602 | cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0) | |
| 1264 | 1603 | } | |
| 1265 | - | win.screen.ClearDamage() | |
| 1266 | - | win.mu.Unlock() | |
| 1604 | + | tb.screen.ClearDamage() | |
| 1605 | + | tb.mu.Unlock() | |
| 1267 | 1606 | ||
| 1268 | 1607 | var serr error | |
| 1269 | 1608 | if err == nil { | |
| @@ -1283,7 +1622,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 1283 | 1622 | } | |
| 1284 | 1623 | } | |
| 1285 | 1624 | ||
| 1286 | - | // resize applies a client size change to every window using rows-1 for the PTY | |
| 1625 | + | // resize applies a client size change to every tab using rows-1 for the PTY | |
| 1287 | 1626 | // and VT screen, then resets the renderer shadow and repaints the composed | |
| 1288 | 1627 | // client-sized frame (including the status row). | |
| 1289 | 1628 | func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut { | |
| @@ -1323,27 +1662,55 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte | |||
| 1323 | 1662 | return b | |
| 1324 | 1663 | } | |
| 1325 | 1664 | ||
| 1326 | - | func composeClientFrame(sess *session, win *window, full bool) (renderer.Frame, []renderer.Damage) { | |
| 1327 | - | width, screenRows := win.screen.Frame.Width, win.screen.Frame.Height | |
| 1665 | + | func composeClientFrame(sess *session, tb *tab, full bool) (renderer.Frame, []renderer.Damage) { | |
| 1666 | + | width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height | |
| 1328 | 1667 | frame := renderer.NewFrame(width, screenRows+1) | |
| 1329 | 1668 | for y := range screenRows { | |
| 1330 | - | copy(frame.Row(y), win.screen.Frame.Row(y)) | |
| 1669 | + | copy(frame.Row(y), tb.screen.Frame.Row(y)) | |
| 1331 | 1670 | } | |
| 1332 | 1671 | drawStatus(frame.Row(screenRows), sess) | |
| 1333 | 1672 | if full { | |
| 1334 | 1673 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 1335 | 1674 | } | |
| 1336 | - | damage := append([]renderer.Damage(nil), win.screen.Damage()...) | |
| 1675 | + | damage := append([]renderer.Damage(nil), tb.screen.Damage()...) | |
| 1337 | 1676 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows, Width: width, Height: 1}) | |
| 1338 | 1677 | return frame, damage | |
| 1339 | 1678 | } | |
| 1340 | 1679 | ||
| 1341 | - | func composeCopyClientFrame(mode *scopy.Mode, win *window) (renderer.Frame, []renderer.Damage) { | |
| 1342 | - | snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame) | |
| 1680 | + | func composeCopyClientFrame(mode *scopy.Mode, tb *tab) (renderer.Frame, []renderer.Damage) { | |
| 1681 | + | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 1343 | 1682 | frame := mode.Render(snap) | |
| 1344 | 1683 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 1345 | 1684 | } | |
| 1346 | 1685 | ||
| 1686 | + | func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame) (renderer.Frame, []renderer.Damage) { | |
| 1687 | + | inner := pickerModal.Composite(base, renderer.DefaultStyle()) | |
| 1688 | + | modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview) | |
| 1689 | + | for y := range min(inner.Height, modalFrame.Height) { | |
| 1690 | + | for x := range min(inner.Width, modalFrame.Width) { | |
| 1691 | + | base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y)) | |
| 1692 | + | } | |
| 1693 | + | } | |
| 1694 | + | return base, []renderer.Damage{renderer.FullRedraw()} | |
| 1695 | + | } | |
| 1696 | + | ||
| 1697 | + | func snapshotPickerPreview(tb *tab) picker.Preview { | |
| 1698 | + | if tb == nil { | |
| 1699 | + | return picker.Preview{} | |
| 1700 | + | } | |
| 1701 | + | tb.mu.Lock() | |
| 1702 | + | defer tb.mu.Unlock() | |
| 1703 | + | return pickerPreviewFromLockedTab(tb) | |
| 1704 | + | } | |
| 1705 | + | ||
| 1706 | + | func pickerPreviewFromLockedTab(tb *tab) picker.Preview { | |
| 1707 | + | rows := make([][]renderer.Cell, tb.screen.Frame.Height) | |
| 1708 | + | for y := range rows { | |
| 1709 | + | rows[y] = append([]renderer.Cell(nil), tb.screen.Frame.Row(y)...) | |
| 1710 | + | } | |
| 1711 | + | return picker.Preview{Rows: rows, Width: tb.screen.Frame.Width, Height: tb.screen.Frame.Height} | |
| 1712 | + | } | |
| 1713 | + | ||
| 1347 | 1714 | func drawStatus(row []renderer.Cell, sess *session) { | |
| 1348 | 1715 | for i := range row { | |
| 1349 | 1716 | row[i] = renderer.BlankCell() | |
| @@ -1351,7 +1718,7 @@ func drawStatus(row []renderer.Cell, sess *session) { | |||
| 1351 | 1718 | status := sess.statusSegments() | |
| 1352 | 1719 | x := 0 | |
| 1353 | 1720 | writeStatusText(row, &x, " "+status.session+" ", renderer.DefaultStyle()) | |
| 1354 | - | for _, w := range status.windows { | |
| 1721 | + | for _, w := range status.tabs { | |
| 1355 | 1722 | style := renderer.DefaultStyle() | |
| 1356 | 1723 | if w.active { | |
| 1357 | 1724 | style.Inverse = true | |
| @@ -1362,10 +1729,10 @@ func drawStatus(row []renderer.Cell, sess *session) { | |||
| 1362 | 1729 | ||
| 1363 | 1730 | type statusSnapshot struct { | |
| 1364 | 1731 | session string | |
| 1365 | - | windows []statusWindow | |
| 1732 | + | tabs []statusTab | |
| 1366 | 1733 | } | |
| 1367 | 1734 | ||
| 1368 | - | type statusWindow struct { | |
| 1735 | + | type statusTab struct { | |
| 1369 | 1736 | name string | |
| 1370 | 1737 | active bool | |
| 1371 | 1738 | } | |
| @@ -1373,10 +1740,10 @@ type statusWindow struct { | |||
| 1373 | 1740 | func (s *session) statusSegments() statusSnapshot { | |
| 1374 | 1741 | s.mu.Lock() | |
| 1375 | 1742 | defer s.mu.Unlock() | |
| 1376 | - | snap := statusSnapshot{session: s.name, windows: make([]statusWindow, len(s.windows))} | |
| 1377 | - | for i := range s.windows { | |
| 1743 | + | snap := statusSnapshot{session: s.name, tabs: make([]statusTab, len(s.tabs))} | |
| 1744 | + | for i := range s.tabs { | |
| 1378 | 1745 | name := strconv.Itoa(i + 1) | |
| 1379 | - | snap.windows[i] = statusWindow{name: name, active: i == s.active} | |
| 1746 | + | snap.tabs[i] = statusTab{name: name, active: i == s.active} | |
| 1380 | 1747 | } | |
| 1381 | 1748 | return snap | |
| 1382 | 1749 | } | |
| @@ -1395,24 +1762,24 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||
| 1395 | 1762 | if !sz.Valid() { | |
| 1396 | 1763 | return | |
| 1397 | 1764 | } | |
| 1398 | - | winSize := windowSize(sz) | |
| 1765 | + | tbSize := tabSize(sz) | |
| 1399 | 1766 | ac.size = sz | |
| 1400 | 1767 | ||
| 1401 | 1768 | sess.mu.Lock() | |
| 1402 | - | windows := append([]*window(nil), sess.windows...) | |
| 1769 | + | tabs := append([]*tab(nil), sess.tabs...) | |
| 1403 | 1770 | sess.mu.Unlock() | |
| 1404 | - | if len(windows) == 0 { | |
| 1771 | + | if len(tabs) == 0 { | |
| 1405 | 1772 | return | |
| 1406 | 1773 | } | |
| 1407 | 1774 | ||
| 1408 | - | for _, win := range windows { | |
| 1409 | - | if err := win.pty.Resize(winSize); err != nil { | |
| 1775 | + | for _, tb := range tabs { | |
| 1776 | + | if err := tb.pty.Resize(tbSize); err != nil { | |
| 1410 | 1777 | d.log.Warn("pty resize failed", "err", err, "session", sess.name) | |
| 1411 | 1778 | } | |
| 1412 | - | win.mu.Lock() | |
| 1413 | - | win.screen.Resize(winSize.Cols, winSize.Rows) | |
| 1414 | - | win.size = winSize | |
| 1415 | - | win.mu.Unlock() | |
| 1779 | + | tb.mu.Lock() | |
| 1780 | + | tb.screen.Resize(tbSize.Cols, tbSize.Rows) | |
| 1781 | + | tb.size = tbSize | |
| 1782 | + | tb.mu.Unlock() | |
| 1416 | 1783 | } | |
| 1417 | 1784 | d.paint(sess, ac, true) | |
| 1418 | 1785 | } | |
| @@ -1422,6 +1789,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||
| 1422 | 1789 | // its client's); a named session keeps running headless. | |
| 1423 | 1790 | func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) { | |
| 1424 | 1791 | if sess.detachIfCurrent(ac) { | |
| 1792 | + | d.unregisterPreview(ac) | |
| 1425 | 1793 | _ = ac.tr.Close() | |
| 1426 | 1794 | d.log.Warn("detached client after send error", "session", sess.name) | |
| 1427 | 1795 | if sess.ephemeral { | |
| @@ -1458,13 +1826,17 @@ func (d *Daemon) killSession(sess *session, reason uint8) { | |||
| 1458 | 1826 | ac := sess.client | |
| 1459 | 1827 | sess.client = nil | |
| 1460 | 1828 | sess.mu.Unlock() | |
| 1829 | + | if ac != nil { | |
| 1830 | + | d.unregisterPreview(ac) | |
| 1831 | + | ac.setSession(nil) | |
| 1832 | + | } | |
| 1461 | 1833 | ||
| 1462 | 1834 | sess.cancel() | |
| 1463 | 1835 | sess.mu.Lock() | |
| 1464 | - | windows := append([]*window(nil), sess.windows...) | |
| 1836 | + | tabs := append([]*tab(nil), sess.tabs...) | |
| 1465 | 1837 | sess.mu.Unlock() | |
| 1466 | - | for _, win := range windows { | |
| 1467 | - | _ = win.pty.Close() | |
| 1838 | + | for _, tb := range tabs { | |
| 1839 | + | _ = tb.pty.Close() | |
| 1468 | 1840 | } | |
| 1469 | 1841 | if empty { | |
| 1470 | 1842 | d.doneOnce.Do(func() { close(d.done) }) | |
| @@ -1,8 +1,8 @@ | |||||
| 1 | // Package daemon holds vev's server-side session multiplexer use case: the | 1 | // Package daemon holds vev's server-side session multiplexer use case: the | ||
| 2 | - | // accept loop, the ephemeral/named session registry, the per-window PTY reader | 2 | + | // accept loop, the ephemeral/named session registry, the per-tab PTY reader |
| 3 | // and VT screen, and the per-client debounced render scheduler. | 3 | // and VT screen, and the per-client debounced render scheduler. | ||
| 4 | // | 4 | // | ||
| 5 | - | // Concurrency model (one window per session for the MVP; multi-window is M3): | 5 | + | // Concurrency model (one tab per session for the MVP; multi-tab is M3): |
| 6 | // | 6 | // | ||
| 7 | // - Serve runs the accept loop. Each accepted connection is handled by its | 7 | // - Serve runs the accept loop. Each accepted connection is handled by its | ||
| 8 | // own goroutine (handleConn): it reads the first frame and routes it to a | 8 | // own goroutine (handleConn): it reads the first frame and routes it to a | ||
| @@ -18,10 +18,10 @@ | |||||
| 18 | // attached clients with ReasonServerShutdown). | 18 | // attached clients with ReasonServerShutdown). | ||
| 19 | // | 19 | // | ||
| 20 | // Locking: a session's screen and per-client renderer shadow are both guarded | 20 | // Locking: a session's screen and per-client renderer shadow are both guarded | ||
| 21 | - | // by window.mu; the attached-client pointer by session.mu; the registry by | 21 | + | // by tab.mu; the attached-client pointer by session.mu; the registry by |
| 22 | // Daemon.mu. When more than one is held the order is always | 22 | // Daemon.mu. When more than one is held the order is always | ||
| 23 | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | 23 | // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu > | ||
| 24 | - | // window.mu — the PTY reader only ever takes window.mu, so it never blocks on | 24 | + | // tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on |
| 25 | // a slow client. | 25 | // a slow client. | ||
| 26 | package daemon | 26 | package daemon | ||
| 27 | 27 | ||||
| @@ -43,6 +43,8 @@ import ( | |||||
| 43 | scopy "github.com/bnema/vev/internal/usecase/copy" | 43 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 44 | "github.com/bnema/vev/internal/usecase/keys" | 44 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 45 | "github.com/bnema/vev/internal/usecase/mouse" | 45 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| 46 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 47 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 46 | "github.com/bnema/vev/pkg/renderer" | 48 | "github.com/bnema/vev/pkg/renderer" | ||
| 47 | "github.com/bnema/vev/pkg/vt" | 49 | "github.com/bnema/vev/pkg/vt" | ||
| 48 | ) | 50 | ) | ||
| @@ -56,13 +58,15 @@ const ( | |||||
| 56 | maxSyncUpdateDuration = 500 * time.Millisecond | 58 | maxSyncUpdateDuration = 500 * time.Millisecond | ||
| 57 | ) | 59 | ) | ||
| 58 | 60 | ||||
| 61 | + | var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions "} | |||
| 62 | + | ||||
| 59 | // debounceInterval is kept as a test/back-compat alias for the initial delay. | 63 | // debounceInterval is kept as a test/back-compat alias for the initial delay. | ||
| 60 | const debounceInterval = minDebounceInterval | 64 | const debounceInterval = minDebounceInterval | ||
| 61 | 65 | ||||
| 62 | // ptyReadBufSize is the PTY reader's read buffer. | 66 | // ptyReadBufSize is the PTY reader's read buffer. | ||
| 63 | const ptyReadBufSize = 32 * 1024 | 67 | const ptyReadBufSize = 32 * 1024 | ||
| 64 | 68 | ||||
| 65 | - | // defaultScrollbackRows is the per-window retained history size. | 69 | + | // defaultScrollbackRows is the per-tab retained history size. |
| 66 | const defaultScrollbackRows = 10_000 | 70 | const defaultScrollbackRows = 10_000 | ||
| 67 | 71 | ||||
| 68 | // detachNotifyTimeout bounds the best-effort Detached notification on the | 72 | // detachNotifyTimeout bounds the best-effort Detached notification on the | ||
| @@ -117,7 +121,7 @@ type Daemon struct { | |||||
| 117 | } | 121 | } | ||
| 118 | 122 | ||||
| 119 | // session is a single multiplexed session. It owns one or more full-screen | 123 | // session is a single multiplexed session. It owns one or more full-screen | ||
| 120 | - | // windows and at most one attached client. | 124 | + | // tabs and at most one attached client. |
| 121 | type session struct { | 125 | type session struct { | ||
| 122 | id domain.SessionID | 126 | id domain.SessionID | ||
| 123 | name string | 127 | name string | ||
| @@ -126,16 +130,16 @@ type session struct { | |||||
| 126 | ctx context.Context | 130 | ctx context.Context | ||
| 127 | cancel context.CancelFunc | 131 | cancel context.CancelFunc | ||
| 128 | 132 | ||||
| 129 | - | mu sync.Mutex // guards windows, active, and client | 133 | + | mu sync.Mutex // guards tabs, active, and client |
| 130 | - | windows []*window | 134 | + | tabs []*tab |
| 131 | - | active int | 135 | + | active int |
| 132 | - | client *attachedClient | 136 | + | client *attachedClient |
| 133 | } | 137 | } | ||
| 134 | 138 | ||||
| 135 | - | // window is one PTY-backed screen. dirty is a cap-1 channel so bursty output | 139 | + | // tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output |
| 136 | // applies back-pressure by collapsing (a full buffer means "a render is | 140 | // applies back-pressure by collapsing (a full buffer means "a render is | ||
| 137 | // already pending"), never by blocking the reader. | 141 | // already pending"), never by blocking the reader. | ||
| 138 | - | type window struct { | 142 | + | type tab struct { |
| 139 | pty ports.PTY | 143 | pty ports.PTY | ||
| 140 | mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow | 144 | mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow | ||
| 141 | screen *vt.Screen | 145 | screen *vt.Screen | ||
| @@ -143,27 +147,36 @@ type window struct { | |||||
| 143 | dirty chan struct{} | 147 | dirty chan struct{} | ||
| 144 | flush chan struct{} | 148 | flush chan struct{} | ||
| 145 | syncGen uint64 | 149 | syncGen uint64 | ||
| 146 | - | size domain.Size | 150 | + | // previewClient tracks the one client currently previewing this tab in the picker. |
| 147 | - | ctx context.Context | 151 | + | // v1 is last-writer-wins: multiple clients previewing the same tab are not supported. |
| 148 | - | cancel context.CancelFunc | 152 | + | previewClient *attachedClient |
| 153 | + | size domain.Size | |||
| 154 | + | ctx context.Context | |||
| 155 | + | cancel context.CancelFunc | |||
| 149 | } | 156 | } | ||
| 150 | 157 | ||||
| 151 | - | // attachedClient is a client currently attached to a session's window. rend is | 158 | + | // attachedClient is a client currently attached to a session's tab. rend is |
| 152 | // its private renderer shadow (so each client gets output minimised against | 159 | // its private renderer shadow (so each client gets output minimised against | ||
| 153 | // what it has actually seen). sendMu serialises the two senders — the render | 160 | // what it has actually seen). sendMu serialises the two senders — the render | ||
| 154 | // scheduler and the connection handler — so the transport's single-writer | 161 | // scheduler and the connection handler — so the transport's single-writer | ||
| 155 | // contract holds and Draw→Send stays atomic (correct output ordering). | 162 | // contract holds and Draw→Send stays atomic (correct output ordering). | ||
| 156 | type attachedClient struct { | 163 | type attachedClient struct { | ||
| 157 | - | tr ports.Transport | 164 | + | tr ports.Transport |
| 158 | - | rend *renderer.Renderer | 165 | + | rend *renderer.Renderer |
| 159 | - | size domain.Size | 166 | + | size domain.Size |
| 160 | - | keys *keys.Router | 167 | + | keys *keys.Router |
| 161 | - | copyMu sync.Mutex | 168 | + | sessMu sync.Mutex |
| 162 | - | copyMode *scopy.Mode | 169 | + | sess *session |
| 163 | - | copyPending []byte | 170 | + | copyMu sync.Mutex |
| 164 | - | mouseScan mouse.Scanner | 171 | + | copyMode *scopy.Mode |
| 165 | - | lastCursor cursorOut | 172 | + | copyPending []byte |
| 166 | - | sendMu sync.Mutex | 173 | + | pickerMu sync.Mutex |
| 174 | + | picker *picker.Model | |||
| 175 | + | pickerPreview *tab | |||
| 176 | + | pickerPending []byte | |||
| 177 | + | mouseScan mouse.Scanner | |||
| 178 | + | lastCursor cursorOut | |||
| 179 | + | sendMu sync.Mutex | |||
| 167 | } | 180 | } | ||
| 168 | 181 | ||||
| 169 | type cursorOut struct { | 182 | type cursorOut struct { | ||
| @@ -175,12 +188,30 @@ type cursorOut struct { | |||||
| 175 | hasStyle bool | 188 | hasStyle bool | ||
| 176 | } | 189 | } | ||
| 177 | 190 | ||||
| 191 | + | func (ac *attachedClient) currentSession() *session { | |||
| 192 | + | ac.sessMu.Lock() | |||
| 193 | + | defer ac.sessMu.Unlock() | |||
| 194 | + | return ac.sess | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func (ac *attachedClient) setSession(sess *session) { | |||
| 198 | + | ac.sessMu.Lock() | |||
| 199 | + | defer ac.sessMu.Unlock() | |||
| 200 | + | ac.sess = sess | |||
| 201 | + | } | |||
| 202 | + | ||||
| 178 | func (ac *attachedClient) copyModeActive() bool { | 203 | func (ac *attachedClient) copyModeActive() bool { | ||
| 179 | ac.copyMu.Lock() | 204 | ac.copyMu.Lock() | ||
| 180 | defer ac.copyMu.Unlock() | 205 | defer ac.copyMu.Unlock() | ||
| 181 | return ac.copyMode != nil | 206 | return ac.copyMode != nil | ||
| 182 | } | 207 | } | ||
| 183 | 208 | ||||
| 209 | + | func (ac *attachedClient) pickerActive() bool { | |||
| 210 | + | ac.pickerMu.Lock() | |||
| 211 | + | defer ac.pickerMu.Unlock() | |||
| 212 | + | return ac.picker != nil | |||
| 213 | + | } | |||
| 214 | + | ||||
| 184 | // send serialises a frame onto the client's transport. | 215 | // send serialises a frame onto the client's transport. | ||
| 185 | func (ac *attachedClient) send(f ports.Frame) error { | 216 | func (ac *attachedClient) send(f ports.Frame) error { | ||
| 186 | ac.sendMu.Lock() | 217 | ac.sendMu.Lock() | ||
| @@ -391,7 +422,7 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||||
| 391 | SessionID: string(s.id), | 422 | SessionID: string(s.id), | ||
| 392 | Name: s.name, | 423 | Name: s.name, | ||
| 393 | Ephemeral: s.ephemeral, | 424 | Ephemeral: s.ephemeral, | ||
| 394 | - | Windows: uint16(len(s.windows)), | 425 | + | Tabs: uint16(len(s.tabs)), |
| 395 | Attached: s.client != nil, | 426 | Attached: s.client != nil, | ||
| 396 | } | 427 | } | ||
| 397 | s.mu.Unlock() | 428 | s.mu.Unlock() | ||
| @@ -528,8 +559,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 528 | // createSessionLocked spawns a PTY-backed session and starts its reader and | 559 | // createSessionLocked spawns a PTY-backed session and starts its reader and | ||
| 529 | // scheduler goroutines. Caller holds d.mu. | 560 | // scheduler goroutines. Caller holds d.mu. | ||
| 530 | func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size) (*session, error) { | 561 | func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size) (*session, error) { | ||
| 531 | - | winSize := windowSize(sz) | 562 | + | tbSize := tabSize(sz) |
| 532 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), winSize) | 563 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), tbSize) |
| 533 | if err != nil { | 564 | if err != nil { | ||
| 534 | return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err) | 565 | return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err) | ||
| 535 | } | 566 | } | ||
| @@ -537,43 +568,43 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size | |||||
| 537 | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | 568 | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | ||
| 538 | d.nextID++ | 569 | d.nextID++ | ||
| 539 | 570 | ||||
| 540 | - | win := newWindow(pty, winSize) | 571 | + | tb := newTab(pty, tbSize) |
| 541 | sctx, cancel := context.WithCancel(d.serveCtx) | 572 | sctx, cancel := context.WithCancel(d.serveCtx) | ||
| 542 | - | win.ctx, win.cancel = context.WithCancel(sctx) | 573 | + | tb.ctx, tb.cancel = context.WithCancel(sctx) |
| 543 | sess := &session{ | 574 | sess := &session{ | ||
| 544 | id: id, | 575 | id: id, | ||
| 545 | name: name, | 576 | name: name, | ||
| 546 | ephemeral: ephemeral, | 577 | ephemeral: ephemeral, | ||
| 547 | ctx: sctx, | 578 | ctx: sctx, | ||
| 548 | cancel: cancel, | 579 | cancel: cancel, | ||
| 549 | - | windows: []*window{win}, | 580 | + | tabs: []*tab{tb}, |
| 550 | } | 581 | } | ||
| 551 | d.sessions[id] = sess | 582 | d.sessions[id] = sess | ||
| 552 | - | d.startWindowGoroutines(sess, win) | 583 | + | d.startTabGoroutines(sess, tb) |
| 553 | return sess, nil | 584 | return sess, nil | ||
| 554 | } | 585 | } | ||
| 555 | 586 | ||||
| 556 | - | func (d *Daemon) createWindow(sess *session, sz domain.Size) error { | 587 | + | func (d *Daemon) createTab(sess *session, sz domain.Size) error { |
| 557 | - | winSize := windowSize(sz) | 588 | + | tbSize := tabSize(sz) |
| 558 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), winSize) | 589 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), tbSize) |
| 559 | if err != nil { | 590 | if err != nil { | ||
| 560 | - | return fmt.Errorf("daemon: spawning window for session %q: %w", sess.name, err) | 591 | + | return fmt.Errorf("daemon: spawning tab for session %q: %w", sess.name, err) |
| 561 | } | 592 | } | ||
| 562 | - | win := newWindow(pty, winSize) | 593 | + | tb := newTab(pty, tbSize) |
| 563 | - | win.ctx, win.cancel = context.WithCancel(sess.ctx) | 594 | + | tb.ctx, tb.cancel = context.WithCancel(sess.ctx) |
| 564 | sess.mu.Lock() | 595 | sess.mu.Lock() | ||
| 565 | - | sess.windows = append(sess.windows, win) | 596 | + | sess.tabs = append(sess.tabs, tb) |
| 566 | - | sess.active = len(sess.windows) - 1 | 597 | + | sess.active = len(sess.tabs) - 1 |
| 567 | sess.mu.Unlock() | 598 | sess.mu.Unlock() | ||
| 568 | - | d.startWindowGoroutines(sess, win) | 599 | + | d.startTabGoroutines(sess, tb) |
| 569 | return nil | 600 | return nil | ||
| 570 | } | 601 | } | ||
| 571 | 602 | ||||
| 572 | - | func newWindow(pty ports.PTY, sz domain.Size) *window { | 603 | + | func newTab(pty ports.PTY, sz domain.Size) *tab { |
| 573 | sb := scopy.NewScrollback(defaultScrollbackRows) | 604 | sb := scopy.NewScrollback(defaultScrollbackRows) | ||
| 574 | screen := vt.NewScreen(sz.Cols, sz.Rows) | 605 | screen := vt.NewScreen(sz.Cols, sz.Rows) | ||
| 575 | screen.OnLineEvicted = sb.Append | 606 | screen.OnLineEvicted = sb.Append | ||
| 576 | - | return &window{ | 607 | + | return &tab{ |
| 577 | pty: pty, | 608 | pty: pty, | ||
| 578 | screen: screen, | 609 | screen: screen, | ||
| 579 | scrollback: sb, | 610 | scrollback: sb, | ||
| @@ -583,7 +614,7 @@ func newWindow(pty ports.PTY, sz domain.Size) *window { | |||||
| 583 | } | 614 | } | ||
| 584 | } | 615 | } | ||
| 585 | 616 | ||||
| 586 | - | func windowSize(clientSize domain.Size) domain.Size { | 617 | + | func tabSize(clientSize domain.Size) domain.Size { |
| 587 | if !clientSize.Valid() { | 618 | if !clientSize.Valid() { | ||
| 588 | clientSize = defaultSize | 619 | clientSize = defaultSize | ||
| 589 | } | 620 | } | ||
| @@ -591,10 +622,10 @@ func windowSize(clientSize domain.Size) domain.Size { | |||||
| 591 | return domain.Size{Cols: clientSize.Cols, Rows: rows} | 622 | return domain.Size{Cols: clientSize.Cols, Rows: rows} | ||
| 592 | } | 623 | } | ||
| 593 | 624 | ||||
| 594 | - | func (d *Daemon) startWindowGoroutines(sess *session, win *window) { | 625 | + | func (d *Daemon) startTabGoroutines(sess *session, tb *tab) { |
| 595 | d.sessWg.Add(2) | 626 | d.sessWg.Add(2) | ||
| 596 | - | go d.ptyReader(sess, win) | 627 | + | go d.ptyReader(sess, tb) |
| 597 | - | go d.scheduler(sess, win) | 628 | + | go d.scheduler(sess, tb) |
| 598 | } | 629 | } | ||
| 599 | 630 | ||||
| 600 | // attachClient makes ac the session's current client, displacing any prior one | 631 | // attachClient makes ac the session's current client, displacing any prior one | ||
| @@ -606,13 +637,16 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) | |||||
| 606 | rend: renderer.New(renderer.Capabilities{}), | 637 | rend: renderer.New(renderer.Capabilities{}), | ||
| 607 | size: sz, | 638 | size: sz, | ||
| 608 | } | 639 | } | ||
| 609 | - | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac}) | 640 | + | ac.setSession(sess) |
| 641 | + | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}) | |||
| 610 | sess.mu.Lock() | 642 | sess.mu.Lock() | ||
| 611 | old := sess.client | 643 | old := sess.client | ||
| 612 | sess.client = ac | 644 | sess.client = ac | ||
| 613 | sess.mu.Unlock() | 645 | sess.mu.Unlock() | ||
| 614 | 646 | ||||
| 615 | if old != nil { | 647 | if old != nil { | ||
| 648 | + | d.unregisterPreview(old) | |||
| 649 | + | old.setSession(nil) | |||
| 616 | // Async + bounded: a dead or wedged old client must not stall the new | 650 | // Async + bounded: a dead or wedged old client must not stall the new | ||
| 617 | // client's handshake. | 651 | // client's handshake. | ||
| 618 | d.notifyDetachedAsync(old, ports.ReasonDetach) | 652 | d.notifyDetachedAsync(old, ports.ReasonDetach) | ||
| @@ -621,18 +655,18 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) | |||||
| 621 | } | 655 | } | ||
| 622 | 656 | ||||
| 623 | // firstPaint guarantees the freshly attached client sees the full screen: if | 657 | // firstPaint guarantees the freshly attached client sees the full screen: if | ||
| 624 | - | // the window size differs from the client's it resizes first (which paints a | 658 | + | // the tab size differs from the client's it resizes first (which paints a |
| 625 | // full redraw), otherwise it forces a full paint against the fresh renderer. | 659 | // full redraw), otherwise it forces a full paint against the fresh renderer. | ||
| 626 | func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) { | 660 | func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) { | ||
| 627 | - | win := sess.activeWindow() | 661 | + | tb := sess.activeTab() |
| 628 | - | if win == nil { | 662 | + | if tb == nil { |
| 629 | return | 663 | return | ||
| 630 | } | 664 | } | ||
| 631 | - | win.mu.Lock() | 665 | + | tb.mu.Lock() |
| 632 | - | wsz := win.size | 666 | + | wsz := tb.size |
| 633 | - | win.mu.Unlock() | 667 | + | tb.mu.Unlock() |
| 634 | 668 | ||||
| 635 | - | if clientSize.Valid() && wsz != windowSize(clientSize) { | 669 | + | if clientSize.Valid() && wsz != tabSize(clientSize) { |
| 636 | d.resize(sess, ac, clientSize) | 670 | d.resize(sess, ac, clientSize) | ||
| 637 | return | 671 | return | ||
| 638 | } | 672 | } | ||
| @@ -643,11 +677,19 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||||
| 643 | // until detach, EOF, or a transport error. | 677 | // until detach, EOF, or a transport error. | ||
| 644 | func (d *Daemon) runConnLoop(sess *session, ac *attachedClient) { | 678 | func (d *Daemon) runConnLoop(sess *session, ac *attachedClient) { | ||
| 645 | for { | 679 | for { | ||
| 680 | + | sess = ac.currentSession() | |||
| 681 | + | if sess == nil { | |||
| 682 | + | return | |||
| 683 | + | } | |||
| 646 | f, err := ac.tr.Recv() | 684 | f, err := ac.tr.Recv() | ||
| 647 | if err != nil { | 685 | if err != nil { | ||
| 648 | d.clientGone(sess, ac, false) | 686 | d.clientGone(sess, ac, false) | ||
| 649 | return | 687 | return | ||
| 650 | } | 688 | } | ||
| 689 | + | sess = ac.currentSession() | |||
| 690 | + | if sess == nil { | |||
| 691 | + | return | |||
| 692 | + | } | |||
| 651 | switch f.Type { | 693 | switch f.Type { | ||
| 652 | case ports.MsgInput: | 694 | case ports.MsgInput: | ||
| 653 | if in, derr := ports.UnmarshalInput(f.Payload); derr == nil { | 695 | if in, derr := ports.UnmarshalInput(f.Payload); derr == nil { | ||
| @@ -676,6 +718,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) { | |||||
| 676 | if !sess.detachIfCurrent(ac) { | 718 | if !sess.detachIfCurrent(ac) { | ||
| 677 | return // already displaced by a newer client; nothing to do | 719 | return // already displaced by a newer client; nothing to do | ||
| 678 | } | 720 | } | ||
| 721 | + | d.unregisterPreview(ac) | |||
| 679 | if explicit { | 722 | if explicit { | ||
| 680 | // Synchronous so the ack is delivered before the transport closes | 723 | // Synchronous so the ack is delivered before the transport closes | ||
| 681 | // (the client is actively awaiting it), but deadline-bounded so a | 724 | // (the client is actively awaiting it), but deadline-bounded so a | ||
| @@ -696,24 +739,29 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool { | |||||
| 696 | defer s.mu.Unlock() | 739 | defer s.mu.Unlock() | ||
| 697 | if s.client == ac { | 740 | if s.client == ac { | ||
| 698 | s.client = nil | 741 | s.client = nil | ||
| 742 | + | ac.setSession(nil) | |||
| 699 | return true | 743 | return true | ||
| 700 | } | 744 | } | ||
| 701 | return false | 745 | return false | ||
| 702 | } | 746 | } | ||
| 703 | 747 | ||||
| 704 | - | func (s *session) activeWindow() *window { | 748 | + | func (s *session) activeTab() *tab { |
| 705 | s.mu.Lock() | 749 | s.mu.Lock() | ||
| 706 | defer s.mu.Unlock() | 750 | defer s.mu.Unlock() | ||
| 707 | - | if s.active < 0 || s.active >= len(s.windows) { | 751 | + | if s.active < 0 || s.active >= len(s.tabs) { |
| 708 | return nil | 752 | return nil | ||
| 709 | } | 753 | } | ||
| 710 | - | return s.windows[s.active] | 754 | + | return s.tabs[s.active] |
| 711 | } | 755 | } | ||
| 712 | 756 | ||||
| 713 | func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | 757 | func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | ||
| 714 | ac.mouseScan.Scan(data, | 758 | ac.mouseScan.Scan(data, | ||
| 715 | func(ev mouse.Event) { d.handleMouse(sess, ac, ev) }, | 759 | func(ev mouse.Event) { d.handleMouse(sess, ac, ev) }, | ||
| 716 | func(b []byte) { | 760 | func(b []byte) { | ||
| 761 | + | if ac.pickerActive() { | |||
| 762 | + | d.handlePickerInput(sess, ac, b) | |||
| 763 | + | return | |||
| 764 | + | } | |||
| 717 | if ac.copyModeActive() { | 765 | if ac.copyModeActive() { | ||
| 718 | d.handleCopyInput(sess, ac, b) | 766 | d.handleCopyInput(sess, ac, b) | ||
| 719 | return | 767 | return | ||
| @@ -724,8 +772,8 @@ func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | |||||
| 724 | } | 772 | } | ||
| 725 | 773 | ||||
| 726 | func (d *Daemon) handleMouse(sess *session, ac *attachedClient, ev mouse.Event) { | 774 | func (d *Daemon) handleMouse(sess *session, ac *attachedClient, ev mouse.Event) { | ||
| 727 | - | win := sess.activeWindow() | 775 | + | tb := sess.activeTab() |
| 728 | - | if win == nil { | 776 | + | if tb == nil { |
| 729 | return | 777 | return | ||
| 730 | } | 778 | } | ||
| 731 | 779 | ||||
| @@ -739,53 +787,53 @@ func (d *Daemon) handleMouse(sess *session, ac *attachedClient, ev mouse.Event) | |||||
| 739 | return | 787 | return | ||
| 740 | } | 788 | } | ||
| 741 | 789 | ||||
| 742 | - | win.mu.Lock() | 790 | + | tb.mu.Lock() |
| 743 | - | childRows := win.screen.Frame.Height | 791 | + | childRows := tb.screen.Frame.Height |
| 744 | - | mouseMode, mouseSGR := win.screen.MouseMode() | 792 | + | mouseMode, mouseSGR := tb.screen.MouseMode() |
| 745 | - | altScreen := win.screen.AltScreenActive() | 793 | + | altScreen := tb.screen.AltScreenActive() |
| 746 | - | win.mu.Unlock() | 794 | + | tb.mu.Unlock() |
| 747 | 795 | ||||
| 748 | if mouseMode != 0 { | 796 | if mouseMode != 0 { | ||
| 749 | if !mouseSGR || ev.Row >= childRows { | 797 | if !mouseSGR || ev.Row >= childRows { | ||
| 750 | return | 798 | return | ||
| 751 | } | 799 | } | ||
| 752 | - | daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward(ev.Raw) | 800 | + | daemonKeyHandler{d: d, ac: ac}.Forward(ev.Raw) |
| 753 | return | 801 | return | ||
| 754 | } | 802 | } | ||
| 755 | 803 | ||||
| 756 | switch ev.Button { | 804 | switch ev.Button { | ||
| 757 | case mouse.WheelUp: | 805 | case mouse.WheelUp: | ||
| 758 | if altScreen { | 806 | if altScreen { | ||
| 759 | - | daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A")) | 807 | + | daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A")) |
| 760 | return | 808 | return | ||
| 761 | } | 809 | } | ||
| 762 | d.enterCopyMode(sess, ac) | 810 | d.enterCopyMode(sess, ac) | ||
| 763 | d.copyWheel(sess, ac, -3) | 811 | d.copyWheel(sess, ac, -3) | ||
| 764 | case mouse.WheelDown: | 812 | case mouse.WheelDown: | ||
| 765 | if altScreen { | 813 | if altScreen { | ||
| 766 | - | daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B")) | 814 | + | daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B")) |
| 767 | } | 815 | } | ||
| 768 | } | 816 | } | ||
| 769 | } | 817 | } | ||
| 770 | 818 | ||||
| 771 | func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | 819 | func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | ||
| 772 | - | win := sess.activeWindow() | 820 | + | tb := sess.activeTab() |
| 773 | - | if win == nil { | 821 | + | if tb == nil { |
| 774 | return | 822 | return | ||
| 775 | } | 823 | } | ||
| 776 | 824 | ||||
| 777 | - | win.mu.Lock() | 825 | + | tb.mu.Lock() |
| 778 | ac.copyMu.Lock() | 826 | ac.copyMu.Lock() | ||
| 779 | if ac.copyMode == nil { | 827 | if ac.copyMode == nil { | ||
| 780 | ac.copyMu.Unlock() | 828 | ac.copyMu.Unlock() | ||
| 781 | - | win.mu.Unlock() | 829 | + | tb.mu.Unlock() |
| 782 | return | 830 | return | ||
| 783 | } | 831 | } | ||
| 784 | - | snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame) | 832 | + | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) |
| 785 | if delta > 0 && ac.copyMode.AtBottom(snap) { | 833 | if delta > 0 && ac.copyMode.AtBottom(snap) { | ||
| 786 | ac.copyMode = nil | 834 | ac.copyMode = nil | ||
| 787 | ac.copyMu.Unlock() | 835 | ac.copyMu.Unlock() | ||
| 788 | - | win.mu.Unlock() | 836 | + | tb.mu.Unlock() |
| 789 | d.paint(sess, ac, true) | 837 | d.paint(sess, ac, true) | ||
| 790 | return | 838 | return | ||
| 791 | } | 839 | } | ||
| @@ -795,19 +843,19 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||||
| 795 | ac.copyMode = nil | 843 | ac.copyMode = nil | ||
| 796 | } | 844 | } | ||
| 797 | ac.copyMu.Unlock() | 845 | ac.copyMu.Unlock() | ||
| 798 | - | win.mu.Unlock() | 846 | + | tb.mu.Unlock() |
| 799 | 847 | ||||
| 800 | d.paint(sess, ac, true) | 848 | d.paint(sess, ac, true) | ||
| 801 | } | 849 | } | ||
| 802 | 850 | ||||
| 803 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | 851 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | ||
| 804 | - | win := sess.activeWindow() | 852 | + | tb := sess.activeTab() |
| 805 | - | if win == nil { | 853 | + | if tb == nil { |
| 806 | return | 854 | return | ||
| 807 | } | 855 | } | ||
| 808 | - | win.mu.Lock() | 856 | + | tb.mu.Lock() |
| 809 | - | snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame) | 857 | + | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) |
| 810 | - | win.mu.Unlock() | 858 | + | tb.mu.Unlock() |
| 811 | ac.copyMu.Lock() | 859 | ac.copyMu.Lock() | ||
| 812 | ac.copyMode = scopy.NewMode(snap) | 860 | ac.copyMode = scopy.NewMode(snap) | ||
| 813 | ac.copyMu.Unlock() | 861 | ac.copyMu.Unlock() | ||
| @@ -815,17 +863,17 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||||
| 815 | } | 863 | } | ||
| 816 | 864 | ||||
| 817 | func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) { | 865 | func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) { | ||
| 818 | - | win := sess.activeWindow() | 866 | + | tb := sess.activeTab() |
| 819 | - | if win == nil { | 867 | + | if tb == nil { |
| 820 | return | 868 | return | ||
| 821 | } | 869 | } | ||
| 822 | 870 | ||||
| 823 | - | win.mu.Lock() | 871 | + | tb.mu.Lock() |
| 824 | ac.copyMu.Lock() | 872 | ac.copyMu.Lock() | ||
| 825 | if ac.copyMode == nil { | 873 | if ac.copyMode == nil { | ||
| 826 | ac.copyPending = nil | 874 | ac.copyPending = nil | ||
| 827 | ac.copyMu.Unlock() | 875 | ac.copyMu.Unlock() | ||
| 828 | - | win.mu.Unlock() | 876 | + | tb.mu.Unlock() |
| 829 | return | 877 | return | ||
| 830 | } | 878 | } | ||
| 831 | if len(ac.copyPending) > 0 { | 879 | if len(ac.copyPending) > 0 { | ||
| @@ -835,7 +883,7 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) | |||||
| 835 | data = combined | 883 | data = combined | ||
| 836 | ac.copyPending = nil | 884 | ac.copyPending = nil | ||
| 837 | } | 885 | } | ||
| 838 | - | snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame) | 886 | + | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) |
| 839 | changed := false | 887 | changed := false | ||
| 840 | copyOut := false | 888 | copyOut := false | ||
| 841 | exit := false | 889 | exit := false | ||
| @@ -884,7 +932,7 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) | |||||
| 884 | ac.copyMode = nil | 932 | ac.copyMode = nil | ||
| 885 | } | 933 | } | ||
| 886 | ac.copyMu.Unlock() | 934 | ac.copyMu.Unlock() | ||
| 887 | - | win.mu.Unlock() | 935 | + | tb.mu.Unlock() |
| 888 | 936 | ||||
| 889 | if copyOut && text != "" { | 937 | if copyOut && text != "" { | ||
| 890 | for _, chunk := range scopy.OSC52(text) { | 938 | for _, chunk := range scopy.OSC52(text) { | ||
| @@ -932,64 +980,323 @@ func isCopyEscapePrefix(data []byte) bool { | |||||
| 932 | len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6') | 980 | len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6') | ||
| 933 | } | 981 | } | ||
| 934 | 982 | ||||
| 983 | + | func (d *Daemon) enterPicker(sess *session, ac *attachedClient) { | |||
| 984 | + | views, curTab := d.pickerViews(sess) | |||
| 985 | + | ac.pickerMu.Lock() | |||
| 986 | + | ac.picker = picker.New(views, sess.id, curTab) | |||
| 987 | + | ac.pickerPending = nil | |||
| 988 | + | ac.pickerPreview = nil | |||
| 989 | + | ac.pickerMu.Unlock() | |||
| 990 | + | d.registerPreviewForSelection(ac) | |||
| 991 | + | d.paint(sess, ac, true) | |||
| 992 | + | } | |||
| 993 | + | ||||
| 994 | + | func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 995 | + | d.mu.Lock() | |||
| 996 | + | sessions := make([]*session, 0, len(d.sessions)) | |||
| 997 | + | for _, s := range d.sessions { | |||
| 998 | + | sessions = append(sessions, s) | |||
| 999 | + | } | |||
| 1000 | + | d.mu.Unlock() | |||
| 1001 | + | sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name }) | |||
| 1002 | + | ||||
| 1003 | + | views := make([]picker.SessionView, 0, len(sessions)) | |||
| 1004 | + | curTab := 0 | |||
| 1005 | + | for _, s := range sessions { | |||
| 1006 | + | s.mu.Lock() | |||
| 1007 | + | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))} | |||
| 1008 | + | for i := range s.tabs { | |||
| 1009 | + | view.Tabs[i] = strconv.Itoa(i + 1) | |||
| 1010 | + | } | |||
| 1011 | + | if s == cur { | |||
| 1012 | + | curTab = s.active | |||
| 1013 | + | } | |||
| 1014 | + | s.mu.Unlock() | |||
| 1015 | + | views = append(views, view) | |||
| 1016 | + | } | |||
| 1017 | + | return views, curTab | |||
| 1018 | + | } | |||
| 1019 | + | ||||
| 1020 | + | func (d *Daemon) handlePickerInput(sess *session, ac *attachedClient, data []byte) { | |||
| 1021 | + | ac.pickerMu.Lock() | |||
| 1022 | + | if ac.picker == nil { | |||
| 1023 | + | ac.pickerPending = nil | |||
| 1024 | + | ac.pickerMu.Unlock() | |||
| 1025 | + | return | |||
| 1026 | + | } | |||
| 1027 | + | if len(ac.pickerPending) > 0 { | |||
| 1028 | + | combined := make([]byte, 0, len(ac.pickerPending)+len(data)) | |||
| 1029 | + | combined = append(combined, ac.pickerPending...) | |||
| 1030 | + | combined = append(combined, data...) | |||
| 1031 | + | data = combined | |||
| 1032 | + | ac.pickerPending = nil | |||
| 1033 | + | } | |||
| 1034 | + | changed := false | |||
| 1035 | + | exit := false | |||
| 1036 | + | switchTarget := false | |||
| 1037 | + | for i := 0; i < len(data); i++ { | |||
| 1038 | + | switch data[i] { | |||
| 1039 | + | case 'j': | |||
| 1040 | + | ac.picker.Down() | |||
| 1041 | + | changed = true | |||
| 1042 | + | case 'k': | |||
| 1043 | + | ac.picker.Up() | |||
| 1044 | + | changed = true | |||
| 1045 | + | case '\r', '\n': | |||
| 1046 | + | switchTarget = true | |||
| 1047 | + | exit = true | |||
| 1048 | + | case 'q', 0x03, 0x1b: | |||
| 1049 | + | if data[i] == 0x1b { | |||
| 1050 | + | tail := data[i:] | |||
| 1051 | + | consumed, ok := routePickerEscape(ac.picker, tail) | |||
| 1052 | + | if ok { | |||
| 1053 | + | i += consumed - 1 | |||
| 1054 | + | changed = true | |||
| 1055 | + | continue | |||
| 1056 | + | } | |||
| 1057 | + | if isPickerEscapePrefix(tail) { | |||
| 1058 | + | ac.pickerPending = append(ac.pickerPending[:0], tail...) | |||
| 1059 | + | break | |||
| 1060 | + | } | |||
| 1061 | + | } | |||
| 1062 | + | exit = true | |||
| 1063 | + | } | |||
| 1064 | + | } | |||
| 1065 | + | var target picker.Target | |||
| 1066 | + | var ok bool | |||
| 1067 | + | if switchTarget { | |||
| 1068 | + | target, ok = ac.picker.Selected() | |||
| 1069 | + | } | |||
| 1070 | + | ac.pickerMu.Unlock() | |||
| 1071 | + | ||||
| 1072 | + | if changed { | |||
| 1073 | + | d.registerPreviewForSelection(ac) | |||
| 1074 | + | } | |||
| 1075 | + | if exit { | |||
| 1076 | + | d.closePicker(ac) | |||
| 1077 | + | } | |||
| 1078 | + | if switchTarget && ok { | |||
| 1079 | + | d.switchToTarget(sess, ac, target) | |||
| 1080 | + | return | |||
| 1081 | + | } | |||
| 1082 | + | if exit || changed { | |||
| 1083 | + | d.paint(sess, ac, true) | |||
| 1084 | + | } | |||
| 1085 | + | } | |||
| 1086 | + | ||||
| 1087 | + | func routePickerEscape(m *picker.Model, data []byte) (int, bool) { | |||
| 1088 | + | if len(data) >= 3 && data[1] == '[' { | |||
| 1089 | + | switch data[2] { | |||
| 1090 | + | case 'A': | |||
| 1091 | + | m.Up() | |||
| 1092 | + | return 3, true | |||
| 1093 | + | case 'B': | |||
| 1094 | + | m.Down() | |||
| 1095 | + | return 3, true | |||
| 1096 | + | } | |||
| 1097 | + | } | |||
| 1098 | + | return 0, false | |||
| 1099 | + | } | |||
| 1100 | + | ||||
| 1101 | + | func isPickerEscapePrefix(data []byte) bool { | |||
| 1102 | + | return len(data) == 2 && data[0] == 0x1b && data[1] == '[' | |||
| 1103 | + | } | |||
| 1104 | + | ||||
| 1105 | + | func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||
| 1106 | + | ac.pickerMu.Lock() | |||
| 1107 | + | var target picker.Target | |||
| 1108 | + | var ok bool | |||
| 1109 | + | if ac.picker != nil { | |||
| 1110 | + | target, ok = ac.picker.Selected() | |||
| 1111 | + | } | |||
| 1112 | + | ac.pickerMu.Unlock() | |||
| 1113 | + | ||||
| 1114 | + | var next *tab | |||
| 1115 | + | if ok { | |||
| 1116 | + | next = d.tabByTarget(target) | |||
| 1117 | + | } | |||
| 1118 | + | ||||
| 1119 | + | ac.pickerMu.Lock() | |||
| 1120 | + | old := ac.pickerPreview | |||
| 1121 | + | if ac.picker == nil { | |||
| 1122 | + | next = nil | |||
| 1123 | + | } | |||
| 1124 | + | ac.pickerPreview = next | |||
| 1125 | + | ac.pickerMu.Unlock() | |||
| 1126 | + | ||||
| 1127 | + | if old != nil && old != next { | |||
| 1128 | + | old.mu.Lock() | |||
| 1129 | + | if old.previewClient == ac { | |||
| 1130 | + | old.previewClient = nil | |||
| 1131 | + | } | |||
| 1132 | + | old.mu.Unlock() | |||
| 1133 | + | } | |||
| 1134 | + | if next != nil { | |||
| 1135 | + | next.mu.Lock() | |||
| 1136 | + | next.previewClient = ac | |||
| 1137 | + | next.mu.Unlock() | |||
| 1138 | + | ||||
| 1139 | + | ac.pickerMu.Lock() | |||
| 1140 | + | keep := ac.pickerPreview == next | |||
| 1141 | + | ac.pickerMu.Unlock() | |||
| 1142 | + | if !keep { | |||
| 1143 | + | next.mu.Lock() | |||
| 1144 | + | if next.previewClient == ac { | |||
| 1145 | + | next.previewClient = nil | |||
| 1146 | + | } | |||
| 1147 | + | next.mu.Unlock() | |||
| 1148 | + | } | |||
| 1149 | + | } | |||
| 1150 | + | } | |||
| 1151 | + | ||||
| 1152 | + | func (d *Daemon) unregisterPreview(ac *attachedClient) { | |||
| 1153 | + | ac.pickerMu.Lock() | |||
| 1154 | + | old := ac.pickerPreview | |||
| 1155 | + | ac.pickerPreview = nil | |||
| 1156 | + | ac.pickerMu.Unlock() | |||
| 1157 | + | ||||
| 1158 | + | if old != nil { | |||
| 1159 | + | old.mu.Lock() | |||
| 1160 | + | if old.previewClient == ac { | |||
| 1161 | + | old.previewClient = nil | |||
| 1162 | + | } | |||
| 1163 | + | old.mu.Unlock() | |||
| 1164 | + | } | |||
| 1165 | + | } | |||
| 1166 | + | ||||
| 1167 | + | func (d *Daemon) closePicker(ac *attachedClient) { | |||
| 1168 | + | ac.pickerMu.Lock() | |||
| 1169 | + | ac.picker = nil | |||
| 1170 | + | ac.pickerPending = nil | |||
| 1171 | + | ac.pickerMu.Unlock() | |||
| 1172 | + | d.unregisterPreview(ac) | |||
| 1173 | + | } | |||
| 1174 | + | ||||
| 1175 | + | func (d *Daemon) tabByTarget(target picker.Target) *tab { | |||
| 1176 | + | d.mu.Lock() | |||
| 1177 | + | sess := d.sessions[target.Session] | |||
| 1178 | + | d.mu.Unlock() | |||
| 1179 | + | if sess == nil { | |||
| 1180 | + | return nil | |||
| 1181 | + | } | |||
| 1182 | + | sess.mu.Lock() | |||
| 1183 | + | defer sess.mu.Unlock() | |||
| 1184 | + | if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) { | |||
| 1185 | + | return nil | |||
| 1186 | + | } | |||
| 1187 | + | return sess.tabs[target.TabIndex] | |||
| 1188 | + | } | |||
| 1189 | + | ||||
| 1190 | + | func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||
| 1191 | + | d.mu.Lock() | |||
| 1192 | + | defer d.mu.Unlock() | |||
| 1193 | + | return d.sessions[id] | |||
| 1194 | + | } | |||
| 1195 | + | ||||
| 1196 | + | func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) { | |||
| 1197 | + | targetSess := d.sessionByID(target.Session) | |||
| 1198 | + | if targetSess == nil { | |||
| 1199 | + | d.paint(sess, ac, true) | |||
| 1200 | + | return | |||
| 1201 | + | } | |||
| 1202 | + | if targetSess == sess { | |||
| 1203 | + | if sess.switchTab(target.TabIndex) { | |||
| 1204 | + | d.paint(sess, ac, true) | |||
| 1205 | + | } else { | |||
| 1206 | + | d.paint(sess, ac, true) | |||
| 1207 | + | } | |||
| 1208 | + | return | |||
| 1209 | + | } | |||
| 1210 | + | if !sess.detachIfCurrent(ac) { | |||
| 1211 | + | return | |||
| 1212 | + | } | |||
| 1213 | + | targetSess.stealClient(d, ac, target.TabIndex) | |||
| 1214 | + | ac.setSession(targetSess) | |||
| 1215 | + | d.firstPaint(targetSess, ac, ac.size) | |||
| 1216 | + | } | |||
| 1217 | + | ||||
| 1218 | + | func (s *session) stealClient(d *Daemon, ac *attachedClient, idx int) { | |||
| 1219 | + | s.mu.Lock() | |||
| 1220 | + | old := s.client | |||
| 1221 | + | s.client = ac | |||
| 1222 | + | if idx >= 0 && idx < len(s.tabs) { | |||
| 1223 | + | s.active = idx | |||
| 1224 | + | } | |||
| 1225 | + | s.mu.Unlock() | |||
| 1226 | + | if old != nil && old != ac { | |||
| 1227 | + | d.unregisterPreview(old) | |||
| 1228 | + | old.setSession(nil) | |||
| 1229 | + | d.notifyDetachedAsync(old, ports.ReasonDetach) | |||
| 1230 | + | } | |||
| 1231 | + | } | |||
| 1232 | + | ||||
| 935 | type daemonKeyHandler struct { | 1233 | type daemonKeyHandler struct { | ||
| 936 | - | d *Daemon | 1234 | + | d *Daemon |
| 937 | - | sess *session | 1235 | + | ac *attachedClient |
| 938 | - | ac *attachedClient | |||
| 939 | } | 1236 | } | ||
| 940 | 1237 | ||||
| 941 | func (h daemonKeyHandler) Forward(data []byte) { | 1238 | func (h daemonKeyHandler) Forward(data []byte) { | ||
| 942 | - | win := h.sess.activeWindow() | 1239 | + | sess := h.ac.currentSession() |
| 943 | - | if win == nil { | 1240 | + | if sess == nil { |
| 944 | return | 1241 | return | ||
| 945 | } | 1242 | } | ||
| 946 | - | if _, err := win.pty.Write(data); err != nil { | 1243 | + | tb := sess.activeTab() |
| 947 | - | h.d.log.Error("pty write failed", "err", err, "session", h.sess.name) | 1244 | + | if tb == nil { |
| 1245 | + | return | |||
| 1246 | + | } | |||
| 1247 | + | if _, err := tb.pty.Write(data); err != nil { | |||
| 1248 | + | h.d.log.Error("pty write failed", "err", err, "session", sess.name) | |||
| 948 | } | 1249 | } | ||
| 949 | } | 1250 | } | ||
| 950 | 1251 | ||||
| 951 | func (h daemonKeyHandler) Action(action keys.Action) { | 1252 | func (h daemonKeyHandler) Action(action keys.Action) { | ||
| 1253 | + | sess := h.ac.currentSession() | |||
| 1254 | + | if sess == nil { | |||
| 1255 | + | return | |||
| 1256 | + | } | |||
| 952 | switch action { | 1257 | switch action { | ||
| 953 | - | case keys.ActionCreateWindow: | 1258 | + | case keys.ActionCreateTab: |
| 954 | - | if err := h.d.createWindow(h.sess, h.ac.size); err != nil { | 1259 | + | if err := h.d.createTab(sess, h.ac.size); err != nil { |
| 955 | - | h.d.log.Error("create window failed", "err", err, "session", h.sess.name) | 1260 | + | h.d.log.Error("create tab failed", "err", err, "session", sess.name) |
| 956 | return | 1261 | return | ||
| 957 | } | 1262 | } | ||
| 958 | - | h.d.paint(h.sess, h.ac, true) | 1263 | + | h.d.paint(sess, h.ac, true) |
| 959 | - | case keys.ActionNextWindow: | 1264 | + | case keys.ActionNextTab: |
| 960 | - | if h.sess.switchRelative(1) { | 1265 | + | if sess.switchRelative(1) { |
| 961 | - | h.d.paint(h.sess, h.ac, true) | 1266 | + | h.d.paint(sess, h.ac, true) |
| 962 | } | 1267 | } | ||
| 963 | - | case keys.ActionPreviousWindow: | 1268 | + | case keys.ActionPreviousTab: |
| 964 | - | if h.sess.switchRelative(-1) { | 1269 | + | if sess.switchRelative(-1) { |
| 965 | - | h.d.paint(h.sess, h.ac, true) | 1270 | + | h.d.paint(sess, h.ac, true) |
| 966 | } | 1271 | } | ||
| 967 | case keys.ActionDetach: | 1272 | case keys.ActionDetach: | ||
| 968 | - | h.d.clientGone(h.sess, h.ac, true) | 1273 | + | h.d.clientGone(sess, h.ac, true) |
| 969 | - | case keys.ActionCloseWindow: | 1274 | + | case keys.ActionCloseTab: |
| 970 | - | win := h.sess.activeWindow() | 1275 | + | tb := sess.activeTab() |
| 971 | - | if win != nil { | 1276 | + | if tb != nil { |
| 972 | - | h.d.closeWindow(h.sess, win, true) | 1277 | + | h.d.closeTab(sess, tb, true) |
| 973 | } | 1278 | } | ||
| 974 | case keys.ActionCopyMode: | 1279 | case keys.ActionCopyMode: | ||
| 975 | - | h.d.enterCopyMode(h.sess, h.ac) | 1280 | + | h.d.enterCopyMode(sess, h.ac) |
| 976 | case keys.ActionRenameSession: | 1281 | case keys.ActionRenameSession: | ||
| 977 | - | h.sess.promoteEphemeral() | 1282 | + | sess.promoteEphemeral() |
| 978 | - | h.d.paint(h.sess, h.ac, true) | 1283 | + | h.d.paint(sess, h.ac, true) |
| 979 | - | case keys.ActionSwitchWindow1, keys.ActionSwitchWindow2, keys.ActionSwitchWindow3, | 1284 | + | case keys.ActionOpenPicker: |
| 980 | - | keys.ActionSwitchWindow4, keys.ActionSwitchWindow5, keys.ActionSwitchWindow6, | 1285 | + | h.d.enterPicker(sess, h.ac) |
| 981 | - | keys.ActionSwitchWindow7, keys.ActionSwitchWindow8, keys.ActionSwitchWindow9: | 1286 | + | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, |
| 982 | - | idx := int(action - keys.ActionSwitchWindow1) | 1287 | + | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, |
| 983 | - | if h.sess.switchWindow(idx) { | 1288 | + | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: |
| 984 | - | h.d.paint(h.sess, h.ac, true) | 1289 | + | idx := int(action - keys.ActionSwitchTab1) |
| 1290 | + | if sess.switchTab(idx) { | |||
| 1291 | + | h.d.paint(sess, h.ac, true) | |||
| 985 | } | 1292 | } | ||
| 986 | } | 1293 | } | ||
| 987 | } | 1294 | } | ||
| 988 | 1295 | ||||
| 989 | - | func (s *session) switchWindow(idx int) bool { | 1296 | + | func (s *session) switchTab(idx int) bool { |
| 990 | s.mu.Lock() | 1297 | s.mu.Lock() | ||
| 991 | defer s.mu.Unlock() | 1298 | defer s.mu.Unlock() | ||
| 992 | - | if idx < 0 || idx >= len(s.windows) || idx == s.active { | 1299 | + | if idx < 0 || idx >= len(s.tabs) || idx == s.active { |
| 993 | return false | 1300 | return false | ||
| 994 | } | 1301 | } | ||
| 995 | s.active = idx | 1302 | s.active = idx | ||
| @@ -999,10 +1306,10 @@ func (s *session) switchWindow(idx int) bool { | |||||
| 999 | func (s *session) switchRelative(delta int) bool { | 1306 | func (s *session) switchRelative(delta int) bool { | ||
| 1000 | s.mu.Lock() | 1307 | s.mu.Lock() | ||
| 1001 | defer s.mu.Unlock() | 1308 | defer s.mu.Unlock() | ||
| 1002 | - | if len(s.windows) < 2 { | 1309 | + | if len(s.tabs) < 2 { |
| 1003 | return false | 1310 | return false | ||
| 1004 | } | 1311 | } | ||
| 1005 | - | s.active = (s.active + delta + len(s.windows)) % len(s.windows) | 1312 | + | s.active = (s.active + delta + len(s.tabs)) % len(s.tabs) |
| 1006 | return true | 1313 | return true | ||
| 1007 | } | 1314 | } | ||
| 1008 | 1315 | ||||
| @@ -1014,11 +1321,11 @@ func (s *session) promoteEphemeral() { | |||||
| 1014 | s.ephemeral = false | 1321 | s.ephemeral = false | ||
| 1015 | } | 1322 | } | ||
| 1016 | 1323 | ||||
| 1017 | - | func (d *Daemon) closeWindow(sess *session, win *window, repaint bool) { | 1324 | + | func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { |
| 1018 | sess.mu.Lock() | 1325 | sess.mu.Lock() | ||
| 1019 | idx := -1 | 1326 | idx := -1 | ||
| 1020 | - | for i, w := range sess.windows { | 1327 | + | for i, w := range sess.tabs { |
| 1021 | - | if w == win { | 1328 | + | if w == tb { |
| 1022 | idx = i | 1329 | idx = i | ||
| 1023 | break | 1330 | break | ||
| 1024 | } | 1331 | } | ||
| @@ -1027,24 +1334,24 @@ func (d *Daemon) closeWindow(sess *session, win *window, repaint bool) { | |||||
| 1027 | sess.mu.Unlock() | 1334 | sess.mu.Unlock() | ||
| 1028 | return | 1335 | return | ||
| 1029 | } | 1336 | } | ||
| 1030 | - | if len(sess.windows) == 1 { | 1337 | + | if len(sess.tabs) == 1 { |
| 1031 | sess.mu.Unlock() | 1338 | sess.mu.Unlock() | ||
| 1032 | d.killSession(sess, ports.ReasonSessionKilled) | 1339 | d.killSession(sess, ports.ReasonSessionKilled) | ||
| 1033 | return | 1340 | return | ||
| 1034 | } | 1341 | } | ||
| 1035 | - | sess.windows = append(sess.windows[:idx], sess.windows[idx+1:]...) | 1342 | + | sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...) |
| 1036 | - | if sess.active >= len(sess.windows) { | 1343 | + | if sess.active >= len(sess.tabs) { |
| 1037 | - | sess.active = len(sess.windows) - 1 | 1344 | + | sess.active = len(sess.tabs) - 1 |
| 1038 | } else if idx < sess.active { | 1345 | } else if idx < sess.active { | ||
| 1039 | sess.active-- | 1346 | sess.active-- | ||
| 1040 | } | 1347 | } | ||
| 1041 | ac := sess.client | 1348 | ac := sess.client | ||
| 1042 | sess.mu.Unlock() | 1349 | sess.mu.Unlock() | ||
| 1043 | 1350 | ||||
| 1044 | - | if win.cancel != nil { | 1351 | + | if tb.cancel != nil { |
| 1045 | - | win.cancel() | 1352 | + | tb.cancel() |
| 1046 | } | 1353 | } | ||
| 1047 | - | _ = win.pty.Close() | 1354 | + | _ = tb.pty.Close() |
| 1048 | if repaint && ac != nil { | 1355 | if repaint && ac != nil { | ||
| 1049 | d.paint(sess, ac, true) | 1356 | d.paint(sess, ac, true) | ||
| 1050 | } | 1357 | } | ||
| @@ -1060,57 +1367,57 @@ func signal(ch chan struct{}) { | |||||
| 1060 | } | 1367 | } | ||
| 1061 | } | 1368 | } | ||
| 1062 | 1369 | ||||
| 1063 | - | func (d *Daemon) ptyReader(sess *session, win *window) { | 1370 | + | func (d *Daemon) ptyReader(sess *session, tb *tab) { |
| 1064 | defer d.sessWg.Done() | 1371 | defer d.sessWg.Done() | ||
| 1065 | buf := make([]byte, ptyReadBufSize) | 1372 | buf := make([]byte, ptyReadBufSize) | ||
| 1066 | var resp []byte | 1373 | var resp []byte | ||
| 1067 | - | win.mu.Lock() | 1374 | + | tb.mu.Lock() |
| 1068 | - | win.screen.OnResponse = func(b []byte) { resp = append(resp, b...) } | 1375 | + | tb.screen.OnResponse = func(b []byte) { resp = append(resp, b...) } |
| 1069 | - | win.mu.Unlock() | 1376 | + | tb.mu.Unlock() |
| 1070 | for { | 1377 | for { | ||
| 1071 | - | n, err := win.pty.Read(buf) | 1378 | + | n, err := tb.pty.Read(buf) |
| 1072 | if n > 0 { | 1379 | if n > 0 { | ||
| 1073 | data := buf[:n] | 1380 | data := buf[:n] | ||
| 1074 | - | win.mu.Lock() | 1381 | + | tb.mu.Lock() |
| 1075 | - | wasSyncing := win.screen.SyncUpdateActive() | 1382 | + | wasSyncing := tb.screen.SyncUpdateActive() |
| 1076 | - | win.screen.Write(data) | 1383 | + | tb.screen.Write(data) |
| 1077 | - | isSyncing := win.screen.SyncUpdateActive() | 1384 | + | isSyncing := tb.screen.SyncUpdateActive() |
| 1078 | - | win.mu.Unlock() | 1385 | + | tb.mu.Unlock() |
| 1079 | if len(resp) > 0 { | 1386 | if len(resp) > 0 { | ||
| 1080 | - | if _, writeErr := win.pty.Write(resp); writeErr != nil { | 1387 | + | if _, writeErr := tb.pty.Write(resp); writeErr != nil { |
| 1081 | d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name) | 1388 | d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name) | ||
| 1082 | } | 1389 | } | ||
| 1083 | resp = resp[:0] | 1390 | resp = resp[:0] | ||
| 1084 | } | 1391 | } | ||
| 1085 | if wasSyncing != isSyncing { | 1392 | if wasSyncing != isSyncing { | ||
| 1086 | - | win.mu.Lock() | 1393 | + | tb.mu.Lock() |
| 1087 | - | win.syncGen++ | 1394 | + | tb.syncGen++ |
| 1088 | - | gen := win.syncGen | 1395 | + | gen := tb.syncGen |
| 1089 | - | win.mu.Unlock() | 1396 | + | tb.mu.Unlock() |
| 1090 | if isSyncing { | 1397 | if isSyncing { | ||
| 1091 | - | go d.syncWatchdog(win, gen) | 1398 | + | go d.syncWatchdog(tb, gen) |
| 1092 | } | 1399 | } | ||
| 1093 | } | 1400 | } | ||
| 1094 | if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) { | 1401 | if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) { | ||
| 1095 | - | signal(win.flush) | 1402 | + | signal(tb.flush) |
| 1096 | continue | 1403 | continue | ||
| 1097 | } | 1404 | } | ||
| 1098 | if isSyncing { | 1405 | if isSyncing { | ||
| 1099 | continue | 1406 | continue | ||
| 1100 | } | 1407 | } | ||
| 1101 | - | signal(win.dirty) | 1408 | + | signal(tb.dirty) |
| 1102 | } | 1409 | } | ||
| 1103 | if err != nil { | 1410 | if err != nil { | ||
| 1104 | - | d.closeWindow(sess, win, true) | 1411 | + | d.closeTab(sess, tb, true) |
| 1105 | return | 1412 | return | ||
| 1106 | } | 1413 | } | ||
| 1107 | } | 1414 | } | ||
| 1108 | } | 1415 | } | ||
| 1109 | 1416 | ||||
| 1110 | - | // scheduler debounces dirty signals. The first dirty opens a short window; | 1417 | + | // scheduler debounces dirty signals. The first dirty opens a short tab; |
| 1111 | - | // sustained floods progressively widen that window, while isolated updates | 1418 | + | // sustained floods progressively widen that tab, while isolated updates |
| 1112 | // return to the minimum delay for interactive latency. | 1419 | // return to the minimum delay for interactive latency. | ||
| 1113 | - | func (d *Daemon) scheduler(sess *session, win *window) { | 1420 | + | func (d *Daemon) scheduler(sess *session, tb *tab) { |
| 1114 | defer d.sessWg.Done() | 1421 | defer d.sessWg.Done() | ||
| 1115 | delay := minDebounceInterval | 1422 | delay := minDebounceInterval | ||
| 1116 | lastRender := d.clock.Now() | 1423 | lastRender := d.clock.Now() | ||
| @@ -1119,13 +1426,13 @@ outer: | |||||
| 1119 | select { | 1426 | select { | ||
| 1120 | case <-sess.ctx.Done(): | 1427 | case <-sess.ctx.Done(): | ||
| 1121 | return | 1428 | return | ||
| 1122 | - | case <-windowDone(win): | 1429 | + | case <-tabDone(tb): |
| 1123 | return | 1430 | return | ||
| 1124 | - | case <-win.flush: | 1431 | + | case <-tb.flush: |
| 1125 | - | d.render(sess, win) | 1432 | + | d.render(sess, tb) |
| 1126 | lastRender = d.clock.Now() | 1433 | lastRender = d.clock.Now() | ||
| 1127 | continue | 1434 | continue | ||
| 1128 | - | case <-win.dirty: | 1435 | + | case <-tb.dirty: |
| 1129 | if d.clock.Now().Sub(lastRender) >= maxDebounceInterval { | 1436 | if d.clock.Now().Sub(lastRender) >= maxDebounceInterval { | ||
| 1130 | delay = minDebounceInterval | 1437 | delay = minDebounceInterval | ||
| 1131 | } | 1438 | } | ||
| @@ -1139,27 +1446,27 @@ outer: | |||||
| 1139 | case <-sess.ctx.Done(): | 1446 | case <-sess.ctx.Done(): | ||
| 1140 | timer.Stop() | 1447 | timer.Stop() | ||
| 1141 | return | 1448 | return | ||
| 1142 | - | case <-windowDone(win): | 1449 | + | case <-tabDone(tb): |
| 1143 | timer.Stop() | 1450 | timer.Stop() | ||
| 1144 | return | 1451 | return | ||
| 1145 | - | case <-win.flush: | 1452 | + | case <-tb.flush: |
| 1146 | if !timer.Stop() { | 1453 | if !timer.Stop() { | ||
| 1147 | select { | 1454 | select { | ||
| 1148 | case <-timer.C(): | 1455 | case <-timer.C(): | ||
| 1149 | default: | 1456 | default: | ||
| 1150 | } | 1457 | } | ||
| 1151 | } | 1458 | } | ||
| 1152 | - | d.render(sess, win) | 1459 | + | d.render(sess, tb) |
| 1153 | lastRender = d.clock.Now() | 1460 | lastRender = d.clock.Now() | ||
| 1154 | continue outer | 1461 | continue outer | ||
| 1155 | - | case <-win.dirty: | 1462 | + | case <-tb.dirty: |
| 1156 | coalesced++ | 1463 | coalesced++ | ||
| 1157 | case <-timer.C(): | 1464 | case <-timer.C(): | ||
| 1158 | break absorb | 1465 | break absorb | ||
| 1159 | } | 1466 | } | ||
| 1160 | } | 1467 | } | ||
| 1161 | delay = nextDebounceDelay(delay, coalesced) | 1468 | delay = nextDebounceDelay(delay, coalesced) | ||
| 1162 | - | d.render(sess, win) | 1469 | + | d.render(sess, tb) |
| 1163 | lastRender = d.clock.Now() | 1470 | lastRender = d.clock.Now() | ||
| 1164 | } | 1471 | } | ||
| 1165 | } | 1472 | } | ||
| @@ -1178,30 +1485,30 @@ func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration { | |||||
| 1178 | return delay | 1485 | return delay | ||
| 1179 | } | 1486 | } | ||
| 1180 | 1487 | ||||
| 1181 | - | func (d *Daemon) syncWatchdog(win *window, gen uint64) { | 1488 | + | func (d *Daemon) syncWatchdog(tb *tab, gen uint64) { |
| 1182 | timer := d.clock.NewTimer(maxSyncUpdateDuration) | 1489 | timer := d.clock.NewTimer(maxSyncUpdateDuration) | ||
| 1183 | select { | 1490 | select { | ||
| 1184 | - | case <-windowDone(win): | 1491 | + | case <-tabDone(tb): |
| 1185 | timer.Stop() | 1492 | timer.Stop() | ||
| 1186 | return | 1493 | return | ||
| 1187 | case <-timer.C(): | 1494 | case <-timer.C(): | ||
| 1188 | } | 1495 | } | ||
| 1189 | 1496 | ||||
| 1190 | - | win.mu.Lock() | 1497 | + | tb.mu.Lock() |
| 1191 | - | if win.syncGen != gen || !win.screen.SyncUpdateActive() { | 1498 | + | if tb.syncGen != gen || !tb.screen.SyncUpdateActive() { |
| 1192 | - | win.mu.Unlock() | 1499 | + | tb.mu.Unlock() |
| 1193 | return | 1500 | return | ||
| 1194 | } | 1501 | } | ||
| 1195 | - | win.screen.ForceSyncEnd() | 1502 | + | tb.screen.ForceSyncEnd() |
| 1196 | - | win.mu.Unlock() | 1503 | + | tb.mu.Unlock() |
| 1197 | - | signal(win.flush) | 1504 | + | signal(tb.flush) |
| 1198 | } | 1505 | } | ||
| 1199 | 1506 | ||||
| 1200 | - | func windowDone(win *window) <-chan struct{} { | 1507 | + | func tabDone(tb *tab) <-chan struct{} { |
| 1201 | - | if win.ctx == nil { | 1508 | + | if tb.ctx == nil { |
| 1202 | return nil | 1509 | return nil | ||
| 1203 | } | 1510 | } | ||
| 1204 | - | return win.ctx.Done() | 1511 | + | return tb.ctx.Done() |
| 1205 | } | 1512 | } | ||
| 1206 | 1513 | ||||
| 1207 | func syncUpdateEndIn(data []byte) bool { | 1514 | func syncUpdateEndIn(data []byte) bool { | ||
| @@ -1210,60 +1517,92 @@ func syncUpdateEndIn(data []byte) bool { | |||||
| 1210 | 1517 | ||||
| 1211 | // render paints the current client, or (when detached) just clears accumulated | 1518 | // render paints the current client, or (when detached) just clears accumulated | ||
| 1212 | // damage so it never grows unbounded while headless. | 1519 | // damage so it never grows unbounded while headless. | ||
| 1213 | - | func (d *Daemon) render(sess *session, win *window) { | 1520 | + | func (d *Daemon) render(sess *session, tb *tab) { |
| 1214 | - | win.mu.Lock() | 1521 | + | tb.mu.Lock() |
| 1215 | - | if win.screen.SyncUpdateActive() { | 1522 | + | if tb.screen.SyncUpdateActive() { |
| 1216 | - | win.mu.Unlock() | 1523 | + | tb.mu.Unlock() |
| 1217 | return | 1524 | return | ||
| 1218 | } | 1525 | } | ||
| 1219 | - | win.mu.Unlock() | 1526 | + | tb.mu.Unlock() |
| 1527 | + | ||||
| 1528 | + | tb.mu.Lock() | |||
| 1529 | + | previewer := tb.previewClient | |||
| 1530 | + | tb.mu.Unlock() | |||
| 1220 | 1531 | ||||
| 1221 | sess.mu.Lock() | 1532 | sess.mu.Lock() | ||
| 1222 | ac := sess.client | 1533 | ac := sess.client | ||
| 1223 | - | active := sess.active >= 0 && sess.active < len(sess.windows) && sess.windows[sess.active] == win | 1534 | + | active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb |
| 1224 | sess.mu.Unlock() | 1535 | sess.mu.Unlock() | ||
| 1225 | 1536 | ||||
| 1537 | + | if previewer != nil { | |||
| 1538 | + | if previewSess := previewer.currentSession(); previewSess != nil { | |||
| 1539 | + | d.paint(previewSess, previewer, false) | |||
| 1540 | + | } | |||
| 1541 | + | if ac != nil && active && ac != previewer { | |||
| 1542 | + | d.paint(sess, ac, false) | |||
| 1543 | + | return | |||
| 1544 | + | } | |||
| 1545 | + | tb.mu.Lock() | |||
| 1546 | + | tb.screen.ClearDamage() | |||
| 1547 | + | tb.mu.Unlock() | |||
| 1548 | + | return | |||
| 1549 | + | } | |||
| 1550 | + | ||||
| 1226 | if ac == nil || !active { | 1551 | if ac == nil || !active { | ||
| 1227 | - | win.mu.Lock() | 1552 | + | tb.mu.Lock() |
| 1228 | - | win.screen.ClearDamage() | 1553 | + | tb.screen.ClearDamage() |
| 1229 | - | win.mu.Unlock() | 1554 | + | tb.mu.Unlock() |
| 1230 | return | 1555 | return | ||
| 1231 | } | 1556 | } | ||
| 1232 | d.paint(sess, ac, false) | 1557 | d.paint(sess, ac, false) | ||
| 1233 | } | 1558 | } | ||
| 1234 | 1559 | ||||
| 1235 | - | // paint draws the composed client frame (active window plus status bar) and | 1560 | + | // paint draws the composed client frame (active tab plus status bar) and |
| 1236 | // sends the resulting bytes. The renderer shadow is reset on explicit invalidations | 1561 | // sends the resulting bytes. The renderer shadow is reset on explicit invalidations | ||
| 1237 | // such as switch/create/close/rename/resize so the repaint is complete. | 1562 | // such as switch/create/close/rename/resize so the repaint is complete. | ||
| 1238 | func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | 1563 | func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | ||
| 1239 | - | win := sess.activeWindow() | 1564 | + | tb := sess.activeTab() |
| 1240 | - | if win == nil { | 1565 | + | if tb == nil { |
| 1241 | return | 1566 | return | ||
| 1242 | } | 1567 | } | ||
| 1243 | 1568 | ||||
| 1244 | ac.sendMu.Lock() | 1569 | ac.sendMu.Lock() | ||
| 1245 | - | win.mu.Lock() | |||
| 1246 | ac.copyMu.Lock() | 1570 | ac.copyMu.Lock() | ||
| 1247 | copyActive := ac.copyMode != nil | 1571 | copyActive := ac.copyMode != nil | ||
| 1248 | - | if reset || copyActive { | 1572 | + | copyMode := ac.copyMode |
| 1573 | + | ac.copyMu.Unlock() | |||
| 1574 | + | ac.pickerMu.Lock() | |||
| 1575 | + | pickerActive := ac.picker != nil | |||
| 1576 | + | pickerModel := ac.picker | |||
| 1577 | + | previewTab := ac.pickerPreview | |||
| 1578 | + | preview := snapshotPickerPreview(previewTab) | |||
| 1579 | + | ||||
| 1580 | + | tb.mu.Lock() | |||
| 1581 | + | if reset || copyActive || pickerActive { | |||
| 1249 | ac.rend.Reset() | 1582 | ac.rend.Reset() | ||
| 1250 | } | 1583 | } | ||
| 1251 | - | if reset { | 1584 | + | if reset || pickerActive { |
| 1252 | ac.lastCursor.valid = false | 1585 | ac.lastCursor.valid = false | ||
| 1253 | } | 1586 | } | ||
| 1254 | - | frame, damage := composeClientFrame(sess, win, reset) | 1587 | + | frame, damage := composeClientFrame(sess, tb, reset) |
| 1255 | if copyActive { | 1588 | if copyActive { | ||
| 1256 | - | frame, damage = composeCopyClientFrame(ac.copyMode, win) | 1589 | + | frame, damage = composeCopyClientFrame(copyMode, tb) |
| 1257 | } | 1590 | } | ||
| 1258 | - | desiredCursor := desiredCursorOut(win.screen, copyActive) | 1591 | + | if pickerActive { |
| 1259 | - | ac.copyMu.Unlock() | 1592 | + | if previewTab == tb { |
| 1593 | + | preview = pickerPreviewFromLockedTab(tb) | |||
| 1594 | + | } | |||
| 1595 | + | frame, damage = composePickerClientFrame(pickerModel, preview, frame) | |||
| 1596 | + | } | |||
| 1597 | + | ac.pickerMu.Unlock() | |||
| 1598 | + | desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive) | |||
| 1260 | data, err := ac.rend.Draw(frame, damage) | 1599 | data, err := ac.rend.Draw(frame, damage) | ||
| 1261 | var cursorTail []byte | 1600 | var cursorTail []byte | ||
| 1262 | if err == nil { | 1601 | if err == nil { | ||
| 1263 | cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0) | 1602 | cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0) | ||
| 1264 | } | 1603 | } | ||
| 1265 | - | win.screen.ClearDamage() | 1604 | + | tb.screen.ClearDamage() |
| 1266 | - | win.mu.Unlock() | 1605 | + | tb.mu.Unlock() |
| 1267 | 1606 | ||||
| 1268 | var serr error | 1607 | var serr error | ||
| 1269 | if err == nil { | 1608 | if err == nil { | ||
| @@ -1283,7 +1622,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 1283 | } | 1622 | } | ||
| 1284 | } | 1623 | } | ||
| 1285 | 1624 | ||||
| 1286 | - | // resize applies a client size change to every window using rows-1 for the PTY | 1625 | + | // resize applies a client size change to every tab using rows-1 for the PTY |
| 1287 | // and VT screen, then resets the renderer shadow and repaints the composed | 1626 | // and VT screen, then resets the renderer shadow and repaints the composed | ||
| 1288 | // client-sized frame (including the status row). | 1627 | // client-sized frame (including the status row). | ||
| 1289 | func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut { | 1628 | func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut { | ||
| @@ -1323,27 +1662,55 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte | |||||
| 1323 | return b | 1662 | return b | ||
| 1324 | } | 1663 | } | ||
| 1325 | 1664 | ||||
| 1326 | - | func composeClientFrame(sess *session, win *window, full bool) (renderer.Frame, []renderer.Damage) { | 1665 | + | func composeClientFrame(sess *session, tb *tab, full bool) (renderer.Frame, []renderer.Damage) { |
| 1327 | - | width, screenRows := win.screen.Frame.Width, win.screen.Frame.Height | 1666 | + | width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height |
| 1328 | frame := renderer.NewFrame(width, screenRows+1) | 1667 | frame := renderer.NewFrame(width, screenRows+1) | ||
| 1329 | for y := range screenRows { | 1668 | for y := range screenRows { | ||
| 1330 | - | copy(frame.Row(y), win.screen.Frame.Row(y)) | 1669 | + | copy(frame.Row(y), tb.screen.Frame.Row(y)) |
| 1331 | } | 1670 | } | ||
| 1332 | drawStatus(frame.Row(screenRows), sess) | 1671 | drawStatus(frame.Row(screenRows), sess) | ||
| 1333 | if full { | 1672 | if full { | ||
| 1334 | return frame, []renderer.Damage{renderer.FullRedraw()} | 1673 | return frame, []renderer.Damage{renderer.FullRedraw()} | ||
| 1335 | } | 1674 | } | ||
| 1336 | - | damage := append([]renderer.Damage(nil), win.screen.Damage()...) | 1675 | + | damage := append([]renderer.Damage(nil), tb.screen.Damage()...) |
| 1337 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows, Width: width, Height: 1}) | 1676 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows, Width: width, Height: 1}) | ||
| 1338 | return frame, damage | 1677 | return frame, damage | ||
| 1339 | } | 1678 | } | ||
| 1340 | 1679 | ||||
| 1341 | - | func composeCopyClientFrame(mode *scopy.Mode, win *window) (renderer.Frame, []renderer.Damage) { | 1680 | + | func composeCopyClientFrame(mode *scopy.Mode, tb *tab) (renderer.Frame, []renderer.Damage) { |
| 1342 | - | snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame) | 1681 | + | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) |
| 1343 | frame := mode.Render(snap) | 1682 | frame := mode.Render(snap) | ||
| 1344 | return frame, []renderer.Damage{renderer.FullRedraw()} | 1683 | return frame, []renderer.Damage{renderer.FullRedraw()} | ||
| 1345 | } | 1684 | } | ||
| 1346 | 1685 | ||||
| 1686 | + | func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame) (renderer.Frame, []renderer.Damage) { | |||
| 1687 | + | inner := pickerModal.Composite(base, renderer.DefaultStyle()) | |||
| 1688 | + | modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview) | |||
| 1689 | + | for y := range min(inner.Height, modalFrame.Height) { | |||
| 1690 | + | for x := range min(inner.Width, modalFrame.Width) { | |||
| 1691 | + | base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y)) | |||
| 1692 | + | } | |||
| 1693 | + | } | |||
| 1694 | + | return base, []renderer.Damage{renderer.FullRedraw()} | |||
| 1695 | + | } | |||
| 1696 | + | ||||
| 1697 | + | func snapshotPickerPreview(tb *tab) picker.Preview { | |||
| 1698 | + | if tb == nil { | |||
| 1699 | + | return picker.Preview{} | |||
| 1700 | + | } | |||
| 1701 | + | tb.mu.Lock() | |||
| 1702 | + | defer tb.mu.Unlock() | |||
| 1703 | + | return pickerPreviewFromLockedTab(tb) | |||
| 1704 | + | } | |||
| 1705 | + | ||||
| 1706 | + | func pickerPreviewFromLockedTab(tb *tab) picker.Preview { | |||
| 1707 | + | rows := make([][]renderer.Cell, tb.screen.Frame.Height) | |||
| 1708 | + | for y := range rows { | |||
| 1709 | + | rows[y] = append([]renderer.Cell(nil), tb.screen.Frame.Row(y)...) | |||
| 1710 | + | } | |||
| 1711 | + | return picker.Preview{Rows: rows, Width: tb.screen.Frame.Width, Height: tb.screen.Frame.Height} | |||
| 1712 | + | } | |||
| 1713 | + | ||||
| 1347 | func drawStatus(row []renderer.Cell, sess *session) { | 1714 | func drawStatus(row []renderer.Cell, sess *session) { | ||
| 1348 | for i := range row { | 1715 | for i := range row { | ||
| 1349 | row[i] = renderer.BlankCell() | 1716 | row[i] = renderer.BlankCell() | ||
| @@ -1351,7 +1718,7 @@ func drawStatus(row []renderer.Cell, sess *session) { | |||||
| 1351 | status := sess.statusSegments() | 1718 | status := sess.statusSegments() | ||
| 1352 | x := 0 | 1719 | x := 0 | ||
| 1353 | writeStatusText(row, &x, " "+status.session+" ", renderer.DefaultStyle()) | 1720 | writeStatusText(row, &x, " "+status.session+" ", renderer.DefaultStyle()) | ||
| 1354 | - | for _, w := range status.windows { | 1721 | + | for _, w := range status.tabs { |
| 1355 | style := renderer.DefaultStyle() | 1722 | style := renderer.DefaultStyle() | ||
| 1356 | if w.active { | 1723 | if w.active { | ||
| 1357 | style.Inverse = true | 1724 | style.Inverse = true | ||
| @@ -1362,10 +1729,10 @@ func drawStatus(row []renderer.Cell, sess *session) { | |||||
| 1362 | 1729 | ||||
| 1363 | type statusSnapshot struct { | 1730 | type statusSnapshot struct { | ||
| 1364 | session string | 1731 | session string | ||
| 1365 | - | windows []statusWindow | 1732 | + | tabs []statusTab |
| 1366 | } | 1733 | } | ||
| 1367 | 1734 | ||||
| 1368 | - | type statusWindow struct { | 1735 | + | type statusTab struct { |
| 1369 | name string | 1736 | name string | ||
| 1370 | active bool | 1737 | active bool | ||
| 1371 | } | 1738 | } | ||
| @@ -1373,10 +1740,10 @@ type statusWindow struct { | |||||
| 1373 | func (s *session) statusSegments() statusSnapshot { | 1740 | func (s *session) statusSegments() statusSnapshot { | ||
| 1374 | s.mu.Lock() | 1741 | s.mu.Lock() | ||
| 1375 | defer s.mu.Unlock() | 1742 | defer s.mu.Unlock() | ||
| 1376 | - | snap := statusSnapshot{session: s.name, windows: make([]statusWindow, len(s.windows))} | 1743 | + | snap := statusSnapshot{session: s.name, tabs: make([]statusTab, len(s.tabs))} |
| 1377 | - | for i := range s.windows { | 1744 | + | for i := range s.tabs { |
| 1378 | name := strconv.Itoa(i + 1) | 1745 | name := strconv.Itoa(i + 1) | ||
| 1379 | - | snap.windows[i] = statusWindow{name: name, active: i == s.active} | 1746 | + | snap.tabs[i] = statusTab{name: name, active: i == s.active} |
| 1380 | } | 1747 | } | ||
| 1381 | return snap | 1748 | return snap | ||
| 1382 | } | 1749 | } | ||
| @@ -1395,24 +1762,24 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||||
| 1395 | if !sz.Valid() { | 1762 | if !sz.Valid() { | ||
| 1396 | return | 1763 | return | ||
| 1397 | } | 1764 | } | ||
| 1398 | - | winSize := windowSize(sz) | 1765 | + | tbSize := tabSize(sz) |
| 1399 | ac.size = sz | 1766 | ac.size = sz | ||
| 1400 | 1767 | ||||
| 1401 | sess.mu.Lock() | 1768 | sess.mu.Lock() | ||
| 1402 | - | windows := append([]*window(nil), sess.windows...) | 1769 | + | tabs := append([]*tab(nil), sess.tabs...) |
| 1403 | sess.mu.Unlock() | 1770 | sess.mu.Unlock() | ||
| 1404 | - | if len(windows) == 0 { | 1771 | + | if len(tabs) == 0 { |
| 1405 | return | 1772 | return | ||
| 1406 | } | 1773 | } | ||
| 1407 | 1774 | ||||
| 1408 | - | for _, win := range windows { | 1775 | + | for _, tb := range tabs { |
| 1409 | - | if err := win.pty.Resize(winSize); err != nil { | 1776 | + | if err := tb.pty.Resize(tbSize); err != nil { |
| 1410 | d.log.Warn("pty resize failed", "err", err, "session", sess.name) | 1777 | d.log.Warn("pty resize failed", "err", err, "session", sess.name) | ||
| 1411 | } | 1778 | } | ||
| 1412 | - | win.mu.Lock() | 1779 | + | tb.mu.Lock() |
| 1413 | - | win.screen.Resize(winSize.Cols, winSize.Rows) | 1780 | + | tb.screen.Resize(tbSize.Cols, tbSize.Rows) |
| 1414 | - | win.size = winSize | 1781 | + | tb.size = tbSize |
| 1415 | - | win.mu.Unlock() | 1782 | + | tb.mu.Unlock() |
| 1416 | } | 1783 | } | ||
| 1417 | d.paint(sess, ac, true) | 1784 | d.paint(sess, ac, true) | ||
| 1418 | } | 1785 | } | ||
| @@ -1422,6 +1789,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||||
| 1422 | // its client's); a named session keeps running headless. | 1789 | // its client's); a named session keeps running headless. | ||
| 1423 | func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) { | 1790 | func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) { | ||
| 1424 | if sess.detachIfCurrent(ac) { | 1791 | if sess.detachIfCurrent(ac) { | ||
| 1792 | + | d.unregisterPreview(ac) | |||
| 1425 | _ = ac.tr.Close() | 1793 | _ = ac.tr.Close() | ||
| 1426 | d.log.Warn("detached client after send error", "session", sess.name) | 1794 | d.log.Warn("detached client after send error", "session", sess.name) | ||
| 1427 | if sess.ephemeral { | 1795 | if sess.ephemeral { | ||
| @@ -1458,13 +1826,17 @@ func (d *Daemon) killSession(sess *session, reason uint8) { | |||||
| 1458 | ac := sess.client | 1826 | ac := sess.client | ||
| 1459 | sess.client = nil | 1827 | sess.client = nil | ||
| 1460 | sess.mu.Unlock() | 1828 | sess.mu.Unlock() | ||
| 1829 | + | if ac != nil { | |||
| 1830 | + | d.unregisterPreview(ac) | |||
| 1831 | + | ac.setSession(nil) | |||
| 1832 | + | } | |||
| 1461 | 1833 | ||||
| 1462 | sess.cancel() | 1834 | sess.cancel() | ||
| 1463 | sess.mu.Lock() | 1835 | sess.mu.Lock() | ||
| 1464 | - | windows := append([]*window(nil), sess.windows...) | 1836 | + | tabs := append([]*tab(nil), sess.tabs...) |
| 1465 | sess.mu.Unlock() | 1837 | sess.mu.Unlock() | ||
| 1466 | - | for _, win := range windows { | 1838 | + | for _, tb := range tabs { |
| 1467 | - | _ = win.pty.Close() | 1839 | + | _ = tb.pty.Close() |
| 1468 | } | 1840 | } | ||
| 1469 | if empty { | 1841 | if empty { | ||
| 1470 | d.doneOnce.Do(func() { close(d.done) }) | 1842 | d.doneOnce.Do(func() { close(d.done) }) | ||
M internal/usecase/daemon/daemon_test.go +316 -93
| @@ -247,13 +247,14 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio | |||
| 247 | 247 | tr, sends := newCapturingTransport(t) | |
| 248 | 248 | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 249 | 249 | sctx, cancel := context.WithCancel(d.serveCtx) | |
| 250 | - | windows := make([]*window, 0, len(ptys)) | |
| 250 | + | tabs := make([]*tab, 0, len(ptys)) | |
| 251 | 251 | for _, p := range ptys { | |
| 252 | 252 | wctx, wcancel := context.WithCancel(sctx) | |
| 253 | - | windows = append(windows, &window{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: wctx, cancel: wcancel}) | |
| 253 | + | tabs = append(tabs, &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: wctx, cancel: wcancel}) | |
| 254 | 254 | } | |
| 255 | - | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, windows: windows, client: ac} | |
| 256 | - | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac}) | |
| 255 | + | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac} | |
| 256 | + | ac.setSession(sess) | |
| 257 | + | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}) | |
| 257 | 258 | d.sessions[sess.id] = sess | |
| 258 | 259 | t.Cleanup(cancel) | |
| 259 | 260 | return d, sess, ac, sends | |
| @@ -265,11 +266,12 @@ func TestPTYWriteErrorIsLogged(t *testing.T) { | |||
| 265 | 266 | errBoom := errors.New("boom") | |
| 266 | 267 | p := portsmocks.NewMockPTY(t) | |
| 267 | 268 | p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once() | |
| 268 | - | win := &window{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}} | |
| 269 | - | sess := &session{id: "manual", name: "work", windows: []*window{win}} | |
| 269 | + | win := &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}} | |
| 270 | + | sess := &session{id: "manual", name: "work", tabs: []*tab{win}} | |
| 270 | 271 | ac := &attachedClient{} | |
| 272 | + | ac.setSession(sess) | |
| 271 | 273 | ||
| 272 | - | daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("input")) | |
| 274 | + | daemonKeyHandler{d: d, ac: ac}.Forward([]byte("input")) | |
| 273 | 275 | ||
| 274 | 276 | got := logs.String() | |
| 275 | 277 | if !strings.Contains(got, "pty write failed") || !strings.Contains(got, "boom") || !strings.Contains(got, "work") { | |
| @@ -277,7 +279,7 @@ func TestPTYWriteErrorIsLogged(t *testing.T) { | |||
| 277 | 279 | } | |
| 278 | 280 | } | |
| 279 | 281 | ||
| 280 | - | func newManualWindowSession(t *testing.T, n int) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) { | |
| 282 | + | func newManualTabSession(t *testing.T, n int) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) { | |
| 281 | 283 | t.Helper() | |
| 282 | 284 | ptys := make([]ports.PTY, 0, n) | |
| 283 | 285 | releases := make([]func(), 0, n) | |
| @@ -290,7 +292,7 @@ func newManualWindowSession(t *testing.T, n int) (*Daemon, *session, *attachedCl | |||
| 290 | 292 | return d, sess, ac, sends, releases | |
| 291 | 293 | } | |
| 292 | 294 | ||
| 293 | - | func activeWindowIndex(sess *session) int { | |
| 295 | + | func activeTabIndex(sess *session) int { | |
| 294 | 296 | sess.mu.Lock() | |
| 295 | 297 | defer sess.mu.Unlock() | |
| 296 | 298 | return sess.active | |
| @@ -308,8 +310,8 @@ func TestCopyModeAltUInterceptsAndDoesNotForward(t *testing.T) { | |||
| 308 | 310 | writes := make(chan []byte, 1) | |
| 309 | 311 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 310 | 312 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 311 | - | sess.windows[0].scrollback = scopy.NewScrollback(4) | |
| 312 | - | sess.windows[0].screen.Write([]byte("live")) | |
| 313 | + | sess.tabs[0].scrollback = scopy.NewScrollback(4) | |
| 314 | + | sess.tabs[0].screen.Write([]byte("live")) | |
| 313 | 315 | ||
| 314 | 316 | d.handleInput(sess, ac, []byte("\x1bu")) | |
| 315 | 317 | ||
| @@ -333,10 +335,10 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |||
| 333 | 335 | writes := make(chan []byte, 1) | |
| 334 | 336 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 335 | 337 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 336 | - | sess.windows[0].scrollback = scopy.NewScrollback(4) | |
| 337 | - | sess.windows[0].scrollback.Append(testRow("old1 ")) | |
| 338 | - | sess.windows[0].scrollback.Append(testRow("old2 ")) | |
| 339 | - | sess.windows[0].screen.Write([]byte("live")) | |
| 338 | + | sess.tabs[0].scrollback = scopy.NewScrollback(4) | |
| 339 | + | sess.tabs[0].scrollback.Append(testRow("old1 ")) | |
| 340 | + | sess.tabs[0].scrollback.Append(testRow("old2 ")) | |
| 341 | + | sess.tabs[0].screen.Write([]byte("live")) | |
| 340 | 342 | ||
| 341 | 343 | d.enterCopyMode(sess, ac) | |
| 342 | 344 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -400,7 +402,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 400 | 402 | if sess == nil { | |
| 401 | 403 | return false | |
| 402 | 404 | } | |
| 403 | - | win := sess.activeWindow() | |
| 405 | + | win := sess.activeTab() | |
| 404 | 406 | if win == nil { | |
| 405 | 407 | return false | |
| 406 | 408 | } | |
| @@ -440,8 +442,8 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 440 | 442 | func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) { | |
| 441 | 443 | p, _ := newBlockingPTY(t) | |
| 442 | 444 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 443 | - | sess.windows[0].scrollback = scopy.NewScrollback(4) | |
| 444 | - | sess.windows[0].screen.Write([]byte("live")) | |
| 445 | + | sess.tabs[0].scrollback = scopy.NewScrollback(4) | |
| 446 | + | sess.tabs[0].screen.Write([]byte("live")) | |
| 445 | 447 | ||
| 446 | 448 | d.enterCopyMode(sess, ac) | |
| 447 | 449 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -473,10 +475,10 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||
| 473 | 475 | t.Run(tc.name, func(t *testing.T) { | |
| 474 | 476 | p, _ := newBlockingPTY(t) | |
| 475 | 477 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 476 | - | sess.windows[0].scrollback = scopy.NewScrollback(4) | |
| 477 | - | sess.windows[0].scrollback.Append(testRow("old1 ")) | |
| 478 | - | sess.windows[0].scrollback.Append(testRow("old2 ")) | |
| 479 | - | sess.windows[0].screen.Write([]byte("live")) | |
| 478 | + | sess.tabs[0].scrollback = scopy.NewScrollback(4) | |
| 479 | + | sess.tabs[0].scrollback.Append(testRow("old1 ")) | |
| 480 | + | sess.tabs[0].scrollback.Append(testRow("old2 ")) | |
| 481 | + | sess.tabs[0].screen.Write([]byte("live")) | |
| 480 | 482 | ||
| 481 | 483 | d.enterCopyMode(sess, ac) | |
| 482 | 484 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -504,8 +506,8 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) { | |||
| 504 | 506 | t.Run(tc.name, func(t *testing.T) { | |
| 505 | 507 | p, _ := newBlockingPTY(t) | |
| 506 | 508 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 507 | - | sess.windows[0].scrollback = scopy.NewScrollback(4) | |
| 508 | - | sess.windows[0].screen.Write([]byte("live")) | |
| 509 | + | sess.tabs[0].scrollback = scopy.NewScrollback(4) | |
| 510 | + | sess.tabs[0].screen.Write([]byte("live")) | |
| 509 | 511 | ||
| 510 | 512 | d.enterCopyMode(sess, ac) | |
| 511 | 513 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -530,8 +532,8 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) { | |||
| 530 | 532 | func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) { | |
| 531 | 533 | p, _ := newBlockingPTY(t) | |
| 532 | 534 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 533 | - | sess.windows[0].scrollback = scopy.NewScrollback(4) | |
| 534 | - | sess.windows[0].screen.Write([]byte("live")) | |
| 535 | + | sess.tabs[0].scrollback = scopy.NewScrollback(4) | |
| 536 | + | sess.tabs[0].screen.Write([]byte("live")) | |
| 535 | 537 | ||
| 536 | 538 | done := make(chan struct{}) | |
| 537 | 539 | var drain sync.WaitGroup | |
| @@ -560,10 +562,10 @@ func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) { | |||
| 560 | 562 | drain.Wait() | |
| 561 | 563 | } | |
| 562 | 564 | ||
| 563 | - | func windowCount(sess *session) int { | |
| 565 | + | func tabCount(sess *session) int { | |
| 564 | 566 | sess.mu.Lock() | |
| 565 | 567 | defer sess.mu.Unlock() | |
| 566 | - | return len(sess.windows) | |
| 568 | + | return len(sess.tabs) | |
| 567 | 569 | } | |
| 568 | 570 | ||
| 569 | 571 | // --- handshake -------------------------------------------------------------- | |
| @@ -671,7 +673,7 @@ func TestEphemeralNumberingReuse(t *testing.T) { | |||
| 671 | 673 | ||
| 672 | 674 | // --- attach replace --------------------------------------------------------- | |
| 673 | 675 | ||
| 674 | - | func TestAltDigitSwitchesBetweenThreeWindows(t *testing.T) { | |
| 676 | + | func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) { | |
| 675 | 677 | writes1 := make(chan []byte, 1) | |
| 676 | 678 | writes2 := make(chan []byte, 1) | |
| 677 | 679 | writes3 := make(chan []byte, 1) | |
| @@ -698,7 +700,7 @@ func TestAltDigitSwitchesBetweenThreeWindows(t *testing.T) { | |||
| 698 | 700 | ||
| 699 | 701 | require.Eventually(t, func() bool { | |
| 700 | 702 | sessions := listSessions(t, d) | |
| 701 | - | return len(sessions.Sessions) == 1 && sessions.Sessions[0].Windows == 3 | |
| 703 | + | return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3 | |
| 702 | 704 | }, 2*time.Second, 5*time.Millisecond) | |
| 703 | 705 | require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond) | |
| 704 | 706 | require.Equal(t, []byte("A"), <-writes1) | |
| @@ -715,7 +717,7 @@ func TestAltDigitSwitchesBetweenThreeWindows(t *testing.T) { | |||
| 715 | 717 | d.sessWg.Wait() | |
| 716 | 718 | } | |
| 717 | 719 | ||
| 718 | - | func TestAltCCreatesSecondActiveWindow(t *testing.T) { | |
| 720 | + | func TestAltCCreatesSecondActiveTab(t *testing.T) { | |
| 719 | 721 | p1, releasePTY1 := newBlockingPTY(t) | |
| 720 | 722 | p2, releasePTY2 := newBlockingPTY(t) | |
| 721 | 723 | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | |
| @@ -731,7 +733,7 @@ func TestAltCCreatesSecondActiveWindow(t *testing.T) { | |||
| 731 | 733 | ||
| 732 | 734 | require.Eventually(t, func() bool { | |
| 733 | 735 | sessions := listSessions(t, d) | |
| 734 | - | return len(sessions.Sessions) == 1 && sessions.Sessions[0].Windows == 2 | |
| 736 | + | return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 2 | |
| 735 | 737 | }, 2*time.Second, 5*time.Millisecond) | |
| 736 | 738 | ||
| 737 | 739 | releaseConn() | |
| @@ -741,7 +743,7 @@ func TestAltCCreatesSecondActiveWindow(t *testing.T) { | |||
| 741 | 743 | d.sessWg.Wait() | |
| 742 | 744 | } | |
| 743 | 745 | ||
| 744 | - | func TestAltNextPreviousSwitchActiveWindow(t *testing.T) { | |
| 746 | + | func TestAltNextPreviousSwitchActiveTab(t *testing.T) { | |
| 745 | 747 | cases := []struct { | |
| 746 | 748 | name string | |
| 747 | 749 | start int | |
| @@ -756,7 +758,7 @@ func TestAltNextPreviousSwitchActiveWindow(t *testing.T) { | |||
| 756 | 758 | ||
| 757 | 759 | for _, tc := range cases { | |
| 758 | 760 | t.Run(tc.name, func(t *testing.T) { | |
| 759 | - | d, sess, ac, _, releases := newManualWindowSession(t, 3) | |
| 761 | + | d, sess, ac, _, releases := newManualTabSession(t, 3) | |
| 760 | 762 | defer func() { | |
| 761 | 763 | for _, release := range releases { | |
| 762 | 764 | release() | |
| @@ -766,12 +768,12 @@ func TestAltNextPreviousSwitchActiveWindow(t *testing.T) { | |||
| 766 | 768 | ||
| 767 | 769 | d.handleInput(sess, ac, tc.input) | |
| 768 | 770 | ||
| 769 | - | require.Equal(t, tc.wantIndex, activeWindowIndex(sess)) | |
| 771 | + | require.Equal(t, tc.wantIndex, activeTabIndex(sess)) | |
| 770 | 772 | }) | |
| 771 | 773 | } | |
| 772 | 774 | } | |
| 773 | 775 | ||
| 774 | - | func TestAltXClosesActiveWindowAndSelectsRemaining(t *testing.T) { | |
| 776 | + | func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) { | |
| 775 | 777 | writes := make(chan []byte, 1) | |
| 776 | 778 | p1, releasePTY1 := newBlockingPTY(t) | |
| 777 | 779 | p2, releasePTY2 := newBlockingPTY(t) | |
| @@ -782,8 +784,8 @@ func TestAltXClosesActiveWindowAndSelectsRemaining(t *testing.T) { | |||
| 782 | 784 | d.handleInput(sess, ac, []byte("\x1bx")) | |
| 783 | 785 | ||
| 784 | 786 | require.Equal(t, 1, sessionCount(d)) | |
| 785 | - | require.Len(t, sess.windows, 2) | |
| 786 | - | require.Equal(t, 1, activeWindowIndex(sess), "closing middle window selects the next remaining window") | |
| 787 | + | require.Len(t, sess.tabs, 2) | |
| 788 | + | require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab") | |
| 787 | 789 | d.handleInput(sess, ac, []byte("Z")) | |
| 788 | 790 | require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond) | |
| 789 | 791 | require.Equal(t, []byte("Z"), <-writes) | |
| @@ -802,7 +804,7 @@ func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} { | |||
| 802 | 804 | return done | |
| 803 | 805 | } | |
| 804 | 806 | ||
| 805 | - | func TestAltXClosesNonFinalWindowScheduler(t *testing.T) { | |
| 807 | + | func TestAltXClosesNonFinalTabScheduler(t *testing.T) { | |
| 806 | 808 | p1, releasePTY1 := newBlockingPTY(t) | |
| 807 | 809 | p2, releasePTY2 := newBlockingPTY(t) | |
| 808 | 810 | clk := &signalClock{called: make(chan struct{})} | |
| @@ -812,8 +814,8 @@ func TestAltXClosesNonFinalWindowScheduler(t *testing.T) { | |||
| 812 | 814 | defer releasePTY2() | |
| 813 | 815 | ||
| 814 | 816 | d.sessWg.Add(1) | |
| 815 | - | go d.scheduler(sess, sess.windows[1]) | |
| 816 | - | sess.windows[1].dirty <- struct{}{} | |
| 817 | + | go d.scheduler(sess, sess.tabs[1]) | |
| 818 | + | sess.tabs[1].dirty <- struct{}{} | |
| 817 | 819 | <-clk.called | |
| 818 | 820 | ||
| 819 | 821 | d.handleInput(sess, ac, []byte("\x1b2")) | |
| @@ -822,12 +824,12 @@ func TestAltXClosesNonFinalWindowScheduler(t *testing.T) { | |||
| 822 | 824 | select { | |
| 823 | 825 | case <-waitGroupDone(&d.sessWg): | |
| 824 | 826 | case <-time.After(2 * time.Second): | |
| 825 | - | t.Fatal("scheduler for removed window did not exit") | |
| 827 | + | t.Fatal("scheduler for removed tab did not exit") | |
| 826 | 828 | } | |
| 827 | 829 | require.Equal(t, 1, sessionCount(d)) | |
| 828 | 830 | } | |
| 829 | 831 | ||
| 830 | - | func TestPTYEOFClosesNonFinalWindowScheduler(t *testing.T) { | |
| 832 | + | func TestPTYEOFClosesNonFinalTabScheduler(t *testing.T) { | |
| 831 | 833 | p1, releasePTY1 := newBlockingPTY(t) | |
| 832 | 834 | p2, releasePTY2 := newBlockingPTY(t) | |
| 833 | 835 | clk := &signalClock{called: make(chan struct{})} | |
| @@ -837,22 +839,22 @@ func TestPTYEOFClosesNonFinalWindowScheduler(t *testing.T) { | |||
| 837 | 839 | defer releasePTY2() | |
| 838 | 840 | ||
| 839 | 841 | d.sessWg.Add(2) | |
| 840 | - | go d.scheduler(sess, sess.windows[1]) | |
| 841 | - | go d.ptyReader(sess, sess.windows[1]) | |
| 842 | - | sess.windows[1].dirty <- struct{}{} | |
| 842 | + | go d.scheduler(sess, sess.tabs[1]) | |
| 843 | + | go d.ptyReader(sess, sess.tabs[1]) | |
| 844 | + | sess.tabs[1].dirty <- struct{}{} | |
| 843 | 845 | <-clk.called | |
| 844 | 846 | releasePTY2() | |
| 845 | 847 | ||
| 846 | 848 | select { | |
| 847 | 849 | case <-waitGroupDone(&d.sessWg): | |
| 848 | 850 | case <-time.After(2 * time.Second): | |
| 849 | - | t.Fatal("scheduler for EOF-removed window did not exit") | |
| 851 | + | t.Fatal("scheduler for EOF-removed tab did not exit") | |
| 850 | 852 | } | |
| 851 | 853 | require.Equal(t, 1, sessionCount(d)) | |
| 852 | 854 | } | |
| 853 | 855 | ||
| 854 | - | func TestAltXClosesFinalWindowAndDetaches(t *testing.T) { | |
| 855 | - | d, sess, ac, sends, releases := newManualWindowSession(t, 1) | |
| 856 | + | func TestAltXClosesFinalTabAndDetaches(t *testing.T) { | |
| 857 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 856 | 858 | defer releases[0]() | |
| 857 | 859 | ||
| 858 | 860 | d.handleInput(sess, ac, []byte("\x1bx")) | |
| @@ -864,21 +866,21 @@ func TestAltXClosesFinalWindowAndDetaches(t *testing.T) { | |||
| 864 | 866 | require.Equal(t, ports.ReasonSessionKilled, det.Reason) | |
| 865 | 867 | } | |
| 866 | 868 | ||
| 867 | - | func TestPTYEOFClosesActiveNonFinalWindowAndRepaintsRemaining(t *testing.T) { | |
| 869 | + | func TestPTYEOFClosesActiveNonFinalTabAndRepaintsRemaining(t *testing.T) { | |
| 868 | 870 | p1, releasePTY1 := newBlockingPTY(t) | |
| 869 | 871 | p2, releasePTY2 := newBlockingPTY(t) | |
| 870 | 872 | d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2) | |
| 871 | 873 | defer releasePTY2() | |
| 872 | 874 | sess.active = 0 | |
| 873 | - | sess.windows[1].screen.Write([]byte("remaining")) | |
| 875 | + | sess.tabs[1].screen.Write([]byte("remaining")) | |
| 874 | 876 | ||
| 875 | 877 | d.sessWg.Add(1) | |
| 876 | - | go d.ptyReader(sess, sess.windows[0]) | |
| 878 | + | go d.ptyReader(sess, sess.tabs[0]) | |
| 877 | 879 | releasePTY1() | |
| 878 | 880 | ||
| 879 | - | require.Eventually(t, func() bool { return windowCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond) | |
| 881 | + | require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond) | |
| 880 | 882 | require.Equal(t, 1, sessionCount(d)) | |
| 881 | - | require.Equal(t, 0, activeWindowIndex(sess)) | |
| 883 | + | require.Equal(t, 0, activeTabIndex(sess)) | |
| 882 | 884 | f := awaitFrame(t, sends, ports.MsgOutput) | |
| 883 | 885 | out, err := ports.UnmarshalOutput(f.Payload) | |
| 884 | 886 | require.NoError(t, err) | |
| @@ -890,21 +892,21 @@ func TestPTYEOFClosesActiveNonFinalWindowAndRepaintsRemaining(t *testing.T) { | |||
| 890 | 892 | d.sessWg.Wait() | |
| 891 | 893 | } | |
| 892 | 894 | ||
| 893 | - | func TestPTYEOFClosesInactiveNonFinalWindowAndRepaintsStatus(t *testing.T) { | |
| 895 | + | func TestPTYEOFClosesInactiveNonFinalTabAndRepaintsStatus(t *testing.T) { | |
| 894 | 896 | p1, releasePTY1 := newBlockingPTY(t) | |
| 895 | 897 | p2, releasePTY2 := newBlockingPTY(t) | |
| 896 | 898 | d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2) | |
| 897 | 899 | defer releasePTY1() | |
| 898 | 900 | sess.active = 0 | |
| 899 | - | sess.windows[0].screen.Write([]byte("active")) | |
| 901 | + | sess.tabs[0].screen.Write([]byte("active")) | |
| 900 | 902 | ||
| 901 | 903 | d.sessWg.Add(1) | |
| 902 | - | go d.ptyReader(sess, sess.windows[1]) | |
| 904 | + | go d.ptyReader(sess, sess.tabs[1]) | |
| 903 | 905 | releasePTY2() | |
| 904 | 906 | ||
| 905 | - | require.Eventually(t, func() bool { return windowCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond) | |
| 907 | + | require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond) | |
| 906 | 908 | require.Equal(t, 1, sessionCount(d)) | |
| 907 | - | require.Equal(t, 0, activeWindowIndex(sess)) | |
| 909 | + | require.Equal(t, 0, activeTabIndex(sess)) | |
| 908 | 910 | f := awaitFrame(t, sends, ports.MsgOutput) | |
| 909 | 911 | out, err := ports.UnmarshalOutput(f.Payload) | |
| 910 | 912 | require.NoError(t, err) | |
| @@ -916,11 +918,11 @@ func TestPTYEOFClosesInactiveNonFinalWindowAndRepaintsStatus(t *testing.T) { | |||
| 916 | 918 | d.sessWg.Wait() | |
| 917 | 919 | } | |
| 918 | 920 | ||
| 919 | - | func TestPTYEOFFinalWindowKillsSessionAndDetaches(t *testing.T) { | |
| 920 | - | d, sess, _, sends, releases := newManualWindowSession(t, 1) | |
| 921 | + | func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) { | |
| 922 | + | d, sess, _, sends, releases := newManualTabSession(t, 1) | |
| 921 | 923 | ||
| 922 | 924 | d.sessWg.Add(1) | |
| 923 | - | go d.ptyReader(sess, sess.windows[0]) | |
| 925 | + | go d.ptyReader(sess, sess.tabs[0]) | |
| 924 | 926 | releases[0]() | |
| 925 | 927 | ||
| 926 | 928 | require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond) | |
| @@ -933,7 +935,7 @@ func TestPTYEOFFinalWindowKillsSessionAndDetaches(t *testing.T) { | |||
| 933 | 935 | } | |
| 934 | 936 | ||
| 935 | 937 | func TestAltDDetachesCurrentClient(t *testing.T) { | |
| 936 | - | d, sess, ac, sends, releases := newManualWindowSession(t, 1) | |
| 938 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 937 | 939 | defer releases[0]() | |
| 938 | 940 | ||
| 939 | 941 | d.handleInput(sess, ac, []byte("\x1bd")) | |
| @@ -946,7 +948,7 @@ func TestAltDDetachesCurrentClient(t *testing.T) { | |||
| 946 | 948 | } | |
| 947 | 949 | ||
| 948 | 950 | func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) { | |
| 949 | - | d, sess, ac, sends, releases := newManualWindowSession(t, 1) | |
| 951 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 950 | 952 | defer releases[0]() | |
| 951 | 953 | sess.ephemeral = true | |
| 952 | 954 | sess.name = "0" | |
| @@ -958,6 +960,217 @@ func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) { | |||
| 958 | 960 | awaitFrame(t, sends, ports.MsgOutput) | |
| 959 | 961 | } | |
| 960 | 962 | ||
| 963 | + | func TestAltTOpensPickerModalAndDoesNotForward(t *testing.T) { | |
| 964 | + | writes := make(chan []byte, 1) | |
| 965 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| 966 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 967 | + | defer releasePTY() | |
| 968 | + | ||
| 969 | + | d.handleInput(sess, ac, []byte("\x1bt")) | |
| 970 | + | ||
| 971 | + | require.True(t, ac.pickerActive()) | |
| 972 | + | select { | |
| 973 | + | case got := <-writes: | |
| 974 | + | t.Fatalf("picker binding forwarded to PTY: %q", got) | |
| 975 | + | default: | |
| 976 | + | } | |
| 977 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 978 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |
| 979 | + | require.NoError(t, err) | |
| 980 | + | data := string(msg.Data) | |
| 981 | + | require.Contains(t, data, "┌") | |
| 982 | + | require.Contains(t, data, "Sessions") | |
| 983 | + | } | |
| 984 | + | ||
| 985 | + | func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | |
| 986 | + | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 987 | + | defer func() { | |
| 988 | + | for _, release := range releases { | |
| 989 | + | release() | |
| 990 | + | } | |
| 991 | + | }() | |
| 992 | + | ||
| 993 | + | d.handleInput(sess, ac, []byte("\x1bt")) | |
| 994 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 995 | + | d.handleInput(sess, ac, []byte("j")) | |
| 996 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 997 | + | d.handleInput(sess, ac, []byte("\r")) | |
| 998 | + | ||
| 999 | + | require.Equal(t, 1, activeTabIndex(sess)) | |
| 1000 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 1001 | + | d.handleInput(sess, ac, []byte("\x1bt")) | |
| 1002 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 1003 | + | d.handleInput(sess, ac, []byte("\x1b")) | |
| 1004 | + | require.False(t, ac.pickerActive()) | |
| 1005 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 1006 | + | } | |
| 1007 | + | ||
| 1008 | + | func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |
| 1009 | + | p1, releasePTY1 := newBlockingPTY(t) | |
| 1010 | + | p2, releasePTY2 := newBlockingPTY(t) | |
| 1011 | + | defer releasePTY1() | |
| 1012 | + | defer releasePTY2() | |
| 1013 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 1014 | + | tr1, sends1 := newCapturingTransport(t) | |
| 1015 | + | tr2, sends2 := newCapturingTransport(t) | |
| 1016 | + | ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 1017 | + | ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 1018 | + | sctx1, cancel1 := context.WithCancel(d.serveCtx) | |
| 1019 | + | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 1020 | + | defer cancel1() | |
| 1021 | + | defer cancel2() | |
| 1022 | + | sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1} | |
| 1023 | + | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2} | |
| 1024 | + | ac1.setSession(sess1) | |
| 1025 | + | ac2.setSession(sess2) | |
| 1026 | + | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}) | |
| 1027 | + | ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}) | |
| 1028 | + | d.sessions[sess1.id] = sess1 | |
| 1029 | + | d.sessions[sess2.id] = sess2 | |
| 1030 | + | ||
| 1031 | + | d.handleInput(sess1, ac1, []byte("\x1bt")) | |
| 1032 | + | awaitFrame(t, sends1, ports.MsgOutput) | |
| 1033 | + | d.handleInput(sess1, ac1, []byte("j")) | |
| 1034 | + | awaitFrame(t, sends1, ports.MsgOutput) | |
| 1035 | + | d.handleInput(sess1, ac1, []byte("\r")) | |
| 1036 | + | ||
| 1037 | + | require.Same(t, sess2, ac1.currentSession()) | |
| 1038 | + | require.Same(t, ac1, sess2.client) | |
| 1039 | + | require.Nil(t, sess1.client) | |
| 1040 | + | require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch") | |
| 1041 | + | det := awaitFrame(t, sends2, ports.MsgDetached) | |
| 1042 | + | dm, err := ports.UnmarshalDetached(det.Payload) | |
| 1043 | + | require.NoError(t, err) | |
| 1044 | + | require.Equal(t, ports.ReasonDetach, dm.Reason) | |
| 1045 | + | awaitFrame(t, sends1, ports.MsgOutput) | |
| 1046 | + | } | |
| 1047 | + | ||
| 1048 | + | func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) { | |
| 1049 | + | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 1050 | + | defer func() { | |
| 1051 | + | for _, release := range releases { | |
| 1052 | + | release() | |
| 1053 | + | } | |
| 1054 | + | }() | |
| 1055 | + | ||
| 1056 | + | d.handleInput(sess, ac, []byte("\x1bt")) | |
| 1057 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 1058 | + | d.handleInput(sess, ac, []byte("j")) | |
| 1059 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 1060 | + | ||
| 1061 | + | previewTab := sess.tabs[1] | |
| 1062 | + | previewTab.mu.Lock() | |
| 1063 | + | previewTab.screen.Write([]byte("inactive-preview-live")) | |
| 1064 | + | previewTab.mu.Unlock() | |
| 1065 | + | d.render(sess, previewTab) | |
| 1066 | + | ||
| 1067 | + | previewOut := awaitFrame(t, sends, ports.MsgOutput) | |
| 1068 | + | previewMsg, err := ports.UnmarshalOutput(previewOut.Payload) | |
| 1069 | + | require.NoError(t, err) | |
| 1070 | + | require.Contains(t, string(previewMsg.Data), "inactive-preview-live") | |
| 1071 | + | } | |
| 1072 | + | ||
| 1073 | + | func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) { | |
| 1074 | + | p1, releasePTY1 := newBlockingPTY(t) | |
| 1075 | + | p2, releasePTY2 := newBlockingPTY(t) | |
| 1076 | + | defer releasePTY1() | |
| 1077 | + | defer releasePTY2() | |
| 1078 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 1079 | + | tr1, sends1 := newCapturingTransport(t) | |
| 1080 | + | tr2, _ := newCapturingTransport(t) | |
| 1081 | + | ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 1082 | + | ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 1083 | + | sctx1, cancel1 := context.WithCancel(d.serveCtx) | |
| 1084 | + | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 1085 | + | defer cancel1() | |
| 1086 | + | defer cancel2() | |
| 1087 | + | sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1} | |
| 1088 | + | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2} | |
| 1089 | + | ac1.setSession(sess1) | |
| 1090 | + | ac2.setSession(sess2) | |
| 1091 | + | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}) | |
| 1092 | + | ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}) | |
| 1093 | + | d.sessions[sess1.id] = sess1 | |
| 1094 | + | d.sessions[sess2.id] = sess2 | |
| 1095 | + | ||
| 1096 | + | d.handleInput(sess1, ac1, []byte("\x1bt")) | |
| 1097 | + | awaitFrame(t, sends1, ports.MsgOutput) | |
| 1098 | + | d.handleInput(sess1, ac1, []byte("j")) | |
| 1099 | + | awaitFrame(t, sends1, ports.MsgOutput) | |
| 1100 | + | ||
| 1101 | + | previewTab := sess2.tabs[0] | |
| 1102 | + | previewTab.mu.Lock() | |
| 1103 | + | previewTab.screen.Write([]byte("cross-session-preview-live")) | |
| 1104 | + | previewTab.mu.Unlock() | |
| 1105 | + | d.render(sess2, previewTab) | |
| 1106 | + | ||
| 1107 | + | previewOut := awaitFrame(t, sends1, ports.MsgOutput) | |
| 1108 | + | previewMsg, err := ports.UnmarshalOutput(previewOut.Payload) | |
| 1109 | + | require.NoError(t, err) | |
| 1110 | + | require.Contains(t, string(previewMsg.Data), "cross-session-preview-live") | |
| 1111 | + | } | |
| 1112 | + | ||
| 1113 | + | func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) { | |
| 1114 | + | d, sess, ac, sends, releases := newManualTabSession(t, 3) | |
| 1115 | + | defer func() { | |
| 1116 | + | for _, release := range releases { | |
| 1117 | + | release() | |
| 1118 | + | } | |
| 1119 | + | }() | |
| 1120 | + | ||
| 1121 | + | done := make(chan struct{}) | |
| 1122 | + | var drain sync.WaitGroup | |
| 1123 | + | drain.Go(func() { | |
| 1124 | + | for { | |
| 1125 | + | select { | |
| 1126 | + | case <-sends: | |
| 1127 | + | case <-done: | |
| 1128 | + | return | |
| 1129 | + | } | |
| 1130 | + | } | |
| 1131 | + | }) | |
| 1132 | + | ||
| 1133 | + | var wg sync.WaitGroup | |
| 1134 | + | for i := range 100 { | |
| 1135 | + | wg.Go(func() { | |
| 1136 | + | d.enterPicker(sess, ac) | |
| 1137 | + | }) | |
| 1138 | + | wg.Go(func() { | |
| 1139 | + | d.handlePickerInput(sess, ac, []byte("j")) | |
| 1140 | + | d.handlePickerInput(sess, ac, []byte("k")) | |
| 1141 | + | }) | |
| 1142 | + | wg.Go(func() { | |
| 1143 | + | d.closePicker(ac) | |
| 1144 | + | }) | |
| 1145 | + | tb := sess.tabs[i%len(sess.tabs)] | |
| 1146 | + | wg.Go(func() { | |
| 1147 | + | tb.mu.Lock() | |
| 1148 | + | tb.screen.Write([]byte("render-race")) | |
| 1149 | + | tb.mu.Unlock() | |
| 1150 | + | d.render(sess, tb) | |
| 1151 | + | }) | |
| 1152 | + | } | |
| 1153 | + | wg.Wait() | |
| 1154 | + | close(done) | |
| 1155 | + | drain.Wait() | |
| 1156 | + | } | |
| 1157 | + | ||
| 1158 | + | func TestPickerResizeRecomposesModal(t *testing.T) { | |
| 1159 | + | p, releasePTY := newBlockingPTY(t) | |
| 1160 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1161 | + | defer releasePTY() | |
| 1162 | + | ||
| 1163 | + | d.handleInput(sess, ac, []byte("\x1bt")) | |
| 1164 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 1165 | + | d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) | |
| 1166 | + | ||
| 1167 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 1168 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |
| 1169 | + | require.NoError(t, err) | |
| 1170 | + | require.Contains(t, string(msg.Data), "┌") | |
| 1171 | + | require.Contains(t, string(msg.Data), "Sessions") | |
| 1172 | + | } | |
| 1173 | + | ||
| 961 | 1174 | func TestStatusCompositionGolden(t *testing.T) { | |
| 962 | 1175 | p1, releasePTY1 := newBlockingPTY(t) | |
| 963 | 1176 | p2, releasePTY2 := newBlockingPTY(t) | |
| @@ -967,7 +1180,7 @@ func TestStatusCompositionGolden(t *testing.T) { | |||
| 967 | 1180 | sess.active = 1 | |
| 968 | 1181 | sess.name = "work" | |
| 969 | 1182 | ||
| 970 | - | win := sess.activeWindow() | |
| 1183 | + | win := sess.activeTab() | |
| 971 | 1184 | win.screen = vt.NewScreen(12, 2) | |
| 972 | 1185 | win.size = domain.Size{Cols: 12, Rows: 2} | |
| 973 | 1186 | win.screen.Write([]byte("hello")) | |
| @@ -981,7 +1194,7 @@ func TestStatusCompositionGolden(t *testing.T) { | |||
| 981 | 1194 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |
| 982 | 1195 | for i, c := range frame.Row(2) { | |
| 983 | 1196 | if i >= len(" work 1 ") && i < len(" work 1 2 ") { | |
| 984 | - | require.True(t, c.Style.Inverse, "active window segment cell %d should be inverse", i) | |
| 1197 | + | require.True(t, c.Style.Inverse, "active tab segment cell %d should be inverse", i) | |
| 985 | 1198 | } | |
| 986 | 1199 | } | |
| 987 | 1200 | } | |
| @@ -1009,7 +1222,7 @@ func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) { | |||
| 1009 | 1222 | out, err := ports.UnmarshalOutput(f.Payload) | |
| 1010 | 1223 | require.NoError(t, err) | |
| 1011 | 1224 | require.Contains(t, string(out.Data), "work") | |
| 1012 | - | require.Contains(t, string(out.Data), ";7m", "active status window should be inverse-highlighted") | |
| 1225 | + | require.Contains(t, string(out.Data), ";7m", "active status tab should be inverse-highlighted") | |
| 1013 | 1226 | } | |
| 1014 | 1227 | ||
| 1015 | 1228 | releaseConn() | |
| @@ -1147,9 +1360,10 @@ func TestSchedulerDebounceCoalesces(t *testing.T) { | |||
| 1147 | 1360 | ||
| 1148 | 1361 | d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 1149 | 1362 | sctx, cancel := context.WithCancel(context.Background()) | |
| 1150 | - | win := &window{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}} | |
| 1363 | + | win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}} | |
| 1151 | 1364 | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 1152 | - | sess := &session{id: "s", name: "s", windows: []*window{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 1365 | + | sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 1366 | + | ac.setSession(sess) | |
| 1153 | 1367 | ||
| 1154 | 1368 | win.mu.Lock() | |
| 1155 | 1369 | win.screen.Write([]byte("hi")) | |
| @@ -1176,7 +1390,7 @@ func TestSchedulerDebounceCoalesces(t *testing.T) { | |||
| 1176 | 1390 | ||
| 1177 | 1391 | cancel() | |
| 1178 | 1392 | d.sessWg.Wait() | |
| 1179 | - | require.Equal(t, int32(1), outputs.Load(), "N dirties in one window must render exactly once") | |
| 1393 | + | require.Equal(t, int32(1), outputs.Load(), "N dirties in one tab must render exactly once") | |
| 1180 | 1394 | } | |
| 1181 | 1395 | ||
| 1182 | 1396 | func TestSchedulerAdaptiveDebounceFloodAndIdle(t *testing.T) { | |
| @@ -1199,7 +1413,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||
| 1199 | 1413 | ||
| 1200 | 1414 | p := portsmocks.NewMockPTY(t) | |
| 1201 | 1415 | var screenWidthAtResize int | |
| 1202 | - | win := &window{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}} | |
| 1416 | + | win := &tab{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}} | |
| 1203 | 1417 | p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 29}).RunAndReturn(func(sz domain.Size) error { | |
| 1204 | 1418 | // The screen must not yet be resized when the PTY is: proves order. | |
| 1205 | 1419 | screenWidthAtResize = win.screen.Frame.Width | |
| @@ -1218,7 +1432,8 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||
| 1218 | 1432 | ||
| 1219 | 1433 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 1220 | 1434 | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 1221 | - | sess := &session{id: "s", name: "s", windows: []*window{win}, client: ac} | |
| 1435 | + | sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac} | |
| 1436 | + | ac.setSession(sess) | |
| 1222 | 1437 | ||
| 1223 | 1438 | d.resize(sess, ac, newSize) | |
| 1224 | 1439 | ||
| @@ -1509,10 +1724,11 @@ func TestSendErrorKillsEphemeral(t *testing.T) { | |||
| 1509 | 1724 | tr.EXPECT().Close().Return(nil).Maybe() | |
| 1510 | 1725 | ||
| 1511 | 1726 | d := newTestDaemon(t, nil, stubClock{}) | |
| 1512 | - | win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}} | |
| 1727 | + | win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}} | |
| 1513 | 1728 | sctx, cancel := context.WithCancel(context.Background()) | |
| 1514 | 1729 | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 1515 | - | sess := &session{id: "e", name: "0", ephemeral: true, windows: []*window{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 1730 | + | sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 1731 | + | ac.setSession(sess) | |
| 1516 | 1732 | d.sessions[sess.id] = sess | |
| 1517 | 1733 | ||
| 1518 | 1734 | win.mu.Lock() | |
| @@ -1550,9 +1766,10 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) | |||
| 1550 | 1766 | ||
| 1551 | 1767 | d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 1552 | 1768 | sctx, cancel := context.WithCancel(context.Background()) | |
| 1553 | - | win := &window{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | |
| 1769 | + | win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | |
| 1554 | 1770 | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 1555 | - | sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 1771 | + | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 1772 | + | ac.setSession(sess) | |
| 1556 | 1773 | ||
| 1557 | 1774 | win.mu.Lock() | |
| 1558 | 1775 | win.screen.Write([]byte("before")) | |
| @@ -1609,8 +1826,10 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) { | |||
| 1609 | 1826 | ||
| 1610 | 1827 | tr, sends := newCapturingTransport(t) | |
| 1611 | 1828 | sctx, cancel := context.WithCancel(context.Background()) | |
| 1612 | - | win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | |
| 1613 | - | sess := &session{id: "query", name: "query", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}} | |
| 1829 | + | win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | |
| 1830 | + | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 1831 | + | sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 1832 | + | ac.setSession(sess) | |
| 1614 | 1833 | d.sessions[sess.id] = sess | |
| 1615 | 1834 | d.sessWg.Add(1) | |
| 1616 | 1835 | d.ptyReader(sess, win) | |
| @@ -1643,8 +1862,8 @@ func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testin | |||
| 1643 | 1862 | p.EXPECT().Close().Return(nil).Maybe() | |
| 1644 | 1863 | ||
| 1645 | 1864 | sctx, cancel := context.WithCancel(context.Background()) | |
| 1646 | - | win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | |
| 1647 | - | sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel} | |
| 1865 | + | win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | |
| 1866 | + | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 1648 | 1867 | d.sessions[sess.id] = sess | |
| 1649 | 1868 | d.sessWg.Add(1) | |
| 1650 | 1869 | d.ptyReader(sess, win) | |
| @@ -1676,8 +1895,8 @@ func TestPTYReaderDefersDirtyDuringSynchronizedUpdateAndFlushesAtEnd(t *testing. | |||
| 1676 | 1895 | p.EXPECT().Close().Return(nil).Maybe() | |
| 1677 | 1896 | ||
| 1678 | 1897 | sctx, cancel := context.WithCancel(context.Background()) | |
| 1679 | - | win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | |
| 1680 | - | sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel} | |
| 1898 | + | win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | |
| 1899 | + | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 1681 | 1900 | d.sessions[sess.id] = sess | |
| 1682 | 1901 | d.sessWg.Add(1) | |
| 1683 | 1902 | d.ptyReader(sess, win) | |
| @@ -1712,8 +1931,10 @@ func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) { | |||
| 1712 | 1931 | ||
| 1713 | 1932 | tr, sends := newCapturingTransport(t) | |
| 1714 | 1933 | sctx, cancel := context.WithCancel(context.Background()) | |
| 1715 | - | win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | |
| 1716 | - | sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}} | |
| 1934 | + | win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | |
| 1935 | + | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 1936 | + | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 1937 | + | ac.setSession(sess) | |
| 1717 | 1938 | d.sessions[sess.id] = sess | |
| 1718 | 1939 | d.sessWg.Add(2) | |
| 1719 | 1940 | go d.scheduler(sess, win) | |
| @@ -1754,8 +1975,10 @@ func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) { | |||
| 1754 | 1975 | ||
| 1755 | 1976 | tr, sends := newCapturingTransport(t) | |
| 1756 | 1977 | sctx, cancel := context.WithCancel(context.Background()) | |
| 1757 | - | win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | |
| 1758 | - | sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}} | |
| 1978 | + | win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | |
| 1979 | + | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 1980 | + | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 1981 | + | ac.setSession(sess) | |
| 1759 | 1982 | d.sessions[sess.id] = sess | |
| 1760 | 1983 | d.sessWg.Add(2) | |
| 1761 | 1984 | go d.scheduler(sess, win) | |
| @@ -1794,7 +2017,7 @@ func TestMouseWheelCopyEntryExitAndStreamOrder(t *testing.T) { | |||
| 1794 | 2017 | writes := make(chan []byte, 4) | |
| 1795 | 2018 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 1796 | 2019 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1797 | - | win := sess.windows[0] | |
| 2020 | + | win := sess.tabs[0] | |
| 1798 | 2021 | win.screen.Write([]byte("live")) | |
| 1799 | 2022 | ||
| 1800 | 2023 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | |
| @@ -1821,7 +2044,7 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) { | |||
| 1821 | 2044 | writes := make(chan []byte, 2) | |
| 1822 | 2045 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 1823 | 2046 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 1824 | - | sess.windows[0].screen.Write([]byte("\x1b[?1049h")) | |
| 2047 | + | sess.tabs[0].screen.Write([]byte("\x1b[?1049h")) | |
| 1825 | 2048 | ||
| 1826 | 2049 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | |
| 1827 | 2050 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | |
| @@ -1843,7 +2066,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||
| 1843 | 2066 | default: | |
| 1844 | 2067 | } | |
| 1845 | 2068 | ||
| 1846 | - | sess.windows[0].screen.Write([]byte("\x1b[?1000h")) | |
| 2069 | + | sess.tabs[0].screen.Write([]byte("\x1b[?1000h")) | |
| 1847 | 2070 | raw := []byte("\x1b[<0;2;3M") | |
| 1848 | 2071 | d.handleInput(sess, ac, raw) | |
| 1849 | 2072 | select { | |
| @@ -1852,7 +2075,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||
| 1852 | 2075 | default: | |
| 1853 | 2076 | } | |
| 1854 | 2077 | ||
| 1855 | - | sess.windows[0].screen.Write([]byte("\x1b[?1006h")) | |
| 2078 | + | sess.tabs[0].screen.Write([]byte("\x1b[?1006h")) | |
| 1856 | 2079 | d.handleInput(sess, ac, raw) | |
| 1857 | 2080 | require.Equal(t, raw, <-writes) | |
| 1858 | 2081 | ||
| @@ -1868,7 +2091,7 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) { | |||
| 1868 | 2091 | writes := make(chan []byte, 2) | |
| 1869 | 2092 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 1870 | 2093 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1871 | - | sess.windows[0].screen.Write([]byte("live")) | |
| 2094 | + | sess.tabs[0].screen.Write([]byte("live")) | |
| 1872 | 2095 | ||
| 1873 | 2096 | d.handleInput(sess, ac, []byte("\x1b[<64;")) | |
| 1874 | 2097 | d.handleInput(sess, ac, []byte("1;1Mq")) | |
| @@ -1886,7 +2109,7 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) { | |||
| 1886 | 2109 | func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) { | |
| 1887 | 2110 | p, _ := newBlockingPTY(t) | |
| 1888 | 2111 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1889 | - | win := sess.windows[0] | |
| 2112 | + | win := sess.tabs[0] | |
| 1890 | 2113 | win.screen.Write([]byte("A")) | |
| 1891 | 2114 | ||
| 1892 | 2115 | d.paint(sess, ac, true) | |
| @@ -1909,7 +2132,7 @@ func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) { | |||
| 1909 | 2132 | func TestCursorTailHidesInCopyMode(t *testing.T) { | |
| 1910 | 2133 | p, _ := newBlockingPTY(t) | |
| 1911 | 2134 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1912 | - | sess.windows[0].screen.Write([]byte("live")) | |
| 2135 | + | sess.tabs[0].screen.Write([]byte("live")) | |
| 1913 | 2136 | ||
| 1914 | 2137 | d.enterCopyMode(sess, ac) | |
| 1915 | 2138 | data := mustOutputData(t, sends) | |
| @@ -247,13 +247,14 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio | |||||
| 247 | tr, sends := newCapturingTransport(t) | 247 | tr, sends := newCapturingTransport(t) | ||
| 248 | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | 248 | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | ||
| 249 | sctx, cancel := context.WithCancel(d.serveCtx) | 249 | sctx, cancel := context.WithCancel(d.serveCtx) | ||
| 250 | - | windows := make([]*window, 0, len(ptys)) | 250 | + | tabs := make([]*tab, 0, len(ptys)) |
| 251 | for _, p := range ptys { | 251 | for _, p := range ptys { | ||
| 252 | wctx, wcancel := context.WithCancel(sctx) | 252 | wctx, wcancel := context.WithCancel(sctx) | ||
| 253 | - | windows = append(windows, &window{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: wctx, cancel: wcancel}) | 253 | + | tabs = append(tabs, &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: wctx, cancel: wcancel}) |
| 254 | } | 254 | } | ||
| 255 | - | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, windows: windows, client: ac} | 255 | + | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac} |
| 256 | - | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac}) | 256 | + | ac.setSession(sess) |
| 257 | + | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}) | |||
| 257 | d.sessions[sess.id] = sess | 258 | d.sessions[sess.id] = sess | ||
| 258 | t.Cleanup(cancel) | 259 | t.Cleanup(cancel) | ||
| 259 | return d, sess, ac, sends | 260 | return d, sess, ac, sends | ||
| @@ -265,11 +266,12 @@ func TestPTYWriteErrorIsLogged(t *testing.T) { | |||||
| 265 | errBoom := errors.New("boom") | 266 | errBoom := errors.New("boom") | ||
| 266 | p := portsmocks.NewMockPTY(t) | 267 | p := portsmocks.NewMockPTY(t) | ||
| 267 | p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once() | 268 | p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once() | ||
| 268 | - | win := &window{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}} | 269 | + | win := &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}} |
| 269 | - | sess := &session{id: "manual", name: "work", windows: []*window{win}} | 270 | + | sess := &session{id: "manual", name: "work", tabs: []*tab{win}} |
| 270 | ac := &attachedClient{} | 271 | ac := &attachedClient{} | ||
| 272 | + | ac.setSession(sess) | |||
| 271 | 273 | ||||
| 272 | - | daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("input")) | 274 | + | daemonKeyHandler{d: d, ac: ac}.Forward([]byte("input")) |
| 273 | 275 | ||||
| 274 | got := logs.String() | 276 | got := logs.String() | ||
| 275 | if !strings.Contains(got, "pty write failed") || !strings.Contains(got, "boom") || !strings.Contains(got, "work") { | 277 | if !strings.Contains(got, "pty write failed") || !strings.Contains(got, "boom") || !strings.Contains(got, "work") { | ||
| @@ -277,7 +279,7 @@ func TestPTYWriteErrorIsLogged(t *testing.T) { | |||||
| 277 | } | 279 | } | ||
| 278 | } | 280 | } | ||
| 279 | 281 | ||||
| 280 | - | func newManualWindowSession(t *testing.T, n int) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) { | 282 | + | func newManualTabSession(t *testing.T, n int) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) { |
| 281 | t.Helper() | 283 | t.Helper() | ||
| 282 | ptys := make([]ports.PTY, 0, n) | 284 | ptys := make([]ports.PTY, 0, n) | ||
| 283 | releases := make([]func(), 0, n) | 285 | releases := make([]func(), 0, n) | ||
| @@ -290,7 +292,7 @@ func newManualWindowSession(t *testing.T, n int) (*Daemon, *session, *attachedCl | |||||
| 290 | return d, sess, ac, sends, releases | 292 | return d, sess, ac, sends, releases | ||
| 291 | } | 293 | } | ||
| 292 | 294 | ||||
| 293 | - | func activeWindowIndex(sess *session) int { | 295 | + | func activeTabIndex(sess *session) int { |
| 294 | sess.mu.Lock() | 296 | sess.mu.Lock() | ||
| 295 | defer sess.mu.Unlock() | 297 | defer sess.mu.Unlock() | ||
| 296 | return sess.active | 298 | return sess.active | ||
| @@ -308,8 +310,8 @@ func TestCopyModeAltUInterceptsAndDoesNotForward(t *testing.T) { | |||||
| 308 | writes := make(chan []byte, 1) | 310 | writes := make(chan []byte, 1) | ||
| 309 | p, _ := newBlockingPTYWithWrites(t, writes) | 311 | p, _ := newBlockingPTYWithWrites(t, writes) | ||
| 310 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 312 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 311 | - | sess.windows[0].scrollback = scopy.NewScrollback(4) | 313 | + | sess.tabs[0].scrollback = scopy.NewScrollback(4) |
| 312 | - | sess.windows[0].screen.Write([]byte("live")) | 314 | + | sess.tabs[0].screen.Write([]byte("live")) |
| 313 | 315 | ||||
| 314 | d.handleInput(sess, ac, []byte("\x1bu")) | 316 | d.handleInput(sess, ac, []byte("\x1bu")) | ||
| 315 | 317 | ||||
| @@ -333,10 +335,10 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |||||
| 333 | writes := make(chan []byte, 1) | 335 | writes := make(chan []byte, 1) | ||
| 334 | p, _ := newBlockingPTYWithWrites(t, writes) | 336 | p, _ := newBlockingPTYWithWrites(t, writes) | ||
| 335 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 337 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 336 | - | sess.windows[0].scrollback = scopy.NewScrollback(4) | 338 | + | sess.tabs[0].scrollback = scopy.NewScrollback(4) |
| 337 | - | sess.windows[0].scrollback.Append(testRow("old1 ")) | 339 | + | sess.tabs[0].scrollback.Append(testRow("old1 ")) |
| 338 | - | sess.windows[0].scrollback.Append(testRow("old2 ")) | 340 | + | sess.tabs[0].scrollback.Append(testRow("old2 ")) |
| 339 | - | sess.windows[0].screen.Write([]byte("live")) | 341 | + | sess.tabs[0].screen.Write([]byte("live")) |
| 340 | 342 | ||||
| 341 | d.enterCopyMode(sess, ac) | 343 | d.enterCopyMode(sess, ac) | ||
| 342 | awaitFrame(t, sends, ports.MsgOutput) | 344 | awaitFrame(t, sends, ports.MsgOutput) | ||
| @@ -400,7 +402,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||||
| 400 | if sess == nil { | 402 | if sess == nil { | ||
| 401 | return false | 403 | return false | ||
| 402 | } | 404 | } | ||
| 403 | - | win := sess.activeWindow() | 405 | + | win := sess.activeTab() |
| 404 | if win == nil { | 406 | if win == nil { | ||
| 405 | return false | 407 | return false | ||
| 406 | } | 408 | } | ||
| @@ -440,8 +442,8 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||||
| 440 | func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) { | 442 | func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) { | ||
| 441 | p, _ := newBlockingPTY(t) | 443 | p, _ := newBlockingPTY(t) | ||
| 442 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 444 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 443 | - | sess.windows[0].scrollback = scopy.NewScrollback(4) | 445 | + | sess.tabs[0].scrollback = scopy.NewScrollback(4) |
| 444 | - | sess.windows[0].screen.Write([]byte("live")) | 446 | + | sess.tabs[0].screen.Write([]byte("live")) |
| 445 | 447 | ||||
| 446 | d.enterCopyMode(sess, ac) | 448 | d.enterCopyMode(sess, ac) | ||
| 447 | awaitFrame(t, sends, ports.MsgOutput) | 449 | awaitFrame(t, sends, ports.MsgOutput) | ||
| @@ -473,10 +475,10 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||||
| 473 | t.Run(tc.name, func(t *testing.T) { | 475 | t.Run(tc.name, func(t *testing.T) { | ||
| 474 | p, _ := newBlockingPTY(t) | 476 | p, _ := newBlockingPTY(t) | ||
| 475 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 477 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 476 | - | sess.windows[0].scrollback = scopy.NewScrollback(4) | 478 | + | sess.tabs[0].scrollback = scopy.NewScrollback(4) |
| 477 | - | sess.windows[0].scrollback.Append(testRow("old1 ")) | 479 | + | sess.tabs[0].scrollback.Append(testRow("old1 ")) |
| 478 | - | sess.windows[0].scrollback.Append(testRow("old2 ")) | 480 | + | sess.tabs[0].scrollback.Append(testRow("old2 ")) |
| 479 | - | sess.windows[0].screen.Write([]byte("live")) | 481 | + | sess.tabs[0].screen.Write([]byte("live")) |
| 480 | 482 | ||||
| 481 | d.enterCopyMode(sess, ac) | 483 | d.enterCopyMode(sess, ac) | ||
| 482 | awaitFrame(t, sends, ports.MsgOutput) | 484 | awaitFrame(t, sends, ports.MsgOutput) | ||
| @@ -504,8 +506,8 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) { | |||||
| 504 | t.Run(tc.name, func(t *testing.T) { | 506 | t.Run(tc.name, func(t *testing.T) { | ||
| 505 | p, _ := newBlockingPTY(t) | 507 | p, _ := newBlockingPTY(t) | ||
| 506 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 508 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 507 | - | sess.windows[0].scrollback = scopy.NewScrollback(4) | 509 | + | sess.tabs[0].scrollback = scopy.NewScrollback(4) |
| 508 | - | sess.windows[0].screen.Write([]byte("live")) | 510 | + | sess.tabs[0].screen.Write([]byte("live")) |
| 509 | 511 | ||||
| 510 | d.enterCopyMode(sess, ac) | 512 | d.enterCopyMode(sess, ac) | ||
| 511 | awaitFrame(t, sends, ports.MsgOutput) | 513 | awaitFrame(t, sends, ports.MsgOutput) | ||
| @@ -530,8 +532,8 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) { | |||||
| 530 | func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) { | 532 | func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) { | ||
| 531 | p, _ := newBlockingPTY(t) | 533 | p, _ := newBlockingPTY(t) | ||
| 532 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 534 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 533 | - | sess.windows[0].scrollback = scopy.NewScrollback(4) | 535 | + | sess.tabs[0].scrollback = scopy.NewScrollback(4) |
| 534 | - | sess.windows[0].screen.Write([]byte("live")) | 536 | + | sess.tabs[0].screen.Write([]byte("live")) |
| 535 | 537 | ||||
| 536 | done := make(chan struct{}) | 538 | done := make(chan struct{}) | ||
| 537 | var drain sync.WaitGroup | 539 | var drain sync.WaitGroup | ||
| @@ -560,10 +562,10 @@ func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) { | |||||
| 560 | drain.Wait() | 562 | drain.Wait() | ||
| 561 | } | 563 | } | ||
| 562 | 564 | ||||
| 563 | - | func windowCount(sess *session) int { | 565 | + | func tabCount(sess *session) int { |
| 564 | sess.mu.Lock() | 566 | sess.mu.Lock() | ||
| 565 | defer sess.mu.Unlock() | 567 | defer sess.mu.Unlock() | ||
| 566 | - | return len(sess.windows) | 568 | + | return len(sess.tabs) |
| 567 | } | 569 | } | ||
| 568 | 570 | ||||
| 569 | // --- handshake -------------------------------------------------------------- | 571 | // --- handshake -------------------------------------------------------------- | ||
| @@ -671,7 +673,7 @@ func TestEphemeralNumberingReuse(t *testing.T) { | |||||
| 671 | 673 | ||||
| 672 | // --- attach replace --------------------------------------------------------- | 674 | // --- attach replace --------------------------------------------------------- | ||
| 673 | 675 | ||||
| 674 | - | func TestAltDigitSwitchesBetweenThreeWindows(t *testing.T) { | 676 | + | func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) { |
| 675 | writes1 := make(chan []byte, 1) | 677 | writes1 := make(chan []byte, 1) | ||
| 676 | writes2 := make(chan []byte, 1) | 678 | writes2 := make(chan []byte, 1) | ||
| 677 | writes3 := make(chan []byte, 1) | 679 | writes3 := make(chan []byte, 1) | ||
| @@ -698,7 +700,7 @@ func TestAltDigitSwitchesBetweenThreeWindows(t *testing.T) { | |||||
| 698 | 700 | ||||
| 699 | require.Eventually(t, func() bool { | 701 | require.Eventually(t, func() bool { | ||
| 700 | sessions := listSessions(t, d) | 702 | sessions := listSessions(t, d) | ||
| 701 | - | return len(sessions.Sessions) == 1 && sessions.Sessions[0].Windows == 3 | 703 | + | return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3 |
| 702 | }, 2*time.Second, 5*time.Millisecond) | 704 | }, 2*time.Second, 5*time.Millisecond) | ||
| 703 | require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond) | 705 | require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond) | ||
| 704 | require.Equal(t, []byte("A"), <-writes1) | 706 | require.Equal(t, []byte("A"), <-writes1) | ||
| @@ -715,7 +717,7 @@ func TestAltDigitSwitchesBetweenThreeWindows(t *testing.T) { | |||||
| 715 | d.sessWg.Wait() | 717 | d.sessWg.Wait() | ||
| 716 | } | 718 | } | ||
| 717 | 719 | ||||
| 718 | - | func TestAltCCreatesSecondActiveWindow(t *testing.T) { | 720 | + | func TestAltCCreatesSecondActiveTab(t *testing.T) { |
| 719 | p1, releasePTY1 := newBlockingPTY(t) | 721 | p1, releasePTY1 := newBlockingPTY(t) | ||
| 720 | p2, releasePTY2 := newBlockingPTY(t) | 722 | p2, releasePTY2 := newBlockingPTY(t) | ||
| 721 | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | 723 | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | ||
| @@ -731,7 +733,7 @@ func TestAltCCreatesSecondActiveWindow(t *testing.T) { | |||||
| 731 | 733 | ||||
| 732 | require.Eventually(t, func() bool { | 734 | require.Eventually(t, func() bool { | ||
| 733 | sessions := listSessions(t, d) | 735 | sessions := listSessions(t, d) | ||
| 734 | - | return len(sessions.Sessions) == 1 && sessions.Sessions[0].Windows == 2 | 736 | + | return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 2 |
| 735 | }, 2*time.Second, 5*time.Millisecond) | 737 | }, 2*time.Second, 5*time.Millisecond) | ||
| 736 | 738 | ||||
| 737 | releaseConn() | 739 | releaseConn() | ||
| @@ -741,7 +743,7 @@ func TestAltCCreatesSecondActiveWindow(t *testing.T) { | |||||
| 741 | d.sessWg.Wait() | 743 | d.sessWg.Wait() | ||
| 742 | } | 744 | } | ||
| 743 | 745 | ||||
| 744 | - | func TestAltNextPreviousSwitchActiveWindow(t *testing.T) { | 746 | + | func TestAltNextPreviousSwitchActiveTab(t *testing.T) { |
| 745 | cases := []struct { | 747 | cases := []struct { | ||
| 746 | name string | 748 | name string | ||
| 747 | start int | 749 | start int | ||
| @@ -756,7 +758,7 @@ func TestAltNextPreviousSwitchActiveWindow(t *testing.T) { | |||||
| 756 | 758 | ||||
| 757 | for _, tc := range cases { | 759 | for _, tc := range cases { | ||
| 758 | t.Run(tc.name, func(t *testing.T) { | 760 | t.Run(tc.name, func(t *testing.T) { | ||
| 759 | - | d, sess, ac, _, releases := newManualWindowSession(t, 3) | 761 | + | d, sess, ac, _, releases := newManualTabSession(t, 3) |
| 760 | defer func() { | 762 | defer func() { | ||
| 761 | for _, release := range releases { | 763 | for _, release := range releases { | ||
| 762 | release() | 764 | release() | ||
| @@ -766,12 +768,12 @@ func TestAltNextPreviousSwitchActiveWindow(t *testing.T) { | |||||
| 766 | 768 | ||||
| 767 | d.handleInput(sess, ac, tc.input) | 769 | d.handleInput(sess, ac, tc.input) | ||
| 768 | 770 | ||||
| 769 | - | require.Equal(t, tc.wantIndex, activeWindowIndex(sess)) | 771 | + | require.Equal(t, tc.wantIndex, activeTabIndex(sess)) |
| 770 | }) | 772 | }) | ||
| 771 | } | 773 | } | ||
| 772 | } | 774 | } | ||
| 773 | 775 | ||||
| 774 | - | func TestAltXClosesActiveWindowAndSelectsRemaining(t *testing.T) { | 776 | + | func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) { |
| 775 | writes := make(chan []byte, 1) | 777 | writes := make(chan []byte, 1) | ||
| 776 | p1, releasePTY1 := newBlockingPTY(t) | 778 | p1, releasePTY1 := newBlockingPTY(t) | ||
| 777 | p2, releasePTY2 := newBlockingPTY(t) | 779 | p2, releasePTY2 := newBlockingPTY(t) | ||
| @@ -782,8 +784,8 @@ func TestAltXClosesActiveWindowAndSelectsRemaining(t *testing.T) { | |||||
| 782 | d.handleInput(sess, ac, []byte("\x1bx")) | 784 | d.handleInput(sess, ac, []byte("\x1bx")) | ||
| 783 | 785 | ||||
| 784 | require.Equal(t, 1, sessionCount(d)) | 786 | require.Equal(t, 1, sessionCount(d)) | ||
| 785 | - | require.Len(t, sess.windows, 2) | 787 | + | require.Len(t, sess.tabs, 2) |
| 786 | - | require.Equal(t, 1, activeWindowIndex(sess), "closing middle window selects the next remaining window") | 788 | + | require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab") |
| 787 | d.handleInput(sess, ac, []byte("Z")) | 789 | d.handleInput(sess, ac, []byte("Z")) | ||
| 788 | require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond) | 790 | require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond) | ||
| 789 | require.Equal(t, []byte("Z"), <-writes) | 791 | require.Equal(t, []byte("Z"), <-writes) | ||
| @@ -802,7 +804,7 @@ func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} { | |||||
| 802 | return done | 804 | return done | ||
| 803 | } | 805 | } | ||
| 804 | 806 | ||||
| 805 | - | func TestAltXClosesNonFinalWindowScheduler(t *testing.T) { | 807 | + | func TestAltXClosesNonFinalTabScheduler(t *testing.T) { |
| 806 | p1, releasePTY1 := newBlockingPTY(t) | 808 | p1, releasePTY1 := newBlockingPTY(t) | ||
| 807 | p2, releasePTY2 := newBlockingPTY(t) | 809 | p2, releasePTY2 := newBlockingPTY(t) | ||
| 808 | clk := &signalClock{called: make(chan struct{})} | 810 | clk := &signalClock{called: make(chan struct{})} | ||
| @@ -812,8 +814,8 @@ func TestAltXClosesNonFinalWindowScheduler(t *testing.T) { | |||||
| 812 | defer releasePTY2() | 814 | defer releasePTY2() | ||
| 813 | 815 | ||||
| 814 | d.sessWg.Add(1) | 816 | d.sessWg.Add(1) | ||
| 815 | - | go d.scheduler(sess, sess.windows[1]) | 817 | + | go d.scheduler(sess, sess.tabs[1]) |
| 816 | - | sess.windows[1].dirty <- struct{}{} | 818 | + | sess.tabs[1].dirty <- struct{}{} |
| 817 | <-clk.called | 819 | <-clk.called | ||
| 818 | 820 | ||||
| 819 | d.handleInput(sess, ac, []byte("\x1b2")) | 821 | d.handleInput(sess, ac, []byte("\x1b2")) | ||
| @@ -822,12 +824,12 @@ func TestAltXClosesNonFinalWindowScheduler(t *testing.T) { | |||||
| 822 | select { | 824 | select { | ||
| 823 | case <-waitGroupDone(&d.sessWg): | 825 | case <-waitGroupDone(&d.sessWg): | ||
| 824 | case <-time.After(2 * time.Second): | 826 | case <-time.After(2 * time.Second): | ||
| 825 | - | t.Fatal("scheduler for removed window did not exit") | 827 | + | t.Fatal("scheduler for removed tab did not exit") |
| 826 | } | 828 | } | ||
| 827 | require.Equal(t, 1, sessionCount(d)) | 829 | require.Equal(t, 1, sessionCount(d)) | ||
| 828 | } | 830 | } | ||
| 829 | 831 | ||||
| 830 | - | func TestPTYEOFClosesNonFinalWindowScheduler(t *testing.T) { | 832 | + | func TestPTYEOFClosesNonFinalTabScheduler(t *testing.T) { |
| 831 | p1, releasePTY1 := newBlockingPTY(t) | 833 | p1, releasePTY1 := newBlockingPTY(t) | ||
| 832 | p2, releasePTY2 := newBlockingPTY(t) | 834 | p2, releasePTY2 := newBlockingPTY(t) | ||
| 833 | clk := &signalClock{called: make(chan struct{})} | 835 | clk := &signalClock{called: make(chan struct{})} | ||
| @@ -837,22 +839,22 @@ func TestPTYEOFClosesNonFinalWindowScheduler(t *testing.T) { | |||||
| 837 | defer releasePTY2() | 839 | defer releasePTY2() | ||
| 838 | 840 | ||||
| 839 | d.sessWg.Add(2) | 841 | d.sessWg.Add(2) | ||
| 840 | - | go d.scheduler(sess, sess.windows[1]) | 842 | + | go d.scheduler(sess, sess.tabs[1]) |
| 841 | - | go d.ptyReader(sess, sess.windows[1]) | 843 | + | go d.ptyReader(sess, sess.tabs[1]) |
| 842 | - | sess.windows[1].dirty <- struct{}{} | 844 | + | sess.tabs[1].dirty <- struct{}{} |
| 843 | <-clk.called | 845 | <-clk.called | ||
| 844 | releasePTY2() | 846 | releasePTY2() | ||
| 845 | 847 | ||||
| 846 | select { | 848 | select { | ||
| 847 | case <-waitGroupDone(&d.sessWg): | 849 | case <-waitGroupDone(&d.sessWg): | ||
| 848 | case <-time.After(2 * time.Second): | 850 | case <-time.After(2 * time.Second): | ||
| 849 | - | t.Fatal("scheduler for EOF-removed window did not exit") | 851 | + | t.Fatal("scheduler for EOF-removed tab did not exit") |
| 850 | } | 852 | } | ||
| 851 | require.Equal(t, 1, sessionCount(d)) | 853 | require.Equal(t, 1, sessionCount(d)) | ||
| 852 | } | 854 | } | ||
| 853 | 855 | ||||
| 854 | - | func TestAltXClosesFinalWindowAndDetaches(t *testing.T) { | 856 | + | func TestAltXClosesFinalTabAndDetaches(t *testing.T) { |
| 855 | - | d, sess, ac, sends, releases := newManualWindowSession(t, 1) | 857 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) |
| 856 | defer releases[0]() | 858 | defer releases[0]() | ||
| 857 | 859 | ||||
| 858 | d.handleInput(sess, ac, []byte("\x1bx")) | 860 | d.handleInput(sess, ac, []byte("\x1bx")) | ||
| @@ -864,21 +866,21 @@ func TestAltXClosesFinalWindowAndDetaches(t *testing.T) { | |||||
| 864 | require.Equal(t, ports.ReasonSessionKilled, det.Reason) | 866 | require.Equal(t, ports.ReasonSessionKilled, det.Reason) | ||
| 865 | } | 867 | } | ||
| 866 | 868 | ||||
| 867 | - | func TestPTYEOFClosesActiveNonFinalWindowAndRepaintsRemaining(t *testing.T) { | 869 | + | func TestPTYEOFClosesActiveNonFinalTabAndRepaintsRemaining(t *testing.T) { |
| 868 | p1, releasePTY1 := newBlockingPTY(t) | 870 | p1, releasePTY1 := newBlockingPTY(t) | ||
| 869 | p2, releasePTY2 := newBlockingPTY(t) | 871 | p2, releasePTY2 := newBlockingPTY(t) | ||
| 870 | d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2) | 872 | d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2) | ||
| 871 | defer releasePTY2() | 873 | defer releasePTY2() | ||
| 872 | sess.active = 0 | 874 | sess.active = 0 | ||
| 873 | - | sess.windows[1].screen.Write([]byte("remaining")) | 875 | + | sess.tabs[1].screen.Write([]byte("remaining")) |
| 874 | 876 | ||||
| 875 | d.sessWg.Add(1) | 877 | d.sessWg.Add(1) | ||
| 876 | - | go d.ptyReader(sess, sess.windows[0]) | 878 | + | go d.ptyReader(sess, sess.tabs[0]) |
| 877 | releasePTY1() | 879 | releasePTY1() | ||
| 878 | 880 | ||||
| 879 | - | require.Eventually(t, func() bool { return windowCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond) | 881 | + | require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond) |
| 880 | require.Equal(t, 1, sessionCount(d)) | 882 | require.Equal(t, 1, sessionCount(d)) | ||
| 881 | - | require.Equal(t, 0, activeWindowIndex(sess)) | 883 | + | require.Equal(t, 0, activeTabIndex(sess)) |
| 882 | f := awaitFrame(t, sends, ports.MsgOutput) | 884 | f := awaitFrame(t, sends, ports.MsgOutput) | ||
| 883 | out, err := ports.UnmarshalOutput(f.Payload) | 885 | out, err := ports.UnmarshalOutput(f.Payload) | ||
| 884 | require.NoError(t, err) | 886 | require.NoError(t, err) | ||
| @@ -890,21 +892,21 @@ func TestPTYEOFClosesActiveNonFinalWindowAndRepaintsRemaining(t *testing.T) { | |||||
| 890 | d.sessWg.Wait() | 892 | d.sessWg.Wait() | ||
| 891 | } | 893 | } | ||
| 892 | 894 | ||||
| 893 | - | func TestPTYEOFClosesInactiveNonFinalWindowAndRepaintsStatus(t *testing.T) { | 895 | + | func TestPTYEOFClosesInactiveNonFinalTabAndRepaintsStatus(t *testing.T) { |
| 894 | p1, releasePTY1 := newBlockingPTY(t) | 896 | p1, releasePTY1 := newBlockingPTY(t) | ||
| 895 | p2, releasePTY2 := newBlockingPTY(t) | 897 | p2, releasePTY2 := newBlockingPTY(t) | ||
| 896 | d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2) | 898 | d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2) | ||
| 897 | defer releasePTY1() | 899 | defer releasePTY1() | ||
| 898 | sess.active = 0 | 900 | sess.active = 0 | ||
| 899 | - | sess.windows[0].screen.Write([]byte("active")) | 901 | + | sess.tabs[0].screen.Write([]byte("active")) |
| 900 | 902 | ||||
| 901 | d.sessWg.Add(1) | 903 | d.sessWg.Add(1) | ||
| 902 | - | go d.ptyReader(sess, sess.windows[1]) | 904 | + | go d.ptyReader(sess, sess.tabs[1]) |
| 903 | releasePTY2() | 905 | releasePTY2() | ||
| 904 | 906 | ||||
| 905 | - | require.Eventually(t, func() bool { return windowCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond) | 907 | + | require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond) |
| 906 | require.Equal(t, 1, sessionCount(d)) | 908 | require.Equal(t, 1, sessionCount(d)) | ||
| 907 | - | require.Equal(t, 0, activeWindowIndex(sess)) | 909 | + | require.Equal(t, 0, activeTabIndex(sess)) |
| 908 | f := awaitFrame(t, sends, ports.MsgOutput) | 910 | f := awaitFrame(t, sends, ports.MsgOutput) | ||
| 909 | out, err := ports.UnmarshalOutput(f.Payload) | 911 | out, err := ports.UnmarshalOutput(f.Payload) | ||
| 910 | require.NoError(t, err) | 912 | require.NoError(t, err) | ||
| @@ -916,11 +918,11 @@ func TestPTYEOFClosesInactiveNonFinalWindowAndRepaintsStatus(t *testing.T) { | |||||
| 916 | d.sessWg.Wait() | 918 | d.sessWg.Wait() | ||
| 917 | } | 919 | } | ||
| 918 | 920 | ||||
| 919 | - | func TestPTYEOFFinalWindowKillsSessionAndDetaches(t *testing.T) { | 921 | + | func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) { |
| 920 | - | d, sess, _, sends, releases := newManualWindowSession(t, 1) | 922 | + | d, sess, _, sends, releases := newManualTabSession(t, 1) |
| 921 | 923 | ||||
| 922 | d.sessWg.Add(1) | 924 | d.sessWg.Add(1) | ||
| 923 | - | go d.ptyReader(sess, sess.windows[0]) | 925 | + | go d.ptyReader(sess, sess.tabs[0]) |
| 924 | releases[0]() | 926 | releases[0]() | ||
| 925 | 927 | ||||
| 926 | require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond) | 928 | require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond) | ||
| @@ -933,7 +935,7 @@ func TestPTYEOFFinalWindowKillsSessionAndDetaches(t *testing.T) { | |||||
| 933 | } | 935 | } | ||
| 934 | 936 | ||||
| 935 | func TestAltDDetachesCurrentClient(t *testing.T) { | 937 | func TestAltDDetachesCurrentClient(t *testing.T) { | ||
| 936 | - | d, sess, ac, sends, releases := newManualWindowSession(t, 1) | 938 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) |
| 937 | defer releases[0]() | 939 | defer releases[0]() | ||
| 938 | 940 | ||||
| 939 | d.handleInput(sess, ac, []byte("\x1bd")) | 941 | d.handleInput(sess, ac, []byte("\x1bd")) | ||
| @@ -946,7 +948,7 @@ func TestAltDDetachesCurrentClient(t *testing.T) { | |||||
| 946 | } | 948 | } | ||
| 947 | 949 | ||||
| 948 | func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) { | 950 | func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) { | ||
| 949 | - | d, sess, ac, sends, releases := newManualWindowSession(t, 1) | 951 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) |
| 950 | defer releases[0]() | 952 | defer releases[0]() | ||
| 951 | sess.ephemeral = true | 953 | sess.ephemeral = true | ||
| 952 | sess.name = "0" | 954 | sess.name = "0" | ||
| @@ -958,6 +960,217 @@ func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) { | |||||
| 958 | awaitFrame(t, sends, ports.MsgOutput) | 960 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 959 | } | 961 | } | ||
| 960 | 962 | ||||
| 963 | + | func TestAltTOpensPickerModalAndDoesNotForward(t *testing.T) { | |||
| 964 | + | writes := make(chan []byte, 1) | |||
| 965 | + | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |||
| 966 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 967 | + | defer releasePTY() | |||
| 968 | + | ||||
| 969 | + | d.handleInput(sess, ac, []byte("\x1bt")) | |||
| 970 | + | ||||
| 971 | + | require.True(t, ac.pickerActive()) | |||
| 972 | + | select { | |||
| 973 | + | case got := <-writes: | |||
| 974 | + | t.Fatalf("picker binding forwarded to PTY: %q", got) | |||
| 975 | + | default: | |||
| 976 | + | } | |||
| 977 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 978 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 979 | + | require.NoError(t, err) | |||
| 980 | + | data := string(msg.Data) | |||
| 981 | + | require.Contains(t, data, "┌") | |||
| 982 | + | require.Contains(t, data, "Sessions") | |||
| 983 | + | } | |||
| 984 | + | ||||
| 985 | + | func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | |||
| 986 | + | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |||
| 987 | + | defer func() { | |||
| 988 | + | for _, release := range releases { | |||
| 989 | + | release() | |||
| 990 | + | } | |||
| 991 | + | }() | |||
| 992 | + | ||||
| 993 | + | d.handleInput(sess, ac, []byte("\x1bt")) | |||
| 994 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 995 | + | d.handleInput(sess, ac, []byte("j")) | |||
| 996 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 997 | + | d.handleInput(sess, ac, []byte("\r")) | |||
| 998 | + | ||||
| 999 | + | require.Equal(t, 1, activeTabIndex(sess)) | |||
| 1000 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1001 | + | d.handleInput(sess, ac, []byte("\x1bt")) | |||
| 1002 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1003 | + | d.handleInput(sess, ac, []byte("\x1b")) | |||
| 1004 | + | require.False(t, ac.pickerActive()) | |||
| 1005 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1006 | + | } | |||
| 1007 | + | ||||
| 1008 | + | func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |||
| 1009 | + | p1, releasePTY1 := newBlockingPTY(t) | |||
| 1010 | + | p2, releasePTY2 := newBlockingPTY(t) | |||
| 1011 | + | defer releasePTY1() | |||
| 1012 | + | defer releasePTY2() | |||
| 1013 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 1014 | + | tr1, sends1 := newCapturingTransport(t) | |||
| 1015 | + | tr2, sends2 := newCapturingTransport(t) | |||
| 1016 | + | ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 1017 | + | ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 1018 | + | sctx1, cancel1 := context.WithCancel(d.serveCtx) | |||
| 1019 | + | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |||
| 1020 | + | defer cancel1() | |||
| 1021 | + | defer cancel2() | |||
| 1022 | + | sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1} | |||
| 1023 | + | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2} | |||
| 1024 | + | ac1.setSession(sess1) | |||
| 1025 | + | ac2.setSession(sess2) | |||
| 1026 | + | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}) | |||
| 1027 | + | ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}) | |||
| 1028 | + | d.sessions[sess1.id] = sess1 | |||
| 1029 | + | d.sessions[sess2.id] = sess2 | |||
| 1030 | + | ||||
| 1031 | + | d.handleInput(sess1, ac1, []byte("\x1bt")) | |||
| 1032 | + | awaitFrame(t, sends1, ports.MsgOutput) | |||
| 1033 | + | d.handleInput(sess1, ac1, []byte("j")) | |||
| 1034 | + | awaitFrame(t, sends1, ports.MsgOutput) | |||
| 1035 | + | d.handleInput(sess1, ac1, []byte("\r")) | |||
| 1036 | + | ||||
| 1037 | + | require.Same(t, sess2, ac1.currentSession()) | |||
| 1038 | + | require.Same(t, ac1, sess2.client) | |||
| 1039 | + | require.Nil(t, sess1.client) | |||
| 1040 | + | require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch") | |||
| 1041 | + | det := awaitFrame(t, sends2, ports.MsgDetached) | |||
| 1042 | + | dm, err := ports.UnmarshalDetached(det.Payload) | |||
| 1043 | + | require.NoError(t, err) | |||
| 1044 | + | require.Equal(t, ports.ReasonDetach, dm.Reason) | |||
| 1045 | + | awaitFrame(t, sends1, ports.MsgOutput) | |||
| 1046 | + | } | |||
| 1047 | + | ||||
| 1048 | + | func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) { | |||
| 1049 | + | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |||
| 1050 | + | defer func() { | |||
| 1051 | + | for _, release := range releases { | |||
| 1052 | + | release() | |||
| 1053 | + | } | |||
| 1054 | + | }() | |||
| 1055 | + | ||||
| 1056 | + | d.handleInput(sess, ac, []byte("\x1bt")) | |||
| 1057 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1058 | + | d.handleInput(sess, ac, []byte("j")) | |||
| 1059 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1060 | + | ||||
| 1061 | + | previewTab := sess.tabs[1] | |||
| 1062 | + | previewTab.mu.Lock() | |||
| 1063 | + | previewTab.screen.Write([]byte("inactive-preview-live")) | |||
| 1064 | + | previewTab.mu.Unlock() | |||
| 1065 | + | d.render(sess, previewTab) | |||
| 1066 | + | ||||
| 1067 | + | previewOut := awaitFrame(t, sends, ports.MsgOutput) | |||
| 1068 | + | previewMsg, err := ports.UnmarshalOutput(previewOut.Payload) | |||
| 1069 | + | require.NoError(t, err) | |||
| 1070 | + | require.Contains(t, string(previewMsg.Data), "inactive-preview-live") | |||
| 1071 | + | } | |||
| 1072 | + | ||||
| 1073 | + | func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) { | |||
| 1074 | + | p1, releasePTY1 := newBlockingPTY(t) | |||
| 1075 | + | p2, releasePTY2 := newBlockingPTY(t) | |||
| 1076 | + | defer releasePTY1() | |||
| 1077 | + | defer releasePTY2() | |||
| 1078 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 1079 | + | tr1, sends1 := newCapturingTransport(t) | |||
| 1080 | + | tr2, _ := newCapturingTransport(t) | |||
| 1081 | + | ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 1082 | + | ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 1083 | + | sctx1, cancel1 := context.WithCancel(d.serveCtx) | |||
| 1084 | + | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |||
| 1085 | + | defer cancel1() | |||
| 1086 | + | defer cancel2() | |||
| 1087 | + | sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1} | |||
| 1088 | + | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2} | |||
| 1089 | + | ac1.setSession(sess1) | |||
| 1090 | + | ac2.setSession(sess2) | |||
| 1091 | + | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}) | |||
| 1092 | + | ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}) | |||
| 1093 | + | d.sessions[sess1.id] = sess1 | |||
| 1094 | + | d.sessions[sess2.id] = sess2 | |||
| 1095 | + | ||||
| 1096 | + | d.handleInput(sess1, ac1, []byte("\x1bt")) | |||
| 1097 | + | awaitFrame(t, sends1, ports.MsgOutput) | |||
| 1098 | + | d.handleInput(sess1, ac1, []byte("j")) | |||
| 1099 | + | awaitFrame(t, sends1, ports.MsgOutput) | |||
| 1100 | + | ||||
| 1101 | + | previewTab := sess2.tabs[0] | |||
| 1102 | + | previewTab.mu.Lock() | |||
| 1103 | + | previewTab.screen.Write([]byte("cross-session-preview-live")) | |||
| 1104 | + | previewTab.mu.Unlock() | |||
| 1105 | + | d.render(sess2, previewTab) | |||
| 1106 | + | ||||
| 1107 | + | previewOut := awaitFrame(t, sends1, ports.MsgOutput) | |||
| 1108 | + | previewMsg, err := ports.UnmarshalOutput(previewOut.Payload) | |||
| 1109 | + | require.NoError(t, err) | |||
| 1110 | + | require.Contains(t, string(previewMsg.Data), "cross-session-preview-live") | |||
| 1111 | + | } | |||
| 1112 | + | ||||
| 1113 | + | func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) { | |||
| 1114 | + | d, sess, ac, sends, releases := newManualTabSession(t, 3) | |||
| 1115 | + | defer func() { | |||
| 1116 | + | for _, release := range releases { | |||
| 1117 | + | release() | |||
| 1118 | + | } | |||
| 1119 | + | }() | |||
| 1120 | + | ||||
| 1121 | + | done := make(chan struct{}) | |||
| 1122 | + | var drain sync.WaitGroup | |||
| 1123 | + | drain.Go(func() { | |||
| 1124 | + | for { | |||
| 1125 | + | select { | |||
| 1126 | + | case <-sends: | |||
| 1127 | + | case <-done: | |||
| 1128 | + | return | |||
| 1129 | + | } | |||
| 1130 | + | } | |||
| 1131 | + | }) | |||
| 1132 | + | ||||
| 1133 | + | var wg sync.WaitGroup | |||
| 1134 | + | for i := range 100 { | |||
| 1135 | + | wg.Go(func() { | |||
| 1136 | + | d.enterPicker(sess, ac) | |||
| 1137 | + | }) | |||
| 1138 | + | wg.Go(func() { | |||
| 1139 | + | d.handlePickerInput(sess, ac, []byte("j")) | |||
| 1140 | + | d.handlePickerInput(sess, ac, []byte("k")) | |||
| 1141 | + | }) | |||
| 1142 | + | wg.Go(func() { | |||
| 1143 | + | d.closePicker(ac) | |||
| 1144 | + | }) | |||
| 1145 | + | tb := sess.tabs[i%len(sess.tabs)] | |||
| 1146 | + | wg.Go(func() { | |||
| 1147 | + | tb.mu.Lock() | |||
| 1148 | + | tb.screen.Write([]byte("render-race")) | |||
| 1149 | + | tb.mu.Unlock() | |||
| 1150 | + | d.render(sess, tb) | |||
| 1151 | + | }) | |||
| 1152 | + | } | |||
| 1153 | + | wg.Wait() | |||
| 1154 | + | close(done) | |||
| 1155 | + | drain.Wait() | |||
| 1156 | + | } | |||
| 1157 | + | ||||
| 1158 | + | func TestPickerResizeRecomposesModal(t *testing.T) { | |||
| 1159 | + | p, releasePTY := newBlockingPTY(t) | |||
| 1160 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 1161 | + | defer releasePTY() | |||
| 1162 | + | ||||
| 1163 | + | d.handleInput(sess, ac, []byte("\x1bt")) | |||
| 1164 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1165 | + | d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30}) | |||
| 1166 | + | ||||
| 1167 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 1168 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 1169 | + | require.NoError(t, err) | |||
| 1170 | + | require.Contains(t, string(msg.Data), "┌") | |||
| 1171 | + | require.Contains(t, string(msg.Data), "Sessions") | |||
| 1172 | + | } | |||
| 1173 | + | ||||
| 961 | func TestStatusCompositionGolden(t *testing.T) { | 1174 | func TestStatusCompositionGolden(t *testing.T) { | ||
| 962 | p1, releasePTY1 := newBlockingPTY(t) | 1175 | p1, releasePTY1 := newBlockingPTY(t) | ||
| 963 | p2, releasePTY2 := newBlockingPTY(t) | 1176 | p2, releasePTY2 := newBlockingPTY(t) | ||
| @@ -967,7 +1180,7 @@ func TestStatusCompositionGolden(t *testing.T) { | |||||
| 967 | sess.active = 1 | 1180 | sess.active = 1 | ||
| 968 | sess.name = "work" | 1181 | sess.name = "work" | ||
| 969 | 1182 | ||||
| 970 | - | win := sess.activeWindow() | 1183 | + | win := sess.activeTab() |
| 971 | win.screen = vt.NewScreen(12, 2) | 1184 | win.screen = vt.NewScreen(12, 2) | ||
| 972 | win.size = domain.Size{Cols: 12, Rows: 2} | 1185 | win.size = domain.Size{Cols: 12, Rows: 2} | ||
| 973 | win.screen.Write([]byte("hello")) | 1186 | win.screen.Write([]byte("hello")) | ||
| @@ -981,7 +1194,7 @@ func TestStatusCompositionGolden(t *testing.T) { | |||||
| 981 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | 1194 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | ||
| 982 | for i, c := range frame.Row(2) { | 1195 | for i, c := range frame.Row(2) { | ||
| 983 | if i >= len(" work 1 ") && i < len(" work 1 2 ") { | 1196 | if i >= len(" work 1 ") && i < len(" work 1 2 ") { | ||
| 984 | - | require.True(t, c.Style.Inverse, "active window segment cell %d should be inverse", i) | 1197 | + | require.True(t, c.Style.Inverse, "active tab segment cell %d should be inverse", i) |
| 985 | } | 1198 | } | ||
| 986 | } | 1199 | } | ||
| 987 | } | 1200 | } | ||
| @@ -1009,7 +1222,7 @@ func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) { | |||||
| 1009 | out, err := ports.UnmarshalOutput(f.Payload) | 1222 | out, err := ports.UnmarshalOutput(f.Payload) | ||
| 1010 | require.NoError(t, err) | 1223 | require.NoError(t, err) | ||
| 1011 | require.Contains(t, string(out.Data), "work") | 1224 | require.Contains(t, string(out.Data), "work") | ||
| 1012 | - | require.Contains(t, string(out.Data), ";7m", "active status window should be inverse-highlighted") | 1225 | + | require.Contains(t, string(out.Data), ";7m", "active status tab should be inverse-highlighted") |
| 1013 | } | 1226 | } | ||
| 1014 | 1227 | ||||
| 1015 | releaseConn() | 1228 | releaseConn() | ||
| @@ -1147,9 +1360,10 @@ func TestSchedulerDebounceCoalesces(t *testing.T) { | |||||
| 1147 | 1360 | ||||
| 1148 | d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil))) | 1361 | d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 1149 | sctx, cancel := context.WithCancel(context.Background()) | 1362 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 1150 | - | win := &window{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}} | 1363 | + | win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}} |
| 1151 | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | 1364 | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | ||
| 1152 | - | sess := &session{id: "s", name: "s", windows: []*window{win}, ctx: sctx, cancel: cancel, client: ac} | 1365 | + | sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} |
| 1366 | + | ac.setSession(sess) | |||
| 1153 | 1367 | ||||
| 1154 | win.mu.Lock() | 1368 | win.mu.Lock() | ||
| 1155 | win.screen.Write([]byte("hi")) | 1369 | win.screen.Write([]byte("hi")) | ||
| @@ -1176,7 +1390,7 @@ func TestSchedulerDebounceCoalesces(t *testing.T) { | |||||
| 1176 | 1390 | ||||
| 1177 | cancel() | 1391 | cancel() | ||
| 1178 | d.sessWg.Wait() | 1392 | d.sessWg.Wait() | ||
| 1179 | - | require.Equal(t, int32(1), outputs.Load(), "N dirties in one window must render exactly once") | 1393 | + | require.Equal(t, int32(1), outputs.Load(), "N dirties in one tab must render exactly once") |
| 1180 | } | 1394 | } | ||
| 1181 | 1395 | ||||
| 1182 | func TestSchedulerAdaptiveDebounceFloodAndIdle(t *testing.T) { | 1396 | func TestSchedulerAdaptiveDebounceFloodAndIdle(t *testing.T) { | ||
| @@ -1199,7 +1413,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||||
| 1199 | 1413 | ||||
| 1200 | p := portsmocks.NewMockPTY(t) | 1414 | p := portsmocks.NewMockPTY(t) | ||
| 1201 | var screenWidthAtResize int | 1415 | var screenWidthAtResize int | ||
| 1202 | - | win := &window{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}} | 1416 | + | win := &tab{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}} |
| 1203 | p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 29}).RunAndReturn(func(sz domain.Size) error { | 1417 | p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 29}).RunAndReturn(func(sz domain.Size) error { | ||
| 1204 | // The screen must not yet be resized when the PTY is: proves order. | 1418 | // The screen must not yet be resized when the PTY is: proves order. | ||
| 1205 | screenWidthAtResize = win.screen.Frame.Width | 1419 | screenWidthAtResize = win.screen.Frame.Width | ||
| @@ -1218,7 +1432,8 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||||
| 1218 | 1432 | ||||
| 1219 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | 1433 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 1220 | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | 1434 | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | ||
| 1221 | - | sess := &session{id: "s", name: "s", windows: []*window{win}, client: ac} | 1435 | + | sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac} |
| 1436 | + | ac.setSession(sess) | |||
| 1222 | 1437 | ||||
| 1223 | d.resize(sess, ac, newSize) | 1438 | d.resize(sess, ac, newSize) | ||
| 1224 | 1439 | ||||
| @@ -1509,10 +1724,11 @@ func TestSendErrorKillsEphemeral(t *testing.T) { | |||||
| 1509 | tr.EXPECT().Close().Return(nil).Maybe() | 1724 | tr.EXPECT().Close().Return(nil).Maybe() | ||
| 1510 | 1725 | ||||
| 1511 | d := newTestDaemon(t, nil, stubClock{}) | 1726 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 1512 | - | win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}} | 1727 | + | win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}} |
| 1513 | sctx, cancel := context.WithCancel(context.Background()) | 1728 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 1514 | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | 1729 | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | ||
| 1515 | - | sess := &session{id: "e", name: "0", ephemeral: true, windows: []*window{win}, ctx: sctx, cancel: cancel, client: ac} | 1730 | + | sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} |
| 1731 | + | ac.setSession(sess) | |||
| 1516 | d.sessions[sess.id] = sess | 1732 | d.sessions[sess.id] = sess | ||
| 1517 | 1733 | ||||
| 1518 | win.mu.Lock() | 1734 | win.mu.Lock() | ||
| @@ -1550,9 +1766,10 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) | |||||
| 1550 | 1766 | ||||
| 1551 | d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil))) | 1767 | d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 1552 | sctx, cancel := context.WithCancel(context.Background()) | 1768 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 1553 | - | win := &window{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | 1769 | + | win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} |
| 1554 | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | 1770 | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | ||
| 1555 | - | sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: ac} | 1771 | + | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} |
| 1772 | + | ac.setSession(sess) | |||
| 1556 | 1773 | ||||
| 1557 | win.mu.Lock() | 1774 | win.mu.Lock() | ||
| 1558 | win.screen.Write([]byte("before")) | 1775 | win.screen.Write([]byte("before")) | ||
| @@ -1609,8 +1826,10 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) { | |||||
| 1609 | 1826 | ||||
| 1610 | tr, sends := newCapturingTransport(t) | 1827 | tr, sends := newCapturingTransport(t) | ||
| 1611 | sctx, cancel := context.WithCancel(context.Background()) | 1828 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 1612 | - | win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | 1829 | + | win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} |
| 1613 | - | sess := &session{id: "query", name: "query", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}} | 1830 | + | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} |
| 1831 | + | sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |||
| 1832 | + | ac.setSession(sess) | |||
| 1614 | d.sessions[sess.id] = sess | 1833 | d.sessions[sess.id] = sess | ||
| 1615 | d.sessWg.Add(1) | 1834 | d.sessWg.Add(1) | ||
| 1616 | d.ptyReader(sess, win) | 1835 | d.ptyReader(sess, win) | ||
| @@ -1643,8 +1862,8 @@ func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testin | |||||
| 1643 | p.EXPECT().Close().Return(nil).Maybe() | 1862 | p.EXPECT().Close().Return(nil).Maybe() | ||
| 1644 | 1863 | ||||
| 1645 | sctx, cancel := context.WithCancel(context.Background()) | 1864 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 1646 | - | win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | 1865 | + | win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} |
| 1647 | - | sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel} | 1866 | + | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel} |
| 1648 | d.sessions[sess.id] = sess | 1867 | d.sessions[sess.id] = sess | ||
| 1649 | d.sessWg.Add(1) | 1868 | d.sessWg.Add(1) | ||
| 1650 | d.ptyReader(sess, win) | 1869 | d.ptyReader(sess, win) | ||
| @@ -1676,8 +1895,8 @@ func TestPTYReaderDefersDirtyDuringSynchronizedUpdateAndFlushesAtEnd(t *testing. | |||||
| 1676 | p.EXPECT().Close().Return(nil).Maybe() | 1895 | p.EXPECT().Close().Return(nil).Maybe() | ||
| 1677 | 1896 | ||||
| 1678 | sctx, cancel := context.WithCancel(context.Background()) | 1897 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 1679 | - | win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | 1898 | + | win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} |
| 1680 | - | sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel} | 1899 | + | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel} |
| 1681 | d.sessions[sess.id] = sess | 1900 | d.sessions[sess.id] = sess | ||
| 1682 | d.sessWg.Add(1) | 1901 | d.sessWg.Add(1) | ||
| 1683 | d.ptyReader(sess, win) | 1902 | d.ptyReader(sess, win) | ||
| @@ -1712,8 +1931,10 @@ func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) { | |||||
| 1712 | 1931 | ||||
| 1713 | tr, sends := newCapturingTransport(t) | 1932 | tr, sends := newCapturingTransport(t) | ||
| 1714 | sctx, cancel := context.WithCancel(context.Background()) | 1933 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 1715 | - | win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | 1934 | + | win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} |
| 1716 | - | sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}} | 1935 | + | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} |
| 1936 | + | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |||
| 1937 | + | ac.setSession(sess) | |||
| 1717 | d.sessions[sess.id] = sess | 1938 | d.sessions[sess.id] = sess | ||
| 1718 | d.sessWg.Add(2) | 1939 | d.sessWg.Add(2) | ||
| 1719 | go d.scheduler(sess, win) | 1940 | go d.scheduler(sess, win) | ||
| @@ -1754,8 +1975,10 @@ func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) { | |||||
| 1754 | 1975 | ||||
| 1755 | tr, sends := newCapturingTransport(t) | 1976 | tr, sends := newCapturingTransport(t) | ||
| 1756 | sctx, cancel := context.WithCancel(context.Background()) | 1977 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 1757 | - | win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} | 1978 | + | win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel} |
| 1758 | - | sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}} | 1979 | + | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} |
| 1980 | + | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |||
| 1981 | + | ac.setSession(sess) | |||
| 1759 | d.sessions[sess.id] = sess | 1982 | d.sessions[sess.id] = sess | ||
| 1760 | d.sessWg.Add(2) | 1983 | d.sessWg.Add(2) | ||
| 1761 | go d.scheduler(sess, win) | 1984 | go d.scheduler(sess, win) | ||
| @@ -1794,7 +2017,7 @@ func TestMouseWheelCopyEntryExitAndStreamOrder(t *testing.T) { | |||||
| 1794 | writes := make(chan []byte, 4) | 2017 | writes := make(chan []byte, 4) | ||
| 1795 | p, _ := newBlockingPTYWithWrites(t, writes) | 2018 | p, _ := newBlockingPTYWithWrites(t, writes) | ||
| 1796 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 2019 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 1797 | - | win := sess.windows[0] | 2020 | + | win := sess.tabs[0] |
| 1798 | win.screen.Write([]byte("live")) | 2021 | win.screen.Write([]byte("live")) | ||
| 1799 | 2022 | ||||
| 1800 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | 2023 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | ||
| @@ -1821,7 +2044,7 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) { | |||||
| 1821 | writes := make(chan []byte, 2) | 2044 | writes := make(chan []byte, 2) | ||
| 1822 | p, _ := newBlockingPTYWithWrites(t, writes) | 2045 | p, _ := newBlockingPTYWithWrites(t, writes) | ||
| 1823 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | 2046 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | ||
| 1824 | - | sess.windows[0].screen.Write([]byte("\x1b[?1049h")) | 2047 | + | sess.tabs[0].screen.Write([]byte("\x1b[?1049h")) |
| 1825 | 2048 | ||||
| 1826 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | 2049 | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | ||
| 1827 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | 2050 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | ||
| @@ -1843,7 +2066,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||||
| 1843 | default: | 2066 | default: | ||
| 1844 | } | 2067 | } | ||
| 1845 | 2068 | ||||
| 1846 | - | sess.windows[0].screen.Write([]byte("\x1b[?1000h")) | 2069 | + | sess.tabs[0].screen.Write([]byte("\x1b[?1000h")) |
| 1847 | raw := []byte("\x1b[<0;2;3M") | 2070 | raw := []byte("\x1b[<0;2;3M") | ||
| 1848 | d.handleInput(sess, ac, raw) | 2071 | d.handleInput(sess, ac, raw) | ||
| 1849 | select { | 2072 | select { | ||
| @@ -1852,7 +2075,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||||
| 1852 | default: | 2075 | default: | ||
| 1853 | } | 2076 | } | ||
| 1854 | 2077 | ||||
| 1855 | - | sess.windows[0].screen.Write([]byte("\x1b[?1006h")) | 2078 | + | sess.tabs[0].screen.Write([]byte("\x1b[?1006h")) |
| 1856 | d.handleInput(sess, ac, raw) | 2079 | d.handleInput(sess, ac, raw) | ||
| 1857 | require.Equal(t, raw, <-writes) | 2080 | require.Equal(t, raw, <-writes) | ||
| 1858 | 2081 | ||||
| @@ -1868,7 +2091,7 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) { | |||||
| 1868 | writes := make(chan []byte, 2) | 2091 | writes := make(chan []byte, 2) | ||
| 1869 | p, _ := newBlockingPTYWithWrites(t, writes) | 2092 | p, _ := newBlockingPTYWithWrites(t, writes) | ||
| 1870 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 2093 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 1871 | - | sess.windows[0].screen.Write([]byte("live")) | 2094 | + | sess.tabs[0].screen.Write([]byte("live")) |
| 1872 | 2095 | ||||
| 1873 | d.handleInput(sess, ac, []byte("\x1b[<64;")) | 2096 | d.handleInput(sess, ac, []byte("\x1b[<64;")) | ||
| 1874 | d.handleInput(sess, ac, []byte("1;1Mq")) | 2097 | d.handleInput(sess, ac, []byte("1;1Mq")) | ||
| @@ -1886,7 +2109,7 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) { | |||||
| 1886 | func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) { | 2109 | func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) { | ||
| 1887 | p, _ := newBlockingPTY(t) | 2110 | p, _ := newBlockingPTY(t) | ||
| 1888 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 2111 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 1889 | - | win := sess.windows[0] | 2112 | + | win := sess.tabs[0] |
| 1890 | win.screen.Write([]byte("A")) | 2113 | win.screen.Write([]byte("A")) | ||
| 1891 | 2114 | ||||
| 1892 | d.paint(sess, ac, true) | 2115 | d.paint(sess, ac, true) | ||
| @@ -1909,7 +2132,7 @@ func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) { | |||||
| 1909 | func TestCursorTailHidesInCopyMode(t *testing.T) { | 2132 | func TestCursorTailHidesInCopyMode(t *testing.T) { | ||
| 1910 | p, _ := newBlockingPTY(t) | 2133 | p, _ := newBlockingPTY(t) | ||
| 1911 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 2134 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 1912 | - | sess.windows[0].screen.Write([]byte("live")) | 2135 | + | sess.tabs[0].screen.Write([]byte("live")) |
| 1913 | 2136 | ||||
| 1914 | d.enterCopyMode(sess, ac) | 2137 | d.enterCopyMode(sess, ac) | ||
| 1915 | data := mustOutputData(t, sends) | 2138 | data := mustOutputData(t, sends) | ||
M internal/usecase/daemon/scrollback_test.go +3 -3
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | func TestNewWindowInitializesScrollback(t *testing.T) { | |
| 9 | + | func TestNewTabInitializesScrollback(t *testing.T) { | |
| 10 | 10 | tests := []struct { | |
| 11 | 11 | name string | |
| 12 | 12 | write []byte | |
| @@ -14,7 +14,7 @@ func TestNewWindowInitializesScrollback(t *testing.T) { | |||
| 14 | 14 | wantRow string | |
| 15 | 15 | }{ | |
| 16 | 16 | { | |
| 17 | - | name: "window owns scrollback wired to vt eviction callback", | |
| 17 | + | name: "tab owns scrollback wired to vt eviction callback", | |
| 18 | 18 | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"), | |
| 19 | 19 | wantLen: 1, | |
| 20 | 20 | wantRow: "AAAA", | |
| @@ -23,7 +23,7 @@ func TestNewWindowInitializesScrollback(t *testing.T) { | |||
| 23 | 23 | ||
| 24 | 24 | for _, tt := range tests { | |
| 25 | 25 | t.Run(tt.name, func(t *testing.T) { | |
| 26 | - | win := newWindow(nil, domain.Size{Cols: 4, Rows: 3}) | |
| 26 | + | win := newTab(nil, domain.Size{Cols: 4, Rows: 3}) | |
| 27 | 27 | if win.scrollback == nil { | |
| 28 | 28 | t.Fatal("scrollback is nil") | |
| 29 | 29 | } | |
| @@ -6,7 +6,7 @@ import ( | |||||
| 6 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | - | func TestNewWindowInitializesScrollback(t *testing.T) { | 9 | + | func TestNewTabInitializesScrollback(t *testing.T) { |
| 10 | tests := []struct { | 10 | tests := []struct { | ||
| 11 | name string | 11 | name string | ||
| 12 | write []byte | 12 | write []byte | ||
| @@ -14,7 +14,7 @@ func TestNewWindowInitializesScrollback(t *testing.T) { | |||||
| 14 | wantRow string | 14 | wantRow string | ||
| 15 | }{ | 15 | }{ | ||
| 16 | { | 16 | { | ||
| 17 | - | name: "window owns scrollback wired to vt eviction callback", | 17 | + | name: "tab owns scrollback wired to vt eviction callback", |
| 18 | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"), | 18 | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"), | ||
| 19 | wantLen: 1, | 19 | wantLen: 1, | ||
| 20 | wantRow: "AAAA", | 20 | wantRow: "AAAA", | ||
| @@ -23,7 +23,7 @@ func TestNewWindowInitializesScrollback(t *testing.T) { | |||||
| 23 | 23 | ||||
| 24 | for _, tt := range tests { | 24 | for _, tt := range tests { | ||
| 25 | t.Run(tt.name, func(t *testing.T) { | 25 | t.Run(tt.name, func(t *testing.T) { | ||
| 26 | - | win := newWindow(nil, domain.Size{Cols: 4, Rows: 3}) | 26 | + | win := newTab(nil, domain.Size{Cols: 4, Rows: 3}) |
| 27 | if win.scrollback == nil { | 27 | if win.scrollback == nil { | ||
| 28 | t.Fatal("scrollback is nil") | 28 | t.Fatal("scrollback is nil") | ||
| 29 | } | 29 | } | ||
M internal/usecase/keys/router.go +21 -18
| @@ -18,22 +18,23 @@ const ( | |||
| 18 | 18 | type Action int | |
| 19 | 19 | ||
| 20 | 20 | const ( | |
| 21 | - | ActionCreateWindow Action = iota | |
| 22 | - | ActionNextWindow | |
| 23 | - | ActionPreviousWindow | |
| 21 | + | ActionCreateTab Action = iota | |
| 22 | + | ActionNextTab | |
| 23 | + | ActionPreviousTab | |
| 24 | 24 | ActionDetach | |
| 25 | - | ActionCloseWindow | |
| 25 | + | ActionCloseTab | |
| 26 | 26 | ActionCopyMode | |
| 27 | 27 | ActionRenameSession | |
| 28 | - | ActionSwitchWindow1 | |
| 29 | - | ActionSwitchWindow2 | |
| 30 | - | ActionSwitchWindow3 | |
| 31 | - | ActionSwitchWindow4 | |
| 32 | - | ActionSwitchWindow5 | |
| 33 | - | ActionSwitchWindow6 | |
| 34 | - | ActionSwitchWindow7 | |
| 35 | - | ActionSwitchWindow8 | |
| 36 | - | ActionSwitchWindow9 | |
| 28 | + | ActionOpenPicker | |
| 29 | + | ActionSwitchTab1 | |
| 30 | + | ActionSwitchTab2 | |
| 31 | + | ActionSwitchTab3 | |
| 32 | + | ActionSwitchTab4 | |
| 33 | + | ActionSwitchTab5 | |
| 34 | + | ActionSwitchTab6 | |
| 35 | + | ActionSwitchTab7 | |
| 36 | + | ActionSwitchTab8 | |
| 37 | + | ActionSwitchTab9 | |
| 37 | 38 | ) | |
| 38 | 39 | ||
| 39 | 40 | // Handler receives router outputs. Forward is called only for bytes that should | |
| @@ -170,21 +171,23 @@ func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' } | |||
| 170 | 171 | func binding(b byte) (Action, bool) { | |
| 171 | 172 | switch b { | |
| 172 | 173 | case 'c': | |
| 173 | - | return ActionCreateWindow, true | |
| 174 | + | return ActionCreateTab, true | |
| 174 | 175 | case 'n': | |
| 175 | - | return ActionNextWindow, true | |
| 176 | + | return ActionNextTab, true | |
| 176 | 177 | case 'p': | |
| 177 | - | return ActionPreviousWindow, true | |
| 178 | + | return ActionPreviousTab, true | |
| 178 | 179 | case 'd': | |
| 179 | 180 | return ActionDetach, true | |
| 180 | 181 | case 'x': | |
| 181 | - | return ActionCloseWindow, true | |
| 182 | + | return ActionCloseTab, true | |
| 182 | 183 | case 'u': | |
| 183 | 184 | return ActionCopyMode, true | |
| 184 | 185 | case 'r': | |
| 185 | 186 | return ActionRenameSession, true | |
| 187 | + | case 't': | |
| 188 | + | return ActionOpenPicker, true | |
| 186 | 189 | case '1', '2', '3', '4', '5', '6', '7', '8', '9': | |
| 187 | - | return ActionSwitchWindow1 + Action(b-'1'), true | |
| 190 | + | return ActionSwitchTab1 + Action(b-'1'), true | |
| 188 | 191 | default: | |
| 189 | 192 | return 0, false | |
| 190 | 193 | } | |
| @@ -18,22 +18,23 @@ const ( | |||||
| 18 | type Action int | 18 | type Action int | ||
| 19 | 19 | ||||
| 20 | const ( | 20 | const ( | ||
| 21 | - | ActionCreateWindow Action = iota | 21 | + | ActionCreateTab Action = iota |
| 22 | - | ActionNextWindow | 22 | + | ActionNextTab |
| 23 | - | ActionPreviousWindow | 23 | + | ActionPreviousTab |
| 24 | ActionDetach | 24 | ActionDetach | ||
| 25 | - | ActionCloseWindow | 25 | + | ActionCloseTab |
| 26 | ActionCopyMode | 26 | ActionCopyMode | ||
| 27 | ActionRenameSession | 27 | ActionRenameSession | ||
| 28 | - | ActionSwitchWindow1 | 28 | + | ActionOpenPicker |
| 29 | - | ActionSwitchWindow2 | 29 | + | ActionSwitchTab1 |
| 30 | - | ActionSwitchWindow3 | 30 | + | ActionSwitchTab2 |
| 31 | - | ActionSwitchWindow4 | 31 | + | ActionSwitchTab3 |
| 32 | - | ActionSwitchWindow5 | 32 | + | ActionSwitchTab4 |
| 33 | - | ActionSwitchWindow6 | 33 | + | ActionSwitchTab5 |
| 34 | - | ActionSwitchWindow7 | 34 | + | ActionSwitchTab6 |
| 35 | - | ActionSwitchWindow8 | 35 | + | ActionSwitchTab7 |
| 36 | - | ActionSwitchWindow9 | 36 | + | ActionSwitchTab8 |
| 37 | + | ActionSwitchTab9 | |||
| 37 | ) | 38 | ) | ||
| 38 | 39 | ||||
| 39 | // Handler receives router outputs. Forward is called only for bytes that should | 40 | // Handler receives router outputs. Forward is called only for bytes that should | ||
| @@ -170,21 +171,23 @@ func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' } | |||||
| 170 | func binding(b byte) (Action, bool) { | 171 | func binding(b byte) (Action, bool) { | ||
| 171 | switch b { | 172 | switch b { | ||
| 172 | case 'c': | 173 | case 'c': | ||
| 173 | - | return ActionCreateWindow, true | 174 | + | return ActionCreateTab, true |
| 174 | case 'n': | 175 | case 'n': | ||
| 175 | - | return ActionNextWindow, true | 176 | + | return ActionNextTab, true |
| 176 | case 'p': | 177 | case 'p': | ||
| 177 | - | return ActionPreviousWindow, true | 178 | + | return ActionPreviousTab, true |
| 178 | case 'd': | 179 | case 'd': | ||
| 179 | return ActionDetach, true | 180 | return ActionDetach, true | ||
| 180 | case 'x': | 181 | case 'x': | ||
| 181 | - | return ActionCloseWindow, true | 182 | + | return ActionCloseTab, true |
| 182 | case 'u': | 183 | case 'u': | ||
| 183 | return ActionCopyMode, true | 184 | return ActionCopyMode, true | ||
| 184 | case 'r': | 185 | case 'r': | ||
| 185 | return ActionRenameSession, true | 186 | return ActionRenameSession, true | ||
| 187 | + | case 't': | |||
| 188 | + | return ActionOpenPicker, true | |||
| 186 | case '1', '2', '3', '4', '5', '6', '7', '8', '9': | 189 | case '1', '2', '3', '4', '5', '6', '7', '8', '9': | ||
| 187 | - | return ActionSwitchWindow1 + Action(b-'1'), true | 190 | + | return ActionSwitchTab1 + Action(b-'1'), true |
| 188 | default: | 191 | default: | ||
| 189 | return 0, false | 192 | return 0, false | ||
| 190 | } | 193 | } | ||
M internal/usecase/keys/router_test.go +35 -11
| @@ -51,13 +51,14 @@ func TestRouterInterceptsSameReadAltBindings(t *testing.T) { | |||
| 51 | 51 | in []byte | |
| 52 | 52 | want Action | |
| 53 | 53 | }{ | |
| 54 | - | {"Alt+c", []byte{ESC, 'c'}, ActionCreateWindow}, | |
| 55 | - | {"Alt+n", []byte{ESC, 'n'}, ActionNextWindow}, | |
| 56 | - | {"Alt+p", []byte{ESC, 'p'}, ActionPreviousWindow}, | |
| 54 | + | {"Alt+c", []byte{ESC, 'c'}, ActionCreateTab}, | |
| 55 | + | {"Alt+n", []byte{ESC, 'n'}, ActionNextTab}, | |
| 56 | + | {"Alt+p", []byte{ESC, 'p'}, ActionPreviousTab}, | |
| 57 | 57 | {"Alt+d", []byte{ESC, 'd'}, ActionDetach}, | |
| 58 | - | {"Alt+x", []byte{ESC, 'x'}, ActionCloseWindow}, | |
| 58 | + | {"Alt+x", []byte{ESC, 'x'}, ActionCloseTab}, | |
| 59 | 59 | {"Alt+u", []byte{ESC, 'u'}, ActionCopyMode}, | |
| 60 | 60 | {"Alt+r", []byte{ESC, 'r'}, ActionRenameSession}, | |
| 61 | + | {"Alt+t", []byte{ESC, 't'}, ActionOpenPicker}, | |
| 61 | 62 | } | |
| 62 | 63 | for _, tc := range cases { | |
| 63 | 64 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -79,9 +80,9 @@ func TestRouterInterceptsAltDigits(t *testing.T) { | |||
| 79 | 80 | r.Route([]byte{ESC, b}) | |
| 80 | 81 | } | |
| 81 | 82 | require.Equal(t, []Action{ | |
| 82 | - | ActionSwitchWindow1, ActionSwitchWindow2, ActionSwitchWindow3, | |
| 83 | - | ActionSwitchWindow4, ActionSwitchWindow5, ActionSwitchWindow6, | |
| 84 | - | ActionSwitchWindow7, ActionSwitchWindow8, ActionSwitchWindow9, | |
| 83 | + | ActionSwitchTab1, ActionSwitchTab2, ActionSwitchTab3, | |
| 84 | + | ActionSwitchTab4, ActionSwitchTab5, ActionSwitchTab6, | |
| 85 | + | ActionSwitchTab7, ActionSwitchTab8, ActionSwitchTab9, | |
| 85 | 86 | }, h.actions) | |
| 86 | 87 | require.Empty(t, h.forwards) | |
| 87 | 88 | } | |
| @@ -90,7 +91,7 @@ func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) { | |||
| 90 | 91 | clk := &fakeClock{} | |
| 91 | 92 | h := &captureHandler{} | |
| 92 | 93 | NewRouter(clk, h).Route([]byte{'a', ESC, 'c', 'b'}) | |
| 93 | - | require.Equal(t, []Action{ActionCreateWindow}, h.actions) | |
| 94 | + | require.Equal(t, []Action{ActionCreateTab}, h.actions) | |
| 94 | 95 | require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards) | |
| 95 | 96 | } | |
| 96 | 97 | ||
| @@ -117,10 +118,33 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) { | |||
| 117 | 118 | ||
| 118 | 119 | r.Route([]byte{'n'}) | |
| 119 | 120 | require.True(t, clk.timers[0].stopped) | |
| 120 | - | require.Equal(t, []Action{ActionNextWindow}, h.actions) | |
| 121 | + | require.Equal(t, []Action{ActionNextTab}, h.actions) | |
| 121 | 122 | require.Empty(t, h.forwards) | |
| 122 | 123 | } | |
| 123 | 124 | ||
| 125 | + | func TestRouterRetainsSplitESCAndInterceptsPickerBinding(t *testing.T) { | |
| 126 | + | clk := &fakeClock{} | |
| 127 | + | h := &captureHandler{} | |
| 128 | + | r := NewRouter(clk, h) | |
| 129 | + | r.Route([]byte{ESC}) | |
| 130 | + | require.Len(t, clk.timers, 1) | |
| 131 | + | require.Equal(t, ESCDelay, clk.timers[0].d) | |
| 132 | + | require.Empty(t, h.forwards) | |
| 133 | + | ||
| 134 | + | r.Route([]byte{'t'}) | |
| 135 | + | require.True(t, clk.timers[0].stopped) | |
| 136 | + | require.Equal(t, []Action{ActionOpenPicker}, h.actions) | |
| 137 | + | require.Empty(t, h.forwards) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestRouterForwardsBareT(t *testing.T) { | |
| 141 | + | clk := &fakeClock{} | |
| 142 | + | h := &captureHandler{} | |
| 143 | + | NewRouter(clk, h).Route([]byte{'t'}) | |
| 144 | + | require.Empty(t, h.actions) | |
| 145 | + | require.Equal(t, [][]byte{[]byte("t")}, h.forwards) | |
| 146 | + | } | |
| 147 | + | ||
| 124 | 148 | func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) { | |
| 125 | 149 | cases := []struct { | |
| 126 | 150 | name string | |
| @@ -159,7 +183,7 @@ func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) { | |||
| 159 | 183 | ||
| 160 | 184 | require.True(t, clk.timers[0].stopped) | |
| 161 | 185 | require.Equal(t, [][]byte{{ESC, '['}, []byte("A")}, h.forwards) | |
| 162 | - | require.Equal(t, []Action{ActionCreateWindow}, h.actions) | |
| 186 | + | require.Equal(t, []Action{ActionCreateTab}, h.actions) | |
| 163 | 187 | } | |
| 164 | 188 | ||
| 165 | 189 | func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) { | |
| @@ -174,7 +198,7 @@ func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) { | |||
| 174 | 198 | r.Route([]byte{'n'}) | |
| 175 | 199 | require.Eventually(t, func() bool { return retainESCWaiters() == before }, time.Second, time.Millisecond) | |
| 176 | 200 | require.True(t, clk.timers[0].stopped) | |
| 177 | - | require.Equal(t, []Action{ActionNextWindow}, h.actions) | |
| 201 | + | require.Equal(t, []Action{ActionNextTab}, h.actions) | |
| 178 | 202 | require.Empty(t, h.forwards) | |
| 179 | 203 | } | |
| 180 | 204 | ||
| @@ -51,13 +51,14 @@ func TestRouterInterceptsSameReadAltBindings(t *testing.T) { | |||||
| 51 | in []byte | 51 | in []byte | ||
| 52 | want Action | 52 | want Action | ||
| 53 | }{ | 53 | }{ | ||
| 54 | - | {"Alt+c", []byte{ESC, 'c'}, ActionCreateWindow}, | 54 | + | {"Alt+c", []byte{ESC, 'c'}, ActionCreateTab}, |
| 55 | - | {"Alt+n", []byte{ESC, 'n'}, ActionNextWindow}, | 55 | + | {"Alt+n", []byte{ESC, 'n'}, ActionNextTab}, |
| 56 | - | {"Alt+p", []byte{ESC, 'p'}, ActionPreviousWindow}, | 56 | + | {"Alt+p", []byte{ESC, 'p'}, ActionPreviousTab}, |
| 57 | {"Alt+d", []byte{ESC, 'd'}, ActionDetach}, | 57 | {"Alt+d", []byte{ESC, 'd'}, ActionDetach}, | ||
| 58 | - | {"Alt+x", []byte{ESC, 'x'}, ActionCloseWindow}, | 58 | + | {"Alt+x", []byte{ESC, 'x'}, ActionCloseTab}, |
| 59 | {"Alt+u", []byte{ESC, 'u'}, ActionCopyMode}, | 59 | {"Alt+u", []byte{ESC, 'u'}, ActionCopyMode}, | ||
| 60 | {"Alt+r", []byte{ESC, 'r'}, ActionRenameSession}, | 60 | {"Alt+r", []byte{ESC, 'r'}, ActionRenameSession}, | ||
| 61 | + | {"Alt+t", []byte{ESC, 't'}, ActionOpenPicker}, | |||
| 61 | } | 62 | } | ||
| 62 | for _, tc := range cases { | 63 | for _, tc := range cases { | ||
| 63 | t.Run(tc.name, func(t *testing.T) { | 64 | t.Run(tc.name, func(t *testing.T) { | ||
| @@ -79,9 +80,9 @@ func TestRouterInterceptsAltDigits(t *testing.T) { | |||||
| 79 | r.Route([]byte{ESC, b}) | 80 | r.Route([]byte{ESC, b}) | ||
| 80 | } | 81 | } | ||
| 81 | require.Equal(t, []Action{ | 82 | require.Equal(t, []Action{ | ||
| 82 | - | ActionSwitchWindow1, ActionSwitchWindow2, ActionSwitchWindow3, | 83 | + | ActionSwitchTab1, ActionSwitchTab2, ActionSwitchTab3, |
| 83 | - | ActionSwitchWindow4, ActionSwitchWindow5, ActionSwitchWindow6, | 84 | + | ActionSwitchTab4, ActionSwitchTab5, ActionSwitchTab6, |
| 84 | - | ActionSwitchWindow7, ActionSwitchWindow8, ActionSwitchWindow9, | 85 | + | ActionSwitchTab7, ActionSwitchTab8, ActionSwitchTab9, |
| 85 | }, h.actions) | 86 | }, h.actions) | ||
| 86 | require.Empty(t, h.forwards) | 87 | require.Empty(t, h.forwards) | ||
| 87 | } | 88 | } | ||
| @@ -90,7 +91,7 @@ func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) { | |||||
| 90 | clk := &fakeClock{} | 91 | clk := &fakeClock{} | ||
| 91 | h := &captureHandler{} | 92 | h := &captureHandler{} | ||
| 92 | NewRouter(clk, h).Route([]byte{'a', ESC, 'c', 'b'}) | 93 | NewRouter(clk, h).Route([]byte{'a', ESC, 'c', 'b'}) | ||
| 93 | - | require.Equal(t, []Action{ActionCreateWindow}, h.actions) | 94 | + | require.Equal(t, []Action{ActionCreateTab}, h.actions) |
| 94 | require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards) | 95 | require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards) | ||
| 95 | } | 96 | } | ||
| 96 | 97 | ||||
| @@ -117,10 +118,33 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) { | |||||
| 117 | 118 | ||||
| 118 | r.Route([]byte{'n'}) | 119 | r.Route([]byte{'n'}) | ||
| 119 | require.True(t, clk.timers[0].stopped) | 120 | require.True(t, clk.timers[0].stopped) | ||
| 120 | - | require.Equal(t, []Action{ActionNextWindow}, h.actions) | 121 | + | require.Equal(t, []Action{ActionNextTab}, h.actions) |
| 121 | require.Empty(t, h.forwards) | 122 | require.Empty(t, h.forwards) | ||
| 122 | } | 123 | } | ||
| 123 | 124 | ||||
| 125 | + | func TestRouterRetainsSplitESCAndInterceptsPickerBinding(t *testing.T) { | |||
| 126 | + | clk := &fakeClock{} | |||
| 127 | + | h := &captureHandler{} | |||
| 128 | + | r := NewRouter(clk, h) | |||
| 129 | + | r.Route([]byte{ESC}) | |||
| 130 | + | require.Len(t, clk.timers, 1) | |||
| 131 | + | require.Equal(t, ESCDelay, clk.timers[0].d) | |||
| 132 | + | require.Empty(t, h.forwards) | |||
| 133 | + | ||||
| 134 | + | r.Route([]byte{'t'}) | |||
| 135 | + | require.True(t, clk.timers[0].stopped) | |||
| 136 | + | require.Equal(t, []Action{ActionOpenPicker}, h.actions) | |||
| 137 | + | require.Empty(t, h.forwards) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func TestRouterForwardsBareT(t *testing.T) { | |||
| 141 | + | clk := &fakeClock{} | |||
| 142 | + | h := &captureHandler{} | |||
| 143 | + | NewRouter(clk, h).Route([]byte{'t'}) | |||
| 144 | + | require.Empty(t, h.actions) | |||
| 145 | + | require.Equal(t, [][]byte{[]byte("t")}, h.forwards) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 124 | func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) { | 148 | func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) { | ||
| 125 | cases := []struct { | 149 | cases := []struct { | ||
| 126 | name string | 150 | name string | ||
| @@ -159,7 +183,7 @@ func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) { | |||||
| 159 | 183 | ||||
| 160 | require.True(t, clk.timers[0].stopped) | 184 | require.True(t, clk.timers[0].stopped) | ||
| 161 | require.Equal(t, [][]byte{{ESC, '['}, []byte("A")}, h.forwards) | 185 | require.Equal(t, [][]byte{{ESC, '['}, []byte("A")}, h.forwards) | ||
| 162 | - | require.Equal(t, []Action{ActionCreateWindow}, h.actions) | 186 | + | require.Equal(t, []Action{ActionCreateTab}, h.actions) |
| 163 | } | 187 | } | ||
| 164 | 188 | ||||
| 165 | func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) { | 189 | func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) { | ||
| @@ -174,7 +198,7 @@ func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) { | |||||
| 174 | r.Route([]byte{'n'}) | 198 | r.Route([]byte{'n'}) | ||
| 175 | require.Eventually(t, func() bool { return retainESCWaiters() == before }, time.Second, time.Millisecond) | 199 | require.Eventually(t, func() bool { return retainESCWaiters() == before }, time.Second, time.Millisecond) | ||
| 176 | require.True(t, clk.timers[0].stopped) | 200 | require.True(t, clk.timers[0].stopped) | ||
| 177 | - | require.Equal(t, []Action{ActionNextWindow}, h.actions) | 201 | + | require.Equal(t, []Action{ActionNextTab}, h.actions) |
| 178 | require.Empty(t, h.forwards) | 202 | require.Empty(t, h.forwards) | ||
| 179 | } | 203 | } | ||
| 180 | 204 | ||||
A internal/usecase/picker/model.go +229 -0
| @@ -0,0 +1,229 @@ | |||
| 1 | + | package picker | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | const ( | |
| 9 | + | MinPreviewWidth = 24 | |
| 10 | + | MinListWidth = 16 | |
| 11 | + | MinPaneHeight = 4 | |
| 12 | + | MinStackHeight = 12 | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type SessionView struct { | |
| 16 | + | ID domain.SessionID | |
| 17 | + | Name string | |
| 18 | + | Tabs []string | |
| 19 | + | Active int | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | type Target struct { | |
| 23 | + | Session domain.SessionID | |
| 24 | + | TabIndex int | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | type Preview struct { | |
| 28 | + | Rows [][]renderer.Cell | |
| 29 | + | Width int | |
| 30 | + | Height int | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | type Layout struct { | |
| 34 | + | List domain.Rect | |
| 35 | + | Preview domain.Rect | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | type Model struct { | |
| 39 | + | rows []row | |
| 40 | + | selected int | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type row struct { | |
| 44 | + | header bool | |
| 45 | + | label string | |
| 46 | + | session domain.SessionID | |
| 47 | + | tabIndex int | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | |
| 51 | + | m := &Model{selected: -1} | |
| 52 | + | activeSelection := -1 | |
| 53 | + | for _, session := range sessions { | |
| 54 | + | m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, tabIndex: -1}) | |
| 55 | + | active := session.Active | |
| 56 | + | if active < 0 || active >= len(session.Tabs) { | |
| 57 | + | active = 0 | |
| 58 | + | } | |
| 59 | + | for i, tab := range session.Tabs { | |
| 60 | + | idx := len(m.rows) | |
| 61 | + | m.rows = append(m.rows, row{label: tab, session: session.ID, tabIndex: i}) | |
| 62 | + | if session.ID == cur && i == curTab { | |
| 63 | + | m.selected = idx | |
| 64 | + | } | |
| 65 | + | if activeSelection < 0 && i == active { | |
| 66 | + | activeSelection = idx | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | if m.selected < 0 { | |
| 71 | + | m.selected = activeSelection | |
| 72 | + | } | |
| 73 | + | if m.selected < 0 { | |
| 74 | + | m.selected = m.firstLeaf() | |
| 75 | + | } | |
| 76 | + | if m.selected < 0 && len(m.rows) > 0 { | |
| 77 | + | m.selected = 0 | |
| 78 | + | } | |
| 79 | + | return m | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func ChooseLayout(inner domain.Size) Layout { | |
| 83 | + | if inner.Cols <= 0 || inner.Rows <= 0 { | |
| 84 | + | return Layout{} | |
| 85 | + | } | |
| 86 | + | if inner.Cols >= 40 && inner.Rows >= MinPaneHeight { | |
| 87 | + | listWidth := max(MinListWidth, inner.Cols*30/100) | |
| 88 | + | previewWidth := inner.Cols - listWidth - 1 | |
| 89 | + | return Layout{ | |
| 90 | + | List: domain.Rect{Width: listWidth, Height: inner.Rows}, | |
| 91 | + | Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows}, | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | if inner.Rows >= MinStackHeight && inner.Cols >= MinPreviewWidth { | |
| 95 | + | listHeight := max(MinPaneHeight, inner.Rows*40/100) | |
| 96 | + | previewHeight := inner.Rows - listHeight - 1 | |
| 97 | + | return Layout{ | |
| 98 | + | List: domain.Rect{Width: inner.Cols, Height: listHeight}, | |
| 99 | + | Preview: domain.Rect{Y: listHeight + 1, Width: inner.Cols, Height: previewHeight}, | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | return Layout{List: domain.Rect{Width: inner.Cols, Height: inner.Rows}} | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func (m *Model) Up() { | |
| 106 | + | m.move(-1) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (m *Model) Down() { | |
| 110 | + | m.move(1) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (m *Model) Selected() (Target, bool) { | |
| 114 | + | if m == nil || m.selected < 0 || m.selected >= len(m.rows) { | |
| 115 | + | return Target{}, false | |
| 116 | + | } | |
| 117 | + | r := m.rows[m.selected] | |
| 118 | + | if r.header { | |
| 119 | + | return Target{}, false | |
| 120 | + | } | |
| 121 | + | return Target{Session: r.session, TabIndex: r.tabIndex}, true | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func (m *Model) Render(inner domain.Size, preview Preview) renderer.Frame { | |
| 125 | + | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |
| 126 | + | layout := ChooseLayout(inner) | |
| 127 | + | m.renderList(frame, layout.List) | |
| 128 | + | blitPreview(frame, layout.Preview, preview) | |
| 129 | + | return frame | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (m *Model) move(delta int) { | |
| 133 | + | if m == nil || len(m.rows) == 0 { | |
| 134 | + | return | |
| 135 | + | } | |
| 136 | + | if m.selected < 0 || m.selected >= len(m.rows) || m.rows[m.selected].header { | |
| 137 | + | m.selected = m.firstLeaf() | |
| 138 | + | return | |
| 139 | + | } | |
| 140 | + | for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta { | |
| 141 | + | if !m.rows[i].header { | |
| 142 | + | m.selected = i | |
| 143 | + | return | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func (m *Model) firstLeaf() int { | |
| 149 | + | for i, r := range m.rows { | |
| 150 | + | if !r.header { | |
| 151 | + | return i | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | return -1 | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) { | |
| 158 | + | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | |
| 159 | + | return | |
| 160 | + | } | |
| 161 | + | for y := range min(rect.Height, frame.Height-rect.Y) { | |
| 162 | + | idx := y | |
| 163 | + | if idx >= len(m.rows) { | |
| 164 | + | break | |
| 165 | + | } | |
| 166 | + | r := m.rows[idx] | |
| 167 | + | style := renderer.DefaultStyle() | |
| 168 | + | if idx == m.selected { | |
| 169 | + | style.Inverse = true | |
| 170 | + | } | |
| 171 | + | label := r.label | |
| 172 | + | if !r.header { | |
| 173 | + | label = " " + label | |
| 174 | + | } | |
| 175 | + | drawText(frame, rect.X, rect.Y+y, rect.Width, label, style) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func drawText(frame renderer.Frame, x, y, width int, text string, style renderer.Style) { | |
| 180 | + | if y < 0 || y >= frame.Height || x >= frame.Width || width <= 0 { | |
| 181 | + | return | |
| 182 | + | } | |
| 183 | + | limit := min(width, frame.Width-x) | |
| 184 | + | if limit <= 0 { | |
| 185 | + | return | |
| 186 | + | } | |
| 187 | + | row := frame.Row(y) | |
| 188 | + | for i := range limit { | |
| 189 | + | row[x+i] = renderer.Cell{Rune: ' ', Style: style} | |
| 190 | + | } | |
| 191 | + | col := 0 | |
| 192 | + | for _, r := range text { | |
| 193 | + | if col >= limit { | |
| 194 | + | break | |
| 195 | + | } | |
| 196 | + | row[x+col] = renderer.Cell{Rune: r, Style: style} | |
| 197 | + | col++ | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func blitPreview(frame renderer.Frame, rect domain.Rect, preview Preview) { | |
| 202 | + | if rect.Width <= 0 || rect.Height <= 0 { | |
| 203 | + | return | |
| 204 | + | } | |
| 205 | + | w := min(rect.Width, frame.Width-rect.X) | |
| 206 | + | h := min(rect.Height, frame.Height-rect.Y) | |
| 207 | + | if w <= 0 || h <= 0 { | |
| 208 | + | return | |
| 209 | + | } | |
| 210 | + | for y := range h { | |
| 211 | + | if y >= preview.Height || y >= len(preview.Rows) { | |
| 212 | + | continue | |
| 213 | + | } | |
| 214 | + | src := preview.Rows[y] | |
| 215 | + | for x := range w { | |
| 216 | + | if x >= preview.Width || x >= len(src) { | |
| 217 | + | continue | |
| 218 | + | } | |
| 219 | + | cell := src[x] | |
| 220 | + | if cell.Continuation && x == 0 { | |
| 221 | + | continue | |
| 222 | + | } | |
| 223 | + | if !cell.Continuation && x == w-1 && x+1 < len(src) && src[x+1].Continuation { | |
| 224 | + | continue | |
| 225 | + | } | |
| 226 | + | frame.Set(rect.X+x, rect.Y+y, cell) | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | } | |
| @@ -0,0 +1,229 @@ | |||||
| 1 | + | package picker | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | const ( | |||
| 9 | + | MinPreviewWidth = 24 | |||
| 10 | + | MinListWidth = 16 | |||
| 11 | + | MinPaneHeight = 4 | |||
| 12 | + | MinStackHeight = 12 | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type SessionView struct { | |||
| 16 | + | ID domain.SessionID | |||
| 17 | + | Name string | |||
| 18 | + | Tabs []string | |||
| 19 | + | Active int | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | type Target struct { | |||
| 23 | + | Session domain.SessionID | |||
| 24 | + | TabIndex int | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | type Preview struct { | |||
| 28 | + | Rows [][]renderer.Cell | |||
| 29 | + | Width int | |||
| 30 | + | Height int | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | type Layout struct { | |||
| 34 | + | List domain.Rect | |||
| 35 | + | Preview domain.Rect | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | type Model struct { | |||
| 39 | + | rows []row | |||
| 40 | + | selected int | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type row struct { | |||
| 44 | + | header bool | |||
| 45 | + | label string | |||
| 46 | + | session domain.SessionID | |||
| 47 | + | tabIndex int | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | |||
| 51 | + | m := &Model{selected: -1} | |||
| 52 | + | activeSelection := -1 | |||
| 53 | + | for _, session := range sessions { | |||
| 54 | + | m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, tabIndex: -1}) | |||
| 55 | + | active := session.Active | |||
| 56 | + | if active < 0 || active >= len(session.Tabs) { | |||
| 57 | + | active = 0 | |||
| 58 | + | } | |||
| 59 | + | for i, tab := range session.Tabs { | |||
| 60 | + | idx := len(m.rows) | |||
| 61 | + | m.rows = append(m.rows, row{label: tab, session: session.ID, tabIndex: i}) | |||
| 62 | + | if session.ID == cur && i == curTab { | |||
| 63 | + | m.selected = idx | |||
| 64 | + | } | |||
| 65 | + | if activeSelection < 0 && i == active { | |||
| 66 | + | activeSelection = idx | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | if m.selected < 0 { | |||
| 71 | + | m.selected = activeSelection | |||
| 72 | + | } | |||
| 73 | + | if m.selected < 0 { | |||
| 74 | + | m.selected = m.firstLeaf() | |||
| 75 | + | } | |||
| 76 | + | if m.selected < 0 && len(m.rows) > 0 { | |||
| 77 | + | m.selected = 0 | |||
| 78 | + | } | |||
| 79 | + | return m | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func ChooseLayout(inner domain.Size) Layout { | |||
| 83 | + | if inner.Cols <= 0 || inner.Rows <= 0 { | |||
| 84 | + | return Layout{} | |||
| 85 | + | } | |||
| 86 | + | if inner.Cols >= 40 && inner.Rows >= MinPaneHeight { | |||
| 87 | + | listWidth := max(MinListWidth, inner.Cols*30/100) | |||
| 88 | + | previewWidth := inner.Cols - listWidth - 1 | |||
| 89 | + | return Layout{ | |||
| 90 | + | List: domain.Rect{Width: listWidth, Height: inner.Rows}, | |||
| 91 | + | Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows}, | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | if inner.Rows >= MinStackHeight && inner.Cols >= MinPreviewWidth { | |||
| 95 | + | listHeight := max(MinPaneHeight, inner.Rows*40/100) | |||
| 96 | + | previewHeight := inner.Rows - listHeight - 1 | |||
| 97 | + | return Layout{ | |||
| 98 | + | List: domain.Rect{Width: inner.Cols, Height: listHeight}, | |||
| 99 | + | Preview: domain.Rect{Y: listHeight + 1, Width: inner.Cols, Height: previewHeight}, | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | return Layout{List: domain.Rect{Width: inner.Cols, Height: inner.Rows}} | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func (m *Model) Up() { | |||
| 106 | + | m.move(-1) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func (m *Model) Down() { | |||
| 110 | + | m.move(1) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (m *Model) Selected() (Target, bool) { | |||
| 114 | + | if m == nil || m.selected < 0 || m.selected >= len(m.rows) { | |||
| 115 | + | return Target{}, false | |||
| 116 | + | } | |||
| 117 | + | r := m.rows[m.selected] | |||
| 118 | + | if r.header { | |||
| 119 | + | return Target{}, false | |||
| 120 | + | } | |||
| 121 | + | return Target{Session: r.session, TabIndex: r.tabIndex}, true | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func (m *Model) Render(inner domain.Size, preview Preview) renderer.Frame { | |||
| 125 | + | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |||
| 126 | + | layout := ChooseLayout(inner) | |||
| 127 | + | m.renderList(frame, layout.List) | |||
| 128 | + | blitPreview(frame, layout.Preview, preview) | |||
| 129 | + | return frame | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func (m *Model) move(delta int) { | |||
| 133 | + | if m == nil || len(m.rows) == 0 { | |||
| 134 | + | return | |||
| 135 | + | } | |||
| 136 | + | if m.selected < 0 || m.selected >= len(m.rows) || m.rows[m.selected].header { | |||
| 137 | + | m.selected = m.firstLeaf() | |||
| 138 | + | return | |||
| 139 | + | } | |||
| 140 | + | for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta { | |||
| 141 | + | if !m.rows[i].header { | |||
| 142 | + | m.selected = i | |||
| 143 | + | return | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func (m *Model) firstLeaf() int { | |||
| 149 | + | for i, r := range m.rows { | |||
| 150 | + | if !r.header { | |||
| 151 | + | return i | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | return -1 | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) { | |||
| 158 | + | if m == nil || rect.Width <= 0 || rect.Height <= 0 { | |||
| 159 | + | return | |||
| 160 | + | } | |||
| 161 | + | for y := range min(rect.Height, frame.Height-rect.Y) { | |||
| 162 | + | idx := y | |||
| 163 | + | if idx >= len(m.rows) { | |||
| 164 | + | break | |||
| 165 | + | } | |||
| 166 | + | r := m.rows[idx] | |||
| 167 | + | style := renderer.DefaultStyle() | |||
| 168 | + | if idx == m.selected { | |||
| 169 | + | style.Inverse = true | |||
| 170 | + | } | |||
| 171 | + | label := r.label | |||
| 172 | + | if !r.header { | |||
| 173 | + | label = " " + label | |||
| 174 | + | } | |||
| 175 | + | drawText(frame, rect.X, rect.Y+y, rect.Width, label, style) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func drawText(frame renderer.Frame, x, y, width int, text string, style renderer.Style) { | |||
| 180 | + | if y < 0 || y >= frame.Height || x >= frame.Width || width <= 0 { | |||
| 181 | + | return | |||
| 182 | + | } | |||
| 183 | + | limit := min(width, frame.Width-x) | |||
| 184 | + | if limit <= 0 { | |||
| 185 | + | return | |||
| 186 | + | } | |||
| 187 | + | row := frame.Row(y) | |||
| 188 | + | for i := range limit { | |||
| 189 | + | row[x+i] = renderer.Cell{Rune: ' ', Style: style} | |||
| 190 | + | } | |||
| 191 | + | col := 0 | |||
| 192 | + | for _, r := range text { | |||
| 193 | + | if col >= limit { | |||
| 194 | + | break | |||
| 195 | + | } | |||
| 196 | + | row[x+col] = renderer.Cell{Rune: r, Style: style} | |||
| 197 | + | col++ | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func blitPreview(frame renderer.Frame, rect domain.Rect, preview Preview) { | |||
| 202 | + | if rect.Width <= 0 || rect.Height <= 0 { | |||
| 203 | + | return | |||
| 204 | + | } | |||
| 205 | + | w := min(rect.Width, frame.Width-rect.X) | |||
| 206 | + | h := min(rect.Height, frame.Height-rect.Y) | |||
| 207 | + | if w <= 0 || h <= 0 { | |||
| 208 | + | return | |||
| 209 | + | } | |||
| 210 | + | for y := range h { | |||
| 211 | + | if y >= preview.Height || y >= len(preview.Rows) { | |||
| 212 | + | continue | |||
| 213 | + | } | |||
| 214 | + | src := preview.Rows[y] | |||
| 215 | + | for x := range w { | |||
| 216 | + | if x >= preview.Width || x >= len(src) { | |||
| 217 | + | continue | |||
| 218 | + | } | |||
| 219 | + | cell := src[x] | |||
| 220 | + | if cell.Continuation && x == 0 { | |||
| 221 | + | continue | |||
| 222 | + | } | |||
| 223 | + | if !cell.Continuation && x == w-1 && x+1 < len(src) && src[x+1].Continuation { | |||
| 224 | + | continue | |||
| 225 | + | } | |||
| 226 | + | frame.Set(rect.X+x, rect.Y+y, cell) | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | } | |||
A internal/usecase/picker/model_test.go +128 -0
| @@ -0,0 +1,128 @@ | |||
| 1 | + | package picker | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestNewFlattensAndSelectsCurrentTab(t *testing.T) { | |
| 12 | + | m := New([]SessionView{ | |
| 13 | + | {ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 0}, | |
| 14 | + | {ID: "s2", Name: "two", Tabs: []string{"api"}, Active: 0}, | |
| 15 | + | }, "s1", 1) | |
| 16 | + | ||
| 17 | + | got, ok := m.Selected() | |
| 18 | + | require.True(t, ok) | |
| 19 | + | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got) | |
| 20 | + | frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{}) | |
| 21 | + | require.Equal(t, 'o', frame.At(0, 0).Rune) | |
| 22 | + | require.Equal(t, ' ', frame.At(0, 2).Rune) | |
| 23 | + | require.Equal(t, 'l', frame.At(2, 2).Rune) | |
| 24 | + | require.True(t, frame.At(0, 2).Style.Inverse) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) { | |
| 28 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 1}}, "missing", 0) | |
| 29 | + | got, ok := m.Selected() | |
| 30 | + | require.True(t, ok) | |
| 31 | + | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got) | |
| 32 | + | ||
| 33 | + | m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell"}, Active: 4}}, "missing", 0) | |
| 34 | + | got, ok = m.Selected() | |
| 35 | + | require.True(t, ok) | |
| 36 | + | require.Equal(t, Target{Session: "s1", TabIndex: 0}, got) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) { | |
| 40 | + | m := New([]SessionView{ | |
| 41 | + | {ID: "s1", Name: "one", Tabs: []string{"a", "b"}, Active: 0}, | |
| 42 | + | {ID: "s2", Name: "two", Tabs: []string{"c"}, Active: 0}, | |
| 43 | + | }, "s1", 0) | |
| 44 | + | ||
| 45 | + | m.Up() | |
| 46 | + | got, ok := m.Selected() | |
| 47 | + | require.True(t, ok) | |
| 48 | + | require.Equal(t, Target{Session: "s1", TabIndex: 0}, got, "up at first tab clamps") | |
| 49 | + | m.Down() | |
| 50 | + | got, ok = m.Selected() | |
| 51 | + | require.True(t, ok) | |
| 52 | + | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got) | |
| 53 | + | m.Down() | |
| 54 | + | got, ok = m.Selected() | |
| 55 | + | require.True(t, ok) | |
| 56 | + | require.Equal(t, Target{Session: "s2", TabIndex: 0}, got, "down skips second session header") | |
| 57 | + | m.Down() | |
| 58 | + | got, ok = m.Selected() | |
| 59 | + | require.True(t, ok) | |
| 60 | + | require.Equal(t, Target{Session: "s2", TabIndex: 0}, got, "down at last tab clamps") | |
| 61 | + | m.Up() | |
| 62 | + | got, ok = m.Selected() | |
| 63 | + | require.True(t, ok) | |
| 64 | + | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got, "up skips second session header") | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestChooseLayoutBoundaries(t *testing.T) { | |
| 68 | + | layout := ChooseLayout(domain.Size{Cols: 39, Rows: 12}) | |
| 69 | + | require.Equal(t, domain.Rect{Width: 39, Height: 4}, layout.List) | |
| 70 | + | require.Equal(t, domain.Rect{Y: 5, Width: 39, Height: 7}, layout.Preview) | |
| 71 | + | ||
| 72 | + | layout = ChooseLayout(domain.Size{Cols: 40, Rows: 4}) | |
| 73 | + | require.Equal(t, domain.Rect{Width: 16, Height: 4}, layout.List) | |
| 74 | + | require.Equal(t, domain.Rect{X: 17, Width: 23, Height: 4}, layout.Preview) | |
| 75 | + | ||
| 76 | + | layout = ChooseLayout(domain.Size{Cols: 24, Rows: 11}) | |
| 77 | + | require.Equal(t, domain.Rect{Width: 24, Height: 11}, layout.List) | |
| 78 | + | require.Equal(t, domain.Rect{}, layout.Preview) | |
| 79 | + | ||
| 80 | + | layout = ChooseLayout(domain.Size{Cols: 24, Rows: 12}) | |
| 81 | + | require.Equal(t, domain.Rect{Width: 24, Height: 4}, layout.List) | |
| 82 | + | require.Equal(t, domain.Rect{Y: 5, Width: 24, Height: 7}, layout.Preview) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestSelectedMapping(t *testing.T) { | |
| 86 | + | m := New([]SessionView{ | |
| 87 | + | {ID: "alpha", Name: "alpha", Tabs: []string{"one"}, Active: 0}, | |
| 88 | + | {ID: "beta", Name: "beta", Tabs: []string{"two", "three"}, Active: 0}, | |
| 89 | + | }, "beta", 1) | |
| 90 | + | ||
| 91 | + | got, ok := m.Selected() | |
| 92 | + | require.True(t, ok) | |
| 93 | + | require.Equal(t, Target{Session: "beta", TabIndex: 1}, got) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |
| 97 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | |
| 98 | + | preview := Preview{ | |
| 99 | + | Width: 24, | |
| 100 | + | Height: 1, | |
| 101 | + | Rows: [][]renderer.Cell{{ | |
| 102 | + | cell('a'), cell('b'), cell('c'), cell('d'), cell('e'), cell('f'), cell('g'), cell('h'), cell('i'), cell('j'), | |
| 103 | + | cell('k'), cell('l'), cell('m'), cell('n'), cell('o'), cell('p'), cell('q'), cell('r'), cell('s'), cell('t'), | |
| 104 | + | cell('u'), cell('v'), {Rune: '界', Style: renderer.DefaultStyle()}, {Continuation: true, Style: renderer.DefaultStyle()}, | |
| 105 | + | }}, | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | frame := m.Render(domain.Size{Cols: 40, Rows: 4}, preview) | |
| 109 | + | require.True(t, frame.At(0, 1).Style.Inverse) | |
| 110 | + | require.Equal(t, 'a', frame.At(17, 0).Rune) | |
| 111 | + | require.Equal(t, 'v', frame.At(38, 0).Rune) | |
| 112 | + | require.Equal(t, ' ', frame.At(39, 0).Rune, "wide rune crossing preview pane is dropped") | |
| 113 | + | require.Equal(t, ' ', frame.At(17, 1).Rune, "preview pane is padded with blanks") | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | |
| 117 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | |
| 118 | + | preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}} | |
| 119 | + | ||
| 120 | + | frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview) | |
| 121 | + | require.Equal(t, 'o', frame.At(0, 0).Rune) | |
| 122 | + | require.Equal(t, ' ', frame.At(0, 1).Rune) | |
| 123 | + | require.NotEqual(t, 'x', frame.At(0, 0).Rune) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func cell(r rune) renderer.Cell { | |
| 127 | + | return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | |
| 128 | + | } | |
| @@ -0,0 +1,128 @@ | |||||
| 1 | + | package picker | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestNewFlattensAndSelectsCurrentTab(t *testing.T) { | |||
| 12 | + | m := New([]SessionView{ | |||
| 13 | + | {ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 0}, | |||
| 14 | + | {ID: "s2", Name: "two", Tabs: []string{"api"}, Active: 0}, | |||
| 15 | + | }, "s1", 1) | |||
| 16 | + | ||||
| 17 | + | got, ok := m.Selected() | |||
| 18 | + | require.True(t, ok) | |||
| 19 | + | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got) | |||
| 20 | + | frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{}) | |||
| 21 | + | require.Equal(t, 'o', frame.At(0, 0).Rune) | |||
| 22 | + | require.Equal(t, ' ', frame.At(0, 2).Rune) | |||
| 23 | + | require.Equal(t, 'l', frame.At(2, 2).Rune) | |||
| 24 | + | require.True(t, frame.At(0, 2).Style.Inverse) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) { | |||
| 28 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 1}}, "missing", 0) | |||
| 29 | + | got, ok := m.Selected() | |||
| 30 | + | require.True(t, ok) | |||
| 31 | + | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got) | |||
| 32 | + | ||||
| 33 | + | m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell"}, Active: 4}}, "missing", 0) | |||
| 34 | + | got, ok = m.Selected() | |||
| 35 | + | require.True(t, ok) | |||
| 36 | + | require.Equal(t, Target{Session: "s1", TabIndex: 0}, got) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) { | |||
| 40 | + | m := New([]SessionView{ | |||
| 41 | + | {ID: "s1", Name: "one", Tabs: []string{"a", "b"}, Active: 0}, | |||
| 42 | + | {ID: "s2", Name: "two", Tabs: []string{"c"}, Active: 0}, | |||
| 43 | + | }, "s1", 0) | |||
| 44 | + | ||||
| 45 | + | m.Up() | |||
| 46 | + | got, ok := m.Selected() | |||
| 47 | + | require.True(t, ok) | |||
| 48 | + | require.Equal(t, Target{Session: "s1", TabIndex: 0}, got, "up at first tab clamps") | |||
| 49 | + | m.Down() | |||
| 50 | + | got, ok = m.Selected() | |||
| 51 | + | require.True(t, ok) | |||
| 52 | + | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got) | |||
| 53 | + | m.Down() | |||
| 54 | + | got, ok = m.Selected() | |||
| 55 | + | require.True(t, ok) | |||
| 56 | + | require.Equal(t, Target{Session: "s2", TabIndex: 0}, got, "down skips second session header") | |||
| 57 | + | m.Down() | |||
| 58 | + | got, ok = m.Selected() | |||
| 59 | + | require.True(t, ok) | |||
| 60 | + | require.Equal(t, Target{Session: "s2", TabIndex: 0}, got, "down at last tab clamps") | |||
| 61 | + | m.Up() | |||
| 62 | + | got, ok = m.Selected() | |||
| 63 | + | require.True(t, ok) | |||
| 64 | + | require.Equal(t, Target{Session: "s1", TabIndex: 1}, got, "up skips second session header") | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestChooseLayoutBoundaries(t *testing.T) { | |||
| 68 | + | layout := ChooseLayout(domain.Size{Cols: 39, Rows: 12}) | |||
| 69 | + | require.Equal(t, domain.Rect{Width: 39, Height: 4}, layout.List) | |||
| 70 | + | require.Equal(t, domain.Rect{Y: 5, Width: 39, Height: 7}, layout.Preview) | |||
| 71 | + | ||||
| 72 | + | layout = ChooseLayout(domain.Size{Cols: 40, Rows: 4}) | |||
| 73 | + | require.Equal(t, domain.Rect{Width: 16, Height: 4}, layout.List) | |||
| 74 | + | require.Equal(t, domain.Rect{X: 17, Width: 23, Height: 4}, layout.Preview) | |||
| 75 | + | ||||
| 76 | + | layout = ChooseLayout(domain.Size{Cols: 24, Rows: 11}) | |||
| 77 | + | require.Equal(t, domain.Rect{Width: 24, Height: 11}, layout.List) | |||
| 78 | + | require.Equal(t, domain.Rect{}, layout.Preview) | |||
| 79 | + | ||||
| 80 | + | layout = ChooseLayout(domain.Size{Cols: 24, Rows: 12}) | |||
| 81 | + | require.Equal(t, domain.Rect{Width: 24, Height: 4}, layout.List) | |||
| 82 | + | require.Equal(t, domain.Rect{Y: 5, Width: 24, Height: 7}, layout.Preview) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestSelectedMapping(t *testing.T) { | |||
| 86 | + | m := New([]SessionView{ | |||
| 87 | + | {ID: "alpha", Name: "alpha", Tabs: []string{"one"}, Active: 0}, | |||
| 88 | + | {ID: "beta", Name: "beta", Tabs: []string{"two", "three"}, Active: 0}, | |||
| 89 | + | }, "beta", 1) | |||
| 90 | + | ||||
| 91 | + | got, ok := m.Selected() | |||
| 92 | + | require.True(t, ok) | |||
| 93 | + | require.Equal(t, Target{Session: "beta", TabIndex: 1}, got) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) { | |||
| 97 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | |||
| 98 | + | preview := Preview{ | |||
| 99 | + | Width: 24, | |||
| 100 | + | Height: 1, | |||
| 101 | + | Rows: [][]renderer.Cell{{ | |||
| 102 | + | cell('a'), cell('b'), cell('c'), cell('d'), cell('e'), cell('f'), cell('g'), cell('h'), cell('i'), cell('j'), | |||
| 103 | + | cell('k'), cell('l'), cell('m'), cell('n'), cell('o'), cell('p'), cell('q'), cell('r'), cell('s'), cell('t'), | |||
| 104 | + | cell('u'), cell('v'), {Rune: '界', Style: renderer.DefaultStyle()}, {Continuation: true, Style: renderer.DefaultStyle()}, | |||
| 105 | + | }}, | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | frame := m.Render(domain.Size{Cols: 40, Rows: 4}, preview) | |||
| 109 | + | require.True(t, frame.At(0, 1).Style.Inverse) | |||
| 110 | + | require.Equal(t, 'a', frame.At(17, 0).Rune) | |||
| 111 | + | require.Equal(t, 'v', frame.At(38, 0).Rune) | |||
| 112 | + | require.Equal(t, ' ', frame.At(39, 0).Rune, "wide rune crossing preview pane is dropped") | |||
| 113 | + | require.Equal(t, ' ', frame.At(17, 1).Rune, "preview pane is padded with blanks") | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | |||
| 117 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | |||
| 118 | + | preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}} | |||
| 119 | + | ||||
| 120 | + | frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview) | |||
| 121 | + | require.Equal(t, 'o', frame.At(0, 0).Rune) | |||
| 122 | + | require.Equal(t, ' ', frame.At(0, 1).Rune) | |||
| 123 | + | require.NotEqual(t, 'x', frame.At(0, 0).Rune) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func cell(r rune) renderer.Cell { | |||
| 127 | + | return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | |||
| 128 | + | } | |||
A internal/usecase/ui/modal.go +208 -0
| @@ -0,0 +1,208 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // Modal describes a centered rectangular overlay. | |
| 9 | + | type Modal struct { | |
| 10 | + | WidthPct int | |
| 11 | + | HeightPct int | |
| 12 | + | MinWidth int | |
| 13 | + | MinHeight int | |
| 14 | + | Title string | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // Bounds returns the modal rectangle centered within base and clamped to base. | |
| 18 | + | func (m Modal) Bounds(base domain.Size) domain.Rect { | |
| 19 | + | if base.Cols <= 0 || base.Rows <= 0 { | |
| 20 | + | return domain.Rect{} | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | width := percentOf(base.Cols, m.WidthPct) | |
| 24 | + | height := percentOf(base.Rows, m.HeightPct) | |
| 25 | + | if width < m.MinWidth { | |
| 26 | + | width = m.MinWidth | |
| 27 | + | } | |
| 28 | + | if height < m.MinHeight { | |
| 29 | + | height = m.MinHeight | |
| 30 | + | } | |
| 31 | + | width = clamp(width, 0, base.Cols) | |
| 32 | + | height = clamp(height, 0, base.Rows) | |
| 33 | + | ||
| 34 | + | return domain.Rect{ | |
| 35 | + | X: (base.Cols - width) / 2, | |
| 36 | + | Y: (base.Rows - height) / 2, | |
| 37 | + | Width: width, | |
| 38 | + | Height: height, | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // Inner returns the modal content rectangle after removing a one-cell border. | |
| 43 | + | func (m Modal) Inner(base domain.Size) domain.Rect { | |
| 44 | + | bounds := m.Bounds(base) | |
| 45 | + | return domain.Rect{ | |
| 46 | + | X: bounds.X + 1, | |
| 47 | + | Y: bounds.Y + 1, | |
| 48 | + | Width: max(0, bounds.Width-2), | |
| 49 | + | Height: max(0, bounds.Height-2), | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // Composite draws the modal border and title, clears the interior, and returns | |
| 54 | + | // the inner content rectangle. Cells outside the modal bounds are not changed. | |
| 55 | + | func (m Modal) Composite(f renderer.Frame, border renderer.Style) domain.Rect { | |
| 56 | + | bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height}) | |
| 57 | + | inner := m.Inner(domain.Size{Cols: f.Width, Rows: f.Height}) | |
| 58 | + | DrawBox(f, bounds, border) | |
| 59 | + | FillRect(f, inner, renderer.BlankCell()) | |
| 60 | + | if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" { | |
| 61 | + | left := bounds.X + 1 | |
| 62 | + | right := bounds.X + bounds.Width - 1 | |
| 63 | + | start := bounds.X + (bounds.Width-textWidth(m.Title))/2 | |
| 64 | + | if start < left { | |
| 65 | + | start = left | |
| 66 | + | } | |
| 67 | + | DrawText(f, start, bounds.Y, right, m.Title, border) | |
| 68 | + | } | |
| 69 | + | return inner | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // FillRect fills rect with cell, clipped to the frame bounds. | |
| 73 | + | func FillRect(f renderer.Frame, rect domain.Rect, cell renderer.Cell) { | |
| 74 | + | left := clamp(rect.X, 0, f.Width) | |
| 75 | + | top := clamp(rect.Y, 0, f.Height) | |
| 76 | + | right := clamp(rect.X+rect.Width, 0, f.Width) | |
| 77 | + | bottom := clamp(rect.Y+rect.Height, 0, f.Height) | |
| 78 | + | for y := top; y < bottom; y++ { | |
| 79 | + | for x := left; x < right; x++ { | |
| 80 | + | f.Set(x, y, cell) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // DrawBox draws a single-cell box clipped to the frame bounds. | |
| 86 | + | func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) { | |
| 87 | + | if rect.Width <= 0 || rect.Height <= 0 { | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | left := rect.X | |
| 91 | + | top := rect.Y | |
| 92 | + | right := rect.X + rect.Width - 1 | |
| 93 | + | bottom := rect.Y + rect.Height - 1 | |
| 94 | + | ||
| 95 | + | for x := left; x <= right; x++ { | |
| 96 | + | setCell(f, x, top, '─', style) | |
| 97 | + | setCell(f, x, bottom, '─', style) | |
| 98 | + | } | |
| 99 | + | for y := top; y <= bottom; y++ { | |
| 100 | + | setCell(f, left, y, '│', style) | |
| 101 | + | setCell(f, right, y, '│', style) | |
| 102 | + | } | |
| 103 | + | setCell(f, left, top, '┌', style) | |
| 104 | + | setCell(f, right, top, '┐', style) | |
| 105 | + | setCell(f, left, bottom, '└', style) | |
| 106 | + | setCell(f, right, bottom, '┘', style) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // DrawText draws text starting at x,y before exclusive clipX and returns the | |
| 110 | + | // next x position. Wide runes that would cross clipX are dropped. | |
| 111 | + | func DrawText(f renderer.Frame, x, y, clipX int, text string, style renderer.Style) int { | |
| 112 | + | if y < 0 || y >= f.Height { | |
| 113 | + | return x | |
| 114 | + | } | |
| 115 | + | clipX = clamp(clipX, 0, f.Width) | |
| 116 | + | pos := x | |
| 117 | + | for _, r := range text { | |
| 118 | + | w := runeWidth(r) | |
| 119 | + | if w == 0 { | |
| 120 | + | continue | |
| 121 | + | } | |
| 122 | + | if pos >= clipX { | |
| 123 | + | break | |
| 124 | + | } | |
| 125 | + | if pos+w > clipX { | |
| 126 | + | break | |
| 127 | + | } | |
| 128 | + | if pos >= 0 && pos < f.Width { | |
| 129 | + | f.Set(pos, y, renderer.Cell{Rune: r, Style: style}) | |
| 130 | + | } | |
| 131 | + | if w == 2 && pos+1 >= 0 && pos+1 < f.Width { | |
| 132 | + | f.Set(pos+1, y, renderer.Cell{Style: style, Continuation: true}) | |
| 133 | + | } | |
| 134 | + | pos += w | |
| 135 | + | } | |
| 136 | + | return pos | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func setCell(f renderer.Frame, x, y int, r rune, style renderer.Style) { | |
| 140 | + | if x < 0 || x >= f.Width || y < 0 || y >= f.Height { | |
| 141 | + | return | |
| 142 | + | } | |
| 143 | + | f.Set(x, y, renderer.Cell{Rune: r, Style: style}) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func percentOf(n, pct int) int { | |
| 147 | + | if pct <= 0 { | |
| 148 | + | return n | |
| 149 | + | } | |
| 150 | + | return n * pct / 100 | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func clamp(n, low, high int) int { | |
| 154 | + | if n < low { | |
| 155 | + | return low | |
| 156 | + | } | |
| 157 | + | if n > high { | |
| 158 | + | return high | |
| 159 | + | } | |
| 160 | + | return n | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func textWidth(text string) int { | |
| 164 | + | width := 0 | |
| 165 | + | for _, r := range text { | |
| 166 | + | width += runeWidth(r) | |
| 167 | + | } | |
| 168 | + | return width | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func runeWidth(r rune) int { | |
| 172 | + | switch { | |
| 173 | + | case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F): | |
| 174 | + | return 0 | |
| 175 | + | case r >= 0x1100 && r <= 0x115F: | |
| 176 | + | return 2 | |
| 177 | + | case r == 0x2329 || r == 0x232A: | |
| 178 | + | return 2 | |
| 179 | + | case r >= 0x2E80 && r <= 0xA4CF: | |
| 180 | + | return 2 | |
| 181 | + | case r >= 0xAC00 && r <= 0xD7FF: | |
| 182 | + | return 2 | |
| 183 | + | case r >= 0xF900 && r <= 0xFAFF: | |
| 184 | + | return 2 | |
| 185 | + | case r >= 0xFE10 && r <= 0xFE19: | |
| 186 | + | return 2 | |
| 187 | + | case r >= 0xFE30 && r <= 0xFE6F: | |
| 188 | + | return 2 | |
| 189 | + | case r >= 0xFF01 && r <= 0xFF60: | |
| 190 | + | return 2 | |
| 191 | + | case r >= 0xFFE0 && r <= 0xFFE6: | |
| 192 | + | return 2 | |
| 193 | + | case r >= 0x1B000 && r <= 0x1B12F: | |
| 194 | + | return 2 | |
| 195 | + | case r >= 0x1F000 && r <= 0x1F64F: | |
| 196 | + | return 2 | |
| 197 | + | case r >= 0x1F680 && r <= 0x1F6FF: | |
| 198 | + | return 2 | |
| 199 | + | case r >= 0x1F900 && r <= 0x1F9FF: | |
| 200 | + | return 2 | |
| 201 | + | case r >= 0x1FA70 && r <= 0x1FAFF: | |
| 202 | + | return 2 | |
| 203 | + | case r >= 0x20000 && r <= 0x3FFFF: | |
| 204 | + | return 2 | |
| 205 | + | default: | |
| 206 | + | return 1 | |
| 207 | + | } | |
| 208 | + | } | |
| @@ -0,0 +1,208 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // Modal describes a centered rectangular overlay. | |||
| 9 | + | type Modal struct { | |||
| 10 | + | WidthPct int | |||
| 11 | + | HeightPct int | |||
| 12 | + | MinWidth int | |||
| 13 | + | MinHeight int | |||
| 14 | + | Title string | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // Bounds returns the modal rectangle centered within base and clamped to base. | |||
| 18 | + | func (m Modal) Bounds(base domain.Size) domain.Rect { | |||
| 19 | + | if base.Cols <= 0 || base.Rows <= 0 { | |||
| 20 | + | return domain.Rect{} | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | width := percentOf(base.Cols, m.WidthPct) | |||
| 24 | + | height := percentOf(base.Rows, m.HeightPct) | |||
| 25 | + | if width < m.MinWidth { | |||
| 26 | + | width = m.MinWidth | |||
| 27 | + | } | |||
| 28 | + | if height < m.MinHeight { | |||
| 29 | + | height = m.MinHeight | |||
| 30 | + | } | |||
| 31 | + | width = clamp(width, 0, base.Cols) | |||
| 32 | + | height = clamp(height, 0, base.Rows) | |||
| 33 | + | ||||
| 34 | + | return domain.Rect{ | |||
| 35 | + | X: (base.Cols - width) / 2, | |||
| 36 | + | Y: (base.Rows - height) / 2, | |||
| 37 | + | Width: width, | |||
| 38 | + | Height: height, | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // Inner returns the modal content rectangle after removing a one-cell border. | |||
| 43 | + | func (m Modal) Inner(base domain.Size) domain.Rect { | |||
| 44 | + | bounds := m.Bounds(base) | |||
| 45 | + | return domain.Rect{ | |||
| 46 | + | X: bounds.X + 1, | |||
| 47 | + | Y: bounds.Y + 1, | |||
| 48 | + | Width: max(0, bounds.Width-2), | |||
| 49 | + | Height: max(0, bounds.Height-2), | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // Composite draws the modal border and title, clears the interior, and returns | |||
| 54 | + | // the inner content rectangle. Cells outside the modal bounds are not changed. | |||
| 55 | + | func (m Modal) Composite(f renderer.Frame, border renderer.Style) domain.Rect { | |||
| 56 | + | bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height}) | |||
| 57 | + | inner := m.Inner(domain.Size{Cols: f.Width, Rows: f.Height}) | |||
| 58 | + | DrawBox(f, bounds, border) | |||
| 59 | + | FillRect(f, inner, renderer.BlankCell()) | |||
| 60 | + | if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" { | |||
| 61 | + | left := bounds.X + 1 | |||
| 62 | + | right := bounds.X + bounds.Width - 1 | |||
| 63 | + | start := bounds.X + (bounds.Width-textWidth(m.Title))/2 | |||
| 64 | + | if start < left { | |||
| 65 | + | start = left | |||
| 66 | + | } | |||
| 67 | + | DrawText(f, start, bounds.Y, right, m.Title, border) | |||
| 68 | + | } | |||
| 69 | + | return inner | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // FillRect fills rect with cell, clipped to the frame bounds. | |||
| 73 | + | func FillRect(f renderer.Frame, rect domain.Rect, cell renderer.Cell) { | |||
| 74 | + | left := clamp(rect.X, 0, f.Width) | |||
| 75 | + | top := clamp(rect.Y, 0, f.Height) | |||
| 76 | + | right := clamp(rect.X+rect.Width, 0, f.Width) | |||
| 77 | + | bottom := clamp(rect.Y+rect.Height, 0, f.Height) | |||
| 78 | + | for y := top; y < bottom; y++ { | |||
| 79 | + | for x := left; x < right; x++ { | |||
| 80 | + | f.Set(x, y, cell) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // DrawBox draws a single-cell box clipped to the frame bounds. | |||
| 86 | + | func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) { | |||
| 87 | + | if rect.Width <= 0 || rect.Height <= 0 { | |||
| 88 | + | return | |||
| 89 | + | } | |||
| 90 | + | left := rect.X | |||
| 91 | + | top := rect.Y | |||
| 92 | + | right := rect.X + rect.Width - 1 | |||
| 93 | + | bottom := rect.Y + rect.Height - 1 | |||
| 94 | + | ||||
| 95 | + | for x := left; x <= right; x++ { | |||
| 96 | + | setCell(f, x, top, '─', style) | |||
| 97 | + | setCell(f, x, bottom, '─', style) | |||
| 98 | + | } | |||
| 99 | + | for y := top; y <= bottom; y++ { | |||
| 100 | + | setCell(f, left, y, '│', style) | |||
| 101 | + | setCell(f, right, y, '│', style) | |||
| 102 | + | } | |||
| 103 | + | setCell(f, left, top, '┌', style) | |||
| 104 | + | setCell(f, right, top, '┐', style) | |||
| 105 | + | setCell(f, left, bottom, '└', style) | |||
| 106 | + | setCell(f, right, bottom, '┘', style) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // DrawText draws text starting at x,y before exclusive clipX and returns the | |||
| 110 | + | // next x position. Wide runes that would cross clipX are dropped. | |||
| 111 | + | func DrawText(f renderer.Frame, x, y, clipX int, text string, style renderer.Style) int { | |||
| 112 | + | if y < 0 || y >= f.Height { | |||
| 113 | + | return x | |||
| 114 | + | } | |||
| 115 | + | clipX = clamp(clipX, 0, f.Width) | |||
| 116 | + | pos := x | |||
| 117 | + | for _, r := range text { | |||
| 118 | + | w := runeWidth(r) | |||
| 119 | + | if w == 0 { | |||
| 120 | + | continue | |||
| 121 | + | } | |||
| 122 | + | if pos >= clipX { | |||
| 123 | + | break | |||
| 124 | + | } | |||
| 125 | + | if pos+w > clipX { | |||
| 126 | + | break | |||
| 127 | + | } | |||
| 128 | + | if pos >= 0 && pos < f.Width { | |||
| 129 | + | f.Set(pos, y, renderer.Cell{Rune: r, Style: style}) | |||
| 130 | + | } | |||
| 131 | + | if w == 2 && pos+1 >= 0 && pos+1 < f.Width { | |||
| 132 | + | f.Set(pos+1, y, renderer.Cell{Style: style, Continuation: true}) | |||
| 133 | + | } | |||
| 134 | + | pos += w | |||
| 135 | + | } | |||
| 136 | + | return pos | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func setCell(f renderer.Frame, x, y int, r rune, style renderer.Style) { | |||
| 140 | + | if x < 0 || x >= f.Width || y < 0 || y >= f.Height { | |||
| 141 | + | return | |||
| 142 | + | } | |||
| 143 | + | f.Set(x, y, renderer.Cell{Rune: r, Style: style}) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func percentOf(n, pct int) int { | |||
| 147 | + | if pct <= 0 { | |||
| 148 | + | return n | |||
| 149 | + | } | |||
| 150 | + | return n * pct / 100 | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func clamp(n, low, high int) int { | |||
| 154 | + | if n < low { | |||
| 155 | + | return low | |||
| 156 | + | } | |||
| 157 | + | if n > high { | |||
| 158 | + | return high | |||
| 159 | + | } | |||
| 160 | + | return n | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func textWidth(text string) int { | |||
| 164 | + | width := 0 | |||
| 165 | + | for _, r := range text { | |||
| 166 | + | width += runeWidth(r) | |||
| 167 | + | } | |||
| 168 | + | return width | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func runeWidth(r rune) int { | |||
| 172 | + | switch { | |||
| 173 | + | case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F): | |||
| 174 | + | return 0 | |||
| 175 | + | case r >= 0x1100 && r <= 0x115F: | |||
| 176 | + | return 2 | |||
| 177 | + | case r == 0x2329 || r == 0x232A: | |||
| 178 | + | return 2 | |||
| 179 | + | case r >= 0x2E80 && r <= 0xA4CF: | |||
| 180 | + | return 2 | |||
| 181 | + | case r >= 0xAC00 && r <= 0xD7FF: | |||
| 182 | + | return 2 | |||
| 183 | + | case r >= 0xF900 && r <= 0xFAFF: | |||
| 184 | + | return 2 | |||
| 185 | + | case r >= 0xFE10 && r <= 0xFE19: | |||
| 186 | + | return 2 | |||
| 187 | + | case r >= 0xFE30 && r <= 0xFE6F: | |||
| 188 | + | return 2 | |||
| 189 | + | case r >= 0xFF01 && r <= 0xFF60: | |||
| 190 | + | return 2 | |||
| 191 | + | case r >= 0xFFE0 && r <= 0xFFE6: | |||
| 192 | + | return 2 | |||
| 193 | + | case r >= 0x1B000 && r <= 0x1B12F: | |||
| 194 | + | return 2 | |||
| 195 | + | case r >= 0x1F000 && r <= 0x1F64F: | |||
| 196 | + | return 2 | |||
| 197 | + | case r >= 0x1F680 && r <= 0x1F6FF: | |||
| 198 | + | return 2 | |||
| 199 | + | case r >= 0x1F900 && r <= 0x1F9FF: | |||
| 200 | + | return 2 | |||
| 201 | + | case r >= 0x1FA70 && r <= 0x1FAFF: | |||
| 202 | + | return 2 | |||
| 203 | + | case r >= 0x20000 && r <= 0x3FFFF: | |||
| 204 | + | return 2 | |||
| 205 | + | default: | |||
| 206 | + | return 1 | |||
| 207 | + | } | |||
| 208 | + | } | |||
A internal/usecase/ui/modal_test.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestModalBounds(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | base domain.Size | |
| 14 | + | m Modal | |
| 15 | + | want domain.Rect | |
| 16 | + | }{ | |
| 17 | + | { | |
| 18 | + | name: "eighty percent centered", | |
| 19 | + | base: domain.Size{Cols: 100, Rows: 40}, | |
| 20 | + | m: Modal{WidthPct: 80, HeightPct: 80}, | |
| 21 | + | want: domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, | |
| 22 | + | }, | |
| 23 | + | { | |
| 24 | + | name: "minimum clamps to base", | |
| 25 | + | base: domain.Size{Cols: 20, Rows: 6}, | |
| 26 | + | m: Modal{WidthPct: 50, HeightPct: 50, MinWidth: 40, MinHeight: 12}, | |
| 27 | + | want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}, | |
| 28 | + | }, | |
| 29 | + | { | |
| 30 | + | name: "odd centering rounds down", | |
| 31 | + | base: domain.Size{Cols: 11, Rows: 7}, | |
| 32 | + | m: Modal{WidthPct: 50, HeightPct: 50}, | |
| 33 | + | want: domain.Rect{X: 3, Y: 2, Width: 5, Height: 3}, | |
| 34 | + | }, | |
| 35 | + | } | |
| 36 | + | for _, tt := range tests { | |
| 37 | + | t.Run(tt.name, func(t *testing.T) { | |
| 38 | + | if got := tt.m.Bounds(tt.base); got != tt.want { | |
| 39 | + | t.Fatalf("Bounds() = %+v, want %+v", got, tt.want) | |
| 40 | + | } | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestModalInnerDegenerate(t *testing.T) { | |
| 46 | + | m := Modal{WidthPct: 100, HeightPct: 100} | |
| 47 | + | got := m.Inner(domain.Size{Cols: 2, Rows: 2}) | |
| 48 | + | want := domain.Rect{X: 1, Y: 1, Width: 0, Height: 0} | |
| 49 | + | if got != want { | |
| 50 | + | t.Fatalf("Inner() = %+v, want %+v", got, want) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) { | |
| 55 | + | f := renderer.NewFrame(10, 6) | |
| 56 | + | exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()} | |
| 57 | + | FillRect(f, domain.Rect{X: 0, Y: 0, Width: 10, Height: 6}, exterior) | |
| 58 | + | ||
| 59 | + | style := renderer.Style{Bold: true, Foreground: 2, Background: -1} | |
| 60 | + | m := Modal{WidthPct: 60, HeightPct: 80, Title: "Hi"} | |
| 61 | + | inner := m.Composite(f, style) | |
| 62 | + | wantInner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 2} | |
| 63 | + | if inner != wantInner { | |
| 64 | + | t.Fatalf("Composite() inner = %+v, want %+v", inner, wantInner) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | assertRune(t, f, 2, 1, '┌') | |
| 68 | + | assertRune(t, f, 7, 1, '┐') | |
| 69 | + | assertRune(t, f, 2, 4, '└') | |
| 70 | + | assertRune(t, f, 7, 4, '┘') | |
| 71 | + | assertRune(t, f, 3, 1, '─') | |
| 72 | + | assertRune(t, f, 2, 2, '│') | |
| 73 | + | assertRune(t, f, 4, 1, 'H') | |
| 74 | + | assertRune(t, f, 5, 1, 'i') | |
| 75 | + | ||
| 76 | + | for y := inner.Y; y < inner.Y+inner.Height; y++ { | |
| 77 | + | for x := inner.X; x < inner.X+inner.Width; x++ { | |
| 78 | + | if got := f.At(x, y); !got.Equal(renderer.BlankCell()) { | |
| 79 | + | t.Fatalf("interior cell (%d,%d) = %+v, want blank", x, y, got) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | assertCell(t, f, 0, 0, exterior) | |
| 84 | + | assertCell(t, f, 9, 5, exterior) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestFillRectClips(t *testing.T) { | |
| 88 | + | f := renderer.NewFrame(3, 2) | |
| 89 | + | cell := renderer.Cell{Rune: 'z', Style: renderer.DefaultStyle()} | |
| 90 | + | FillRect(f, domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, cell) | |
| 91 | + | assertCell(t, f, 0, 0, cell) | |
| 92 | + | assertCell(t, f, 1, 1, cell) | |
| 93 | + | assertCell(t, f, 2, 1, renderer.BlankCell()) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestDrawTextClipsAndDropsWideRuneCrossingClip(t *testing.T) { | |
| 97 | + | style := renderer.Style{Inverse: true, Foreground: -1, Background: -1} | |
| 98 | + | t.Run("clips ascii", func(t *testing.T) { | |
| 99 | + | f := renderer.NewFrame(5, 1) | |
| 100 | + | next := DrawText(f, 1, 0, 3, "abcd", style) | |
| 101 | + | if next != 3 { | |
| 102 | + | t.Fatalf("DrawText next = %d, want 3", next) | |
| 103 | + | } | |
| 104 | + | assertRune(t, f, 1, 0, 'a') | |
| 105 | + | assertRune(t, f, 2, 0, 'b') | |
| 106 | + | assertCell(t, f, 3, 0, renderer.BlankCell()) | |
| 107 | + | }) | |
| 108 | + | t.Run("draws wide rune when it fits", func(t *testing.T) { | |
| 109 | + | f := renderer.NewFrame(5, 1) | |
| 110 | + | next := DrawText(f, 1, 0, 3, "你", style) | |
| 111 | + | if next != 3 { | |
| 112 | + | t.Fatalf("DrawText next = %d, want 3", next) | |
| 113 | + | } | |
| 114 | + | assertRune(t, f, 1, 0, '你') | |
| 115 | + | if got := f.At(2, 0); !got.Continuation { | |
| 116 | + | t.Fatalf("wide continuation = %+v, want continuation", got) | |
| 117 | + | } | |
| 118 | + | }) | |
| 119 | + | t.Run("drops wide rune crossing clip", func(t *testing.T) { | |
| 120 | + | f := renderer.NewFrame(5, 1) | |
| 121 | + | next := DrawText(f, 2, 0, 3, "你A", style) | |
| 122 | + | if next != 2 { | |
| 123 | + | t.Fatalf("DrawText next = %d, want 2", next) | |
| 124 | + | } | |
| 125 | + | assertCell(t, f, 2, 0, renderer.BlankCell()) | |
| 126 | + | assertCell(t, f, 3, 0, renderer.BlankCell()) | |
| 127 | + | }) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func assertRune(t *testing.T, f renderer.Frame, x, y int, want rune) { | |
| 131 | + | t.Helper() | |
| 132 | + | if got := f.At(x, y).Rune; got != want { | |
| 133 | + | t.Fatalf("cell (%d,%d) rune = %q, want %q", x, y, got, want) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func assertCell(t *testing.T, f renderer.Frame, x, y int, want renderer.Cell) { | |
| 138 | + | t.Helper() | |
| 139 | + | if got := f.At(x, y); !got.Equal(want) { | |
| 140 | + | t.Fatalf("cell (%d,%d) = %+v, want %+v", x, y, got, want) | |
| 141 | + | } | |
| 142 | + | } | |
| @@ -0,0 +1,142 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestModalBounds(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | base domain.Size | |||
| 14 | + | m Modal | |||
| 15 | + | want domain.Rect | |||
| 16 | + | }{ | |||
| 17 | + | { | |||
| 18 | + | name: "eighty percent centered", | |||
| 19 | + | base: domain.Size{Cols: 100, Rows: 40}, | |||
| 20 | + | m: Modal{WidthPct: 80, HeightPct: 80}, | |||
| 21 | + | want: domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, | |||
| 22 | + | }, | |||
| 23 | + | { | |||
| 24 | + | name: "minimum clamps to base", | |||
| 25 | + | base: domain.Size{Cols: 20, Rows: 6}, | |||
| 26 | + | m: Modal{WidthPct: 50, HeightPct: 50, MinWidth: 40, MinHeight: 12}, | |||
| 27 | + | want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}, | |||
| 28 | + | }, | |||
| 29 | + | { | |||
| 30 | + | name: "odd centering rounds down", | |||
| 31 | + | base: domain.Size{Cols: 11, Rows: 7}, | |||
| 32 | + | m: Modal{WidthPct: 50, HeightPct: 50}, | |||
| 33 | + | want: domain.Rect{X: 3, Y: 2, Width: 5, Height: 3}, | |||
| 34 | + | }, | |||
| 35 | + | } | |||
| 36 | + | for _, tt := range tests { | |||
| 37 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 38 | + | if got := tt.m.Bounds(tt.base); got != tt.want { | |||
| 39 | + | t.Fatalf("Bounds() = %+v, want %+v", got, tt.want) | |||
| 40 | + | } | |||
| 41 | + | }) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestModalInnerDegenerate(t *testing.T) { | |||
| 46 | + | m := Modal{WidthPct: 100, HeightPct: 100} | |||
| 47 | + | got := m.Inner(domain.Size{Cols: 2, Rows: 2}) | |||
| 48 | + | want := domain.Rect{X: 1, Y: 1, Width: 0, Height: 0} | |||
| 49 | + | if got != want { | |||
| 50 | + | t.Fatalf("Inner() = %+v, want %+v", got, want) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) { | |||
| 55 | + | f := renderer.NewFrame(10, 6) | |||
| 56 | + | exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()} | |||
| 57 | + | FillRect(f, domain.Rect{X: 0, Y: 0, Width: 10, Height: 6}, exterior) | |||
| 58 | + | ||||
| 59 | + | style := renderer.Style{Bold: true, Foreground: 2, Background: -1} | |||
| 60 | + | m := Modal{WidthPct: 60, HeightPct: 80, Title: "Hi"} | |||
| 61 | + | inner := m.Composite(f, style) | |||
| 62 | + | wantInner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 2} | |||
| 63 | + | if inner != wantInner { | |||
| 64 | + | t.Fatalf("Composite() inner = %+v, want %+v", inner, wantInner) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | assertRune(t, f, 2, 1, '┌') | |||
| 68 | + | assertRune(t, f, 7, 1, '┐') | |||
| 69 | + | assertRune(t, f, 2, 4, '└') | |||
| 70 | + | assertRune(t, f, 7, 4, '┘') | |||
| 71 | + | assertRune(t, f, 3, 1, '─') | |||
| 72 | + | assertRune(t, f, 2, 2, '│') | |||
| 73 | + | assertRune(t, f, 4, 1, 'H') | |||
| 74 | + | assertRune(t, f, 5, 1, 'i') | |||
| 75 | + | ||||
| 76 | + | for y := inner.Y; y < inner.Y+inner.Height; y++ { | |||
| 77 | + | for x := inner.X; x < inner.X+inner.Width; x++ { | |||
| 78 | + | if got := f.At(x, y); !got.Equal(renderer.BlankCell()) { | |||
| 79 | + | t.Fatalf("interior cell (%d,%d) = %+v, want blank", x, y, got) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | assertCell(t, f, 0, 0, exterior) | |||
| 84 | + | assertCell(t, f, 9, 5, exterior) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestFillRectClips(t *testing.T) { | |||
| 88 | + | f := renderer.NewFrame(3, 2) | |||
| 89 | + | cell := renderer.Cell{Rune: 'z', Style: renderer.DefaultStyle()} | |||
| 90 | + | FillRect(f, domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, cell) | |||
| 91 | + | assertCell(t, f, 0, 0, cell) | |||
| 92 | + | assertCell(t, f, 1, 1, cell) | |||
| 93 | + | assertCell(t, f, 2, 1, renderer.BlankCell()) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestDrawTextClipsAndDropsWideRuneCrossingClip(t *testing.T) { | |||
| 97 | + | style := renderer.Style{Inverse: true, Foreground: -1, Background: -1} | |||
| 98 | + | t.Run("clips ascii", func(t *testing.T) { | |||
| 99 | + | f := renderer.NewFrame(5, 1) | |||
| 100 | + | next := DrawText(f, 1, 0, 3, "abcd", style) | |||
| 101 | + | if next != 3 { | |||
| 102 | + | t.Fatalf("DrawText next = %d, want 3", next) | |||
| 103 | + | } | |||
| 104 | + | assertRune(t, f, 1, 0, 'a') | |||
| 105 | + | assertRune(t, f, 2, 0, 'b') | |||
| 106 | + | assertCell(t, f, 3, 0, renderer.BlankCell()) | |||
| 107 | + | }) | |||
| 108 | + | t.Run("draws wide rune when it fits", func(t *testing.T) { | |||
| 109 | + | f := renderer.NewFrame(5, 1) | |||
| 110 | + | next := DrawText(f, 1, 0, 3, "你", style) | |||
| 111 | + | if next != 3 { | |||
| 112 | + | t.Fatalf("DrawText next = %d, want 3", next) | |||
| 113 | + | } | |||
| 114 | + | assertRune(t, f, 1, 0, '你') | |||
| 115 | + | if got := f.At(2, 0); !got.Continuation { | |||
| 116 | + | t.Fatalf("wide continuation = %+v, want continuation", got) | |||
| 117 | + | } | |||
| 118 | + | }) | |||
| 119 | + | t.Run("drops wide rune crossing clip", func(t *testing.T) { | |||
| 120 | + | f := renderer.NewFrame(5, 1) | |||
| 121 | + | next := DrawText(f, 2, 0, 3, "你A", style) | |||
| 122 | + | if next != 2 { | |||
| 123 | + | t.Fatalf("DrawText next = %d, want 2", next) | |||
| 124 | + | } | |||
| 125 | + | assertCell(t, f, 2, 0, renderer.BlankCell()) | |||
| 126 | + | assertCell(t, f, 3, 0, renderer.BlankCell()) | |||
| 127 | + | }) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func assertRune(t *testing.T, f renderer.Frame, x, y int, want rune) { | |||
| 131 | + | t.Helper() | |||
| 132 | + | if got := f.At(x, y).Rune; got != want { | |||
| 133 | + | t.Fatalf("cell (%d,%d) rune = %q, want %q", x, y, got, want) | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func assertCell(t *testing.T, f renderer.Frame, x, y int, want renderer.Cell) { | |||
| 138 | + | t.Helper() | |||
| 139 | + | if got := f.At(x, y); !got.Equal(want) { | |||
| 140 | + | t.Fatalf("cell (%d,%d) = %+v, want %+v", x, y, got, want) | |||
| 141 | + | } | |||
| 142 | + | } | |||