Merge pull request #66 from bnema/feature/per-tab-floating-pane
feat(daemon): add per-tab floating terminals
50 files · +3437 -203
M README.md +15 -0
| @@ -49,8 +49,14 @@ vev reads `$XDG_CONFIG_HOME/vev/config`, or `~/.config/vev/config` when `XDG_CON | |||||
| 49 | # Theme: auto follows the client; dark/light force built-in palettes. | 49 | # Theme: auto follows the client; dark/light force built-in palettes. | ||
| 50 | theme = auto | 50 | theme = auto | ||
| 51 | 51 | ||||
| 52 | + | # One prewarmed floating terminal per tab. An empty command uses the normal shell. | |||
| 53 | + | floating.command = | |||
| 54 | + | floating.width = 80% | |||
| 55 | + | floating.height = 80% | |||
| 56 | + | ||||
| 52 | # Rebindable actions. Leave a line out to keep its built-in binding. | 57 | # Rebindable actions. Leave a line out to keep its built-in binding. | ||
| 53 | open-palette = alt+space | 58 | open-palette = alt+space | ||
| 59 | + | toggle-floating-pane = alt+f | |||
| 54 | jump-attention = alt+a | 60 | jump-attention = alt+a | ||
| 55 | focus-pane-left = alt+h | 61 | focus-pane-left = alt+h | ||
| 56 | focus-pane-right = alt+l | 62 | focus-pane-right = alt+l | ||
| @@ -97,6 +103,7 @@ code.back-session = BSK | |||||
| 97 | code.forward-session = FSK | 103 | code.forward-session = FSK | ||
| 98 | code.session-picker = SSP | 104 | code.session-picker = SSP | ||
| 99 | code.visual-mode = VIS | 105 | code.visual-mode = VIS | ||
| 106 | + | code.toggle-floating-pane = FLT | |||
| 100 | code.rename-session = RNS | 107 | code.rename-session = RNS | ||
| 101 | code.detach = DET | 108 | code.detach = DET | ||
| 102 | ``` | 109 | ``` | ||
| @@ -105,6 +112,8 @@ Configuring an action replaces all of its built-in aliases. In the example above | |||||
| 105 | 112 | ||||
| 106 | Key specs support `alt+<char>`, `alt+space`, and `alt+left/right/up/down`; digit key specs support `alt+1` through `alt+9`, not `alt+0`. `jump-attention` first opens the oldest attention tab in the current session; if none exists, it opens the oldest attention tab in another session. Invalid entries are logged as warnings and skipped where possible; duplicate config keys use the last value, binding conflicts keep the later action's defaults, and command-code conflicts drop the conflicting override. | 113 | Key specs support `alt+<char>`, `alt+space`, and `alt+left/right/up/down`; digit key specs support `alt+1` through `alt+9`, not `alt+0`. `jump-attention` first opens the oldest attention tab in the current session; if none exists, it opens the oldest attention tab in another session. Invalid entries are logged as warnings and skipped where possible; duplicate config keys use the last value, binding conflicts keep the later action's defaults, and command-code conflicts drop the conflicting override. | ||
| 107 | 114 | ||||
| 115 | + | `floating.command` runs through the normal shell; leave it empty to launch that shell directly. `floating.width` and `floating.height` require exact percentages from `1%` through `100%`; invalid values log a warning and leave the last accepted value (or the `80%` default) intact. Reloading does not relaunch or immediately alter an installed floating terminal: a changed command is used on its next launch, while changed dimensions take effect when that terminal is next shown or resized. Only configuration—not floating runtime state—is restored after a daemon restart; restored tabs stay cold until first visited. | |||
| 116 | + | ||||
| 108 | ### Bar right anchors | 117 | ### Bar right anchors | ||
| 109 | 118 | ||||
| 110 | The top and bottom bar right anchors run configurable commands on the daemon host. By default, `bar.top-right = vev-bar-top-right` prints CPU, memory, then hour text (for example `C 12% M 48% 09:41`), and `bar.bottom-right = vev-bar-bottom-right` prints compact git state for the focused pane's `VEV_PANE_CWD`. Set either value empty to disable that anchor, or set it to another command to replace it. | 119 | The top and bottom bar right anchors run configurable commands on the daemon host. By default, `bar.top-right = vev-bar-top-right` prints CPU, memory, then hour text (for example `C 12% M 48% 09:41`), and `bar.bottom-right = vev-bar-bottom-right` prints compact git state for the focused pane's `VEV_PANE_CWD`. Set either value empty to disable that anchor, or set it to another command to replace it. | ||
| @@ -145,6 +154,7 @@ All bindings use Alt directly; there is no prefix key. | |||||
| 145 | | Key | Action | | 154 | | Key | Action | | ||
| 146 | |---|---| | 155 | |---|---| | ||
| 147 | | Alt+Space | open the command palette | | 156 | | Alt+Space | open the command palette | | ||
| 157 | + | | Alt+f | show or hide the current tab's floating terminal | | |||
| 148 | | Alt+1 .. Alt+9 | switch to tab by number | | 158 | | Alt+1 .. Alt+9 | switch to tab by number | | ||
| 149 | | Alt+h/j/k/l | focus pane left/down/up/right | | 159 | | Alt+h/j/k/l | focus pane left/down/up/right | | ||
| 150 | | Alt+Arrow | focus pane by direction | | 160 | | Alt+Arrow | focus pane by direction | | ||
| @@ -172,6 +182,7 @@ Successful commands are promoted near the top the next time the palette opens. | |||||
| 172 | | BSK / FSK | move back / forward through recent sessions | | 182 | | BSK / FSK | move back / forward through recent sessions | | ||
| 173 | | SSP | open session picker | | 183 | | SSP | open session picker | | ||
| 174 | | VIS | enter visual mode | | 184 | | VIS | enter visual mode | | ||
| 185 | + | | FLT | show or hide the current tab's floating terminal | | |||
| 175 | | RNS | rename session | | 186 | | RNS | rename session | | ||
| 176 | | RNT | rename current tab | | 187 | | RNT | rename current tab | | ||
| 177 | | DET | detach | | 188 | | DET | detach | | ||
| @@ -193,6 +204,10 @@ Open it with `SSP` in the palette. It lists sessions and tabs, previews the sele | |||||
| 193 | 204 | ||||
| 194 | Tabs hold pane layouts. Panes can be split left/right/up/down, stacked, focused by direction, and closed from the command palette. Clicking a pane focuses it. | 205 | Tabs hold pane layouts. Panes can be split left/right/up/down, stacked, focused by direction, and closed from the command palette. Clicking a pane focuses it. | ||
| 195 | 206 | ||||
| 207 | + | Each tab also owns one independent floating terminal. It prewarms asynchronously on the tab's first visit, opens centered at the configured percentage of the pane-content area, and dims normal pane content while leaving status bars and popup chrome crisp. Hiding it retains the process, screen, and scrollback; switching tabs restores each tab's own visible or hidden state. Process exit clears the slot so the next open recreates it. The title combines the foreground process and OSC terminal title as `ProcessName: ProcessTerminalTitle`, falling back to either available title and then the configured command or shell. | |||
| 208 | + | ||||
| 209 | + | While visible, the floating terminal receives ordinary input, bracketed paste, mouse input, clipboard-path injection, and scrollback/copy mode. Global vev bindings remain active, and a bare Esc is passed to the floating application. `Alt+f` and `FLT` toggle visibility; there is no separate floating-pane close command. | |||
| 210 | + | ||||
| 196 | The top bar shows numbered tabs. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`. | 211 | The top bar shows numbered tabs. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`. | ||
| 197 | 212 | ||||
| 198 | ## Scrollback mode and copy | 213 | ## Scrollback mode and copy | ||
M internal/adapters/config/config.go +45 -1
| @@ -18,7 +18,10 @@ import ( | |||||
| 18 | 18 | ||||
| 19 | const pollInterval = 2 * time.Second | 19 | const pollInterval = 2 * time.Second | ||
| 20 | 20 | ||||
| 21 | - | var processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`) | 21 | + | var ( |
| 22 | + | processNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$`) | |||
| 23 | + | percentagePattern = regexp.MustCompile(`^[0-9]{1,3}%$`) | |||
| 24 | + | ) | |||
| 22 | 25 | ||||
| 23 | // Parse reads vev's flat action = value config format. Duplicate action keys | 26 | // Parse reads vev's flat action = value config format. Duplicate action keys | ||
| 24 | // are accepted with a warning; the last value wins while first-seen action order | 27 | // are accepted with a warning; the last value wins while first-seen action order | ||
| @@ -27,6 +30,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 27 | cfg := domain.Defaults() | 30 | cfg := domain.Defaults() | ||
| 28 | var warnings []domain.Warning | 31 | var warnings []domain.Warning | ||
| 29 | seenBindingKeys := make(map[string]bool) | 32 | seenBindingKeys := make(map[string]bool) | ||
| 33 | + | seenFloatingKeys := make(map[string]bool) | |||
| 30 | 34 | ||||
| 31 | scanner := bufio.NewScanner(r) | 35 | scanner := bufio.NewScanner(r) | ||
| 32 | lineNo := 0 | 36 | lineNo := 0 | ||
| @@ -86,6 +90,30 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 86 | interval = domain.MinBarInterval | 90 | interval = domain.MinBarInterval | ||
| 87 | } | 91 | } | ||
| 88 | cfg.Bar.Interval = interval | 92 | cfg.Bar.Interval = interval | ||
| 93 | + | case key == "floating.command": | |||
| 94 | + | warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo) | |||
| 95 | + | command, ok := parseBarCommand(value) | |||
| 96 | + | if !ok { | |||
| 97 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.command %q", value)}) | |||
| 98 | + | continue | |||
| 99 | + | } | |||
| 100 | + | cfg.Floating.Command = command | |||
| 101 | + | case key == "floating.width": | |||
| 102 | + | warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo) | |||
| 103 | + | width, ok := parsePercentage(value) | |||
| 104 | + | if !ok { | |||
| 105 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.width %q", value)}) | |||
| 106 | + | continue | |||
| 107 | + | } | |||
| 108 | + | cfg.Floating.Width = width | |||
| 109 | + | case key == "floating.height": | |||
| 110 | + | warnings = warnDuplicateFloatingKey(warnings, seenFloatingKeys, key, lineNo) | |||
| 111 | + | height, ok := parsePercentage(value) | |||
| 112 | + | if !ok { | |||
| 113 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid floating.height %q", value)}) | |||
| 114 | + | continue | |||
| 115 | + | } | |||
| 116 | + | cfg.Floating.Height = height | |||
| 89 | case strings.HasPrefix(key, "code."): | 117 | case strings.HasPrefix(key, "code."): | ||
| 90 | codeKey := strings.TrimPrefix(key, "code.") | 118 | codeKey := strings.TrimPrefix(key, "code.") | ||
| 91 | if codeKey == "" { | 119 | if codeKey == "" { | ||
| @@ -189,6 +217,14 @@ func fileStamp(path string) (stamp, error) { | |||||
| 189 | return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil | 217 | return stamp{modTime: st.ModTime(), size: st.Size(), exists: true}, nil | ||
| 190 | } | 218 | } | ||
| 191 | 219 | ||||
| 220 | + | func warnDuplicateFloatingKey(warnings []domain.Warning, seen map[string]bool, key string, lineNo int) []domain.Warning { | |||
| 221 | + | if seen[key] { | |||
| 222 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("duplicate key %q", key)}) | |||
| 223 | + | } | |||
| 224 | + | seen[key] = true | |||
| 225 | + | return warnings | |||
| 226 | + | } | |||
| 227 | + | ||||
| 192 | func updateBindingEntry(entries []domain.ConfigEntry, key, value string) { | 228 | func updateBindingEntry(entries []domain.ConfigEntry, key, value string) { | ||
| 193 | for i := range entries { | 229 | for i := range entries { | ||
| 194 | if entries[i].Key == key { | 230 | if entries[i].Key == key { | ||
| @@ -224,6 +260,14 @@ func parseProcessList(value string, lineNo int) ([]string, []domain.Warning) { | |||||
| 224 | return out, warnings | 260 | return out, warnings | ||
| 225 | } | 261 | } | ||
| 226 | 262 | ||||
| 263 | + | func parsePercentage(value string) (int, bool) { | |||
| 264 | + | if !percentagePattern.MatchString(value) { | |||
| 265 | + | return 0, false | |||
| 266 | + | } | |||
| 267 | + | percentage, err := strconv.Atoi(strings.TrimSuffix(value, "%")) | |||
| 268 | + | return percentage, err == nil && percentage >= 1 && percentage <= 100 | |||
| 269 | + | } | |||
| 270 | + | ||||
| 227 | func parseBarCommand(value string) (string, bool) { | 271 | func parseBarCommand(value string) (string, bool) { | ||
| 228 | value = strings.TrimSpace(value) | 272 | value = strings.TrimSpace(value) | ||
| 229 | if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { | 273 | if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { | ||
M internal/adapters/config/config_test.go +94 -0
| @@ -91,6 +91,97 @@ func TestParse(t *testing.T) { | |||||
| 91 | {Line: 3, Msg: "missing key"}, | 91 | {Line: 3, Msg: "missing key"}, | ||
| 92 | }, | 92 | }, | ||
| 93 | }, | 93 | }, | ||
| 94 | + | { | |||
| 95 | + | name: "floating settings custom values", | |||
| 96 | + | input: "floating.command = \"btop --utf-force\"\nfloating.width = 75%\nfloating.height = 60%\n", | |||
| 97 | + | want: domain.Config{ | |||
| 98 | + | Theme: domain.ThemeAuto, | |||
| 99 | + | Floating: domain.FloatingConfig{ | |||
| 100 | + | Command: "btop --utf-force", | |||
| 101 | + | Width: 75, | |||
| 102 | + | Height: 60, | |||
| 103 | + | }, | |||
| 104 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 105 | + | Codes: map[string]string{}, | |||
| 106 | + | }, | |||
| 107 | + | }, | |||
| 108 | + | { | |||
| 109 | + | name: "floating command can be empty or quoted", | |||
| 110 | + | input: "floating.command = echo ready\nfloating.command = \"\"\n", | |||
| 111 | + | want: domain.Config{ | |||
| 112 | + | Theme: domain.ThemeAuto, | |||
| 113 | + | Floating: domain.FloatingConfig{ | |||
| 114 | + | Width: 80, | |||
| 115 | + | Height: 80, | |||
| 116 | + | }, | |||
| 117 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 118 | + | Codes: map[string]string{}, | |||
| 119 | + | }, | |||
| 120 | + | wantWarnings: []domain.Warning{{Line: 2, Msg: "duplicate key \"floating.command\""}}, | |||
| 121 | + | }, | |||
| 122 | + | { | |||
| 123 | + | name: "floating percentage endpoints", | |||
| 124 | + | input: "floating.width = 1%\nfloating.height = 100%\n", | |||
| 125 | + | want: domain.Config{ | |||
| 126 | + | Theme: domain.ThemeAuto, | |||
| 127 | + | Floating: domain.FloatingConfig{ | |||
| 128 | + | Width: 1, | |||
| 129 | + | Height: 100, | |||
| 130 | + | }, | |||
| 131 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 132 | + | Codes: map[string]string{}, | |||
| 133 | + | }, | |||
| 134 | + | }, | |||
| 135 | + | { | |||
| 136 | + | name: "floating invalid percentages warn and preserve defaults", | |||
| 137 | + | input: "floating.width = 80\nfloating.height = 0%\nfloating.width = 101%\nfloating.height = eighty%\n", | |||
| 138 | + | want: domain.Config{ | |||
| 139 | + | Theme: domain.ThemeAuto, | |||
| 140 | + | Floating: domain.FloatingConfig{ | |||
| 141 | + | Width: 80, | |||
| 142 | + | Height: 80, | |||
| 143 | + | }, | |||
| 144 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 145 | + | Codes: map[string]string{}, | |||
| 146 | + | }, | |||
| 147 | + | wantWarnings: []domain.Warning{ | |||
| 148 | + | {Line: 1, Msg: "invalid floating.width \"80\""}, | |||
| 149 | + | {Line: 2, Msg: "invalid floating.height \"0%\""}, | |||
| 150 | + | {Line: 3, Msg: "duplicate key \"floating.width\""}, | |||
| 151 | + | {Line: 3, Msg: "invalid floating.width \"101%\""}, | |||
| 152 | + | {Line: 4, Msg: "duplicate key \"floating.height\""}, | |||
| 153 | + | {Line: 4, Msg: "invalid floating.height \"eighty%\""}, | |||
| 154 | + | }, | |||
| 155 | + | }, | |||
| 156 | + | { | |||
| 157 | + | name: "floating duplicate keys warn and last valid value wins", | |||
| 158 | + | input: "floating.command = btop\nfloating.command = lazygit\nfloating.width = 70%\nfloating.width = 90%\nfloating.height = 60%\nfloating.height = 85%\n", | |||
| 159 | + | want: domain.Config{ | |||
| 160 | + | Theme: domain.ThemeAuto, | |||
| 161 | + | Floating: domain.FloatingConfig{ | |||
| 162 | + | Command: "lazygit", | |||
| 163 | + | Width: 90, | |||
| 164 | + | Height: 85, | |||
| 165 | + | }, | |||
| 166 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 167 | + | Codes: map[string]string{}, | |||
| 168 | + | }, | |||
| 169 | + | wantWarnings: []domain.Warning{ | |||
| 170 | + | {Line: 2, Msg: "duplicate key \"floating.command\""}, | |||
| 171 | + | {Line: 4, Msg: "duplicate key \"floating.width\""}, | |||
| 172 | + | {Line: 6, Msg: "duplicate key \"floating.height\""}, | |||
| 173 | + | }, | |||
| 174 | + | }, | |||
| 175 | + | { | |||
| 176 | + | name: "floating malformed quoted command warns and keeps default", | |||
| 177 | + | input: "floating.command = \"bad\\q\"\n", | |||
| 178 | + | want: domain.Config{ | |||
| 179 | + | Theme: domain.ThemeAuto, | |||
| 180 | + | BindingEntries: []domain.ConfigEntry{}, | |||
| 181 | + | Codes: map[string]string{}, | |||
| 182 | + | }, | |||
| 183 | + | wantWarnings: []domain.Warning{{Line: 1, Msg: "invalid floating.command \"\\\"bad\\\\q\\\"\""}}, | |||
| 184 | + | }, | |||
| 94 | { | 185 | { | ||
| 95 | name: "bar settings custom values", | 186 | name: "bar settings custom values", | ||
| 96 | input: "bar.top-right = custom-top\nbar.bottom-right = custom-bottom\nbar.interval = 2s\n", | 187 | input: "bar.top-right = custom-top\nbar.bottom-right = custom-bottom\nbar.interval = 2s\n", | ||
| @@ -244,6 +335,9 @@ func TestParse(t *testing.T) { | |||||
| 244 | if tt.want.Bar.Interval == 0 { | 335 | if tt.want.Bar.Interval == 0 { | ||
| 245 | tt.want.Bar = domain.Defaults().Bar | 336 | tt.want.Bar = domain.Defaults().Bar | ||
| 246 | } | 337 | } | ||
| 338 | + | if tt.want.Floating.Width == 0 && tt.want.Floating.Height == 0 { | |||
| 339 | + | tt.want.Floating = domain.Defaults().Floating | |||
| 340 | + | } | |||
| 247 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | 341 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | ||
| 248 | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | 342 | tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...) | ||
| 249 | } | 343 | } | ||
M internal/domain/config.go +12 -0
| @@ -47,6 +47,13 @@ type BarConfig struct { | |||||
| 47 | Interval time.Duration | 47 | Interval time.Duration | ||
| 48 | } | 48 | } | ||
| 49 | 49 | ||||
| 50 | + | // FloatingConfig contains launch and sizing settings for per-tab floating panes. | |||
| 51 | + | type FloatingConfig struct { | |||
| 52 | + | Command string | |||
| 53 | + | Width int | |||
| 54 | + | Height int | |||
| 55 | + | } | |||
| 56 | + | ||||
| 50 | // Config is the user-editable vev configuration after parsing. Unknown binding | 57 | // Config is the user-editable vev configuration after parsing. Unknown binding | ||
| 51 | // keys are preserved here (in BindingEntries, in file order) so the usecase | 58 | // keys are preserved here (in BindingEntries, in file order) so the usecase | ||
| 52 | // layer can decide which actions it understands. | 59 | // layer can decide which actions it understands. | ||
| @@ -56,6 +63,7 @@ type Config struct { | |||||
| 56 | BindingEntries []ConfigEntry | 63 | BindingEntries []ConfigEntry | ||
| 57 | Codes map[string]string | 64 | Codes map[string]string | ||
| 58 | Snapshot SnapshotConfig | 65 | Snapshot SnapshotConfig | ||
| 66 | + | Floating FloatingConfig | |||
| 59 | } | 67 | } | ||
| 60 | 68 | ||||
| 61 | // Warning describes a non-fatal config problem. Parsers and reloaders should | 69 | // Warning describes a non-fatal config problem. Parsers and reloaders should | ||
| @@ -79,5 +87,9 @@ func Defaults() Config { | |||||
| 79 | Snapshot: SnapshotConfig{ | 87 | Snapshot: SnapshotConfig{ | ||
| 80 | RestoreProcesses: DefaultSnapshotRestoreProcesses(), | 88 | RestoreProcesses: DefaultSnapshotRestoreProcesses(), | ||
| 81 | }, | 89 | }, | ||
| 90 | + | Floating: FloatingConfig{ | |||
| 91 | + | Width: 80, | |||
| 92 | + | Height: 80, | |||
| 93 | + | }, | |||
| 82 | } | 94 | } | ||
| 83 | } | 95 | } | ||
M internal/usecase/command/command.go +1 -0
| @@ -11,6 +11,7 @@ type Context interface { | |||||
| 11 | SplitDown() error | 11 | SplitDown() error | ||
| 12 | StackPane() error | 12 | StackPane() error | ||
| 13 | ToggleStack() error | 13 | ToggleStack() error | ||
| 14 | + | ToggleFloatingPane() error | |||
| 14 | ClosePane() error | 15 | ClosePane() error | ||
| 15 | FocusPaneLeft() error | 16 | FocusPaneLeft() error | ||
| 16 | FocusPaneRight() error | 17 | FocusPaneRight() error | ||
M internal/usecase/command/context_mock_test.go +44 -0
| @@ -1003,6 +1003,50 @@ func (_c *MockContext_StackPane_Call) RunAndReturn(run func() error) *MockContex | |||||
| 1003 | return _c | 1003 | return _c | ||
| 1004 | } | 1004 | } | ||
| 1005 | 1005 | ||||
| 1006 | + | // ToggleFloatingPane provides a mock function for the type MockContext | |||
| 1007 | + | func (_mock *MockContext) ToggleFloatingPane() error { | |||
| 1008 | + | ret := _mock.Called() | |||
| 1009 | + | ||||
| 1010 | + | if len(ret) == 0 { | |||
| 1011 | + | panic("no return value specified for ToggleFloatingPane") | |||
| 1012 | + | } | |||
| 1013 | + | ||||
| 1014 | + | var r0 error | |||
| 1015 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 1016 | + | r0 = returnFunc() | |||
| 1017 | + | } else { | |||
| 1018 | + | r0 = ret.Error(0) | |||
| 1019 | + | } | |||
| 1020 | + | return r0 | |||
| 1021 | + | } | |||
| 1022 | + | ||||
| 1023 | + | // MockContext_ToggleFloatingPane_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ToggleFloatingPane' | |||
| 1024 | + | type MockContext_ToggleFloatingPane_Call struct { | |||
| 1025 | + | *mock.Call | |||
| 1026 | + | } | |||
| 1027 | + | ||||
| 1028 | + | // ToggleFloatingPane is a helper method to define mock.On call | |||
| 1029 | + | func (_e *MockContext_Expecter) ToggleFloatingPane() *MockContext_ToggleFloatingPane_Call { | |||
| 1030 | + | return &MockContext_ToggleFloatingPane_Call{Call: _e.mock.On("ToggleFloatingPane")} | |||
| 1031 | + | } | |||
| 1032 | + | ||||
| 1033 | + | func (_c *MockContext_ToggleFloatingPane_Call) Run(run func()) *MockContext_ToggleFloatingPane_Call { | |||
| 1034 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1035 | + | run() | |||
| 1036 | + | }) | |||
| 1037 | + | return _c | |||
| 1038 | + | } | |||
| 1039 | + | ||||
| 1040 | + | func (_c *MockContext_ToggleFloatingPane_Call) Return(err error) *MockContext_ToggleFloatingPane_Call { | |||
| 1041 | + | _c.Call.Return(err) | |||
| 1042 | + | return _c | |||
| 1043 | + | } | |||
| 1044 | + | ||||
| 1045 | + | func (_c *MockContext_ToggleFloatingPane_Call) RunAndReturn(run func() error) *MockContext_ToggleFloatingPane_Call { | |||
| 1046 | + | _c.Call.Return(run) | |||
| 1047 | + | return _c | |||
| 1048 | + | } | |||
| 1049 | + | ||||
| 1006 | // ToggleStack provides a mock function for the type MockContext | 1050 | // ToggleStack provides a mock function for the type MockContext | ||
| 1007 | func (_mock *MockContext) ToggleStack() error { | 1051 | func (_mock *MockContext) ToggleStack() error { | ||
| 1008 | ret := _mock.Called() | 1052 | ret := _mock.Called() | ||
M internal/usecase/command/registry.go +1 -0
| @@ -14,6 +14,7 @@ func Registry() []Command { | |||||
| 14 | {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }}, | 14 | {Slug: "split-down", Code: "SPD", Name: "Split down", Desc: "Split the focused pane downward", Run: func(ctx Context) error { return ctx.SplitDown() }}, | ||
| 15 | {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }}, | 15 | {Slug: "stack-pane", Code: "STP", Name: "Stack pane", Desc: "Create a new pane in a stack", Run: func(ctx Context) error { return ctx.StackPane() }}, | ||
| 16 | {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }}, | 16 | {Slug: "toggle-stack", Code: "TST", Name: "Toggle stack", Desc: "Toggle the focused pane stack", Run: func(ctx Context) error { return ctx.ToggleStack() }}, | ||
| 17 | + | {Slug: "toggle-floating-pane", Code: "FLT", Name: "Toggle floating pane", Desc: "Toggle the floating pane", Run: func(ctx Context) error { return ctx.ToggleFloatingPane() }}, | |||
| 17 | {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }}, | 18 | {Slug: "close-pane", Code: "CLP", Name: "Close pane", Desc: "Close the focused pane", Run: func(ctx Context) error { return ctx.ClosePane() }}, | ||
| 18 | {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }}, | 19 | {Slug: "focus-pane-left", Code: "FPL", Name: "Focus pane left", Desc: "Focus the pane to the left", Run: func(ctx Context) error { return ctx.FocusPaneLeft() }}, | ||
| 19 | {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }}, | 20 | {Slug: "focus-pane-right", Code: "FPR", Name: "Focus pane right", Desc: "Focus the pane to the right", Run: func(ctx Context) error { return ctx.FocusPaneRight() }}, | ||
M internal/usecase/command/registry_test.go +12 -1
| @@ -7,7 +7,7 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | 8 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | ||
| 9 | commands := Registry() | 9 | commands := Registry() | ||
| 10 | - | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"} | 10 | + | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"} |
| 11 | 11 | ||||
| 12 | if len(commands) != len(wantCodes) { | 12 | if len(commands) != len(wantCodes) { | ||
| 13 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | 13 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | ||
| @@ -37,6 +37,16 @@ func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |||||
| 37 | } | 37 | } | ||
| 38 | } | 38 | } | ||
| 39 | 39 | ||||
| 40 | + | func TestRegistryIncludesFloatingPaneToggle(t *testing.T) { | |||
| 41 | + | cmd, ok := ByCode("FLT") | |||
| 42 | + | if !ok { | |||
| 43 | + | t.Fatal("ByCode(\"FLT\") ok = false, want true") | |||
| 44 | + | } | |||
| 45 | + | if cmd.Slug != "toggle-floating-pane" || cmd.Code != "FLT" || cmd.Name != "Toggle floating pane" { | |||
| 46 | + | t.Fatalf("floating command = %#v, want toggle-floating-pane/FLT/Toggle floating pane", cmd) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 40 | func TestByCodeIsCaseInsensitive(t *testing.T) { | 50 | func TestByCodeIsCaseInsensitive(t *testing.T) { | ||
| 41 | for _, code := range []string{"CNT", "cnt", "CnT"} { | 51 | for _, code := range []string{"CNT", "cnt", "CnT"} { | ||
| 42 | cmd, ok := ByCode(code) | 52 | cmd, ok := ByCode(code) | ||
| @@ -67,6 +77,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||||
| 67 | {code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }}, | 77 | {code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }}, | ||
| 68 | {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }}, | 78 | {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }}, | ||
| 69 | {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }}, | 79 | {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }}, | ||
| 80 | + | {code: "FLT", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().Return(nil).Once() }}, | |||
| 70 | {code: "CLP", expect: func(ctx *MockContext) { ctx.EXPECT().ClosePane().Return(nil).Once() }}, | 81 | {code: "CLP", expect: func(ctx *MockContext) { ctx.EXPECT().ClosePane().Return(nil).Once() }}, | ||
| 71 | {code: "FPL", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneLeft().Return(nil).Once() }}, | 82 | {code: "FPL", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneLeft().Return(nil).Once() }}, | ||
| 72 | {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }}, | 83 | {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }}, | ||
M internal/usecase/daemon/attention.go +1 -0
| @@ -48,6 +48,7 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) { | |||||
| 48 | } | 48 | } | ||
| 49 | if idx, ok := oldestAttentionTab(sess); ok { | 49 | if idx, ok := oldestAttentionTab(sess); ok { | ||
| 50 | if sess.switchTab(idx) { | 50 | if sess.switchTab(idx) { | ||
| 51 | + | d.activateTab(sess, sess.activeTab()) | |||
| 51 | d.paint(sess, ac, true) | 52 | d.paint(sess, ac, true) | ||
| 52 | } | 53 | } | ||
| 53 | return | 54 | return | ||
M internal/usecase/daemon/attention_test.go +5 -0
| @@ -440,6 +440,10 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||||
| 440 | 440 | ||||
| 441 | func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) { | 441 | func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) { | ||
| 442 | d, sess, ac, sends, releases := newManualTabSession(t, 3) | 442 | d, sess, ac, sends, releases := newManualTabSession(t, 3) | ||
| 443 | + | sess.mu.Lock() | |||
| 444 | + | sess.client = ac | |||
| 445 | + | sess.mu.Unlock() | |||
| 446 | + | d.ptys = newBlockingOpenFactory(t, d) | |||
| 443 | defer releases[0]() | 447 | defer releases[0]() | ||
| 444 | defer releases[1]() | 448 | defer releases[1]() | ||
| 445 | defer releases[2]() | 449 | defer releases[2]() | ||
| @@ -454,6 +458,7 @@ func TestAltAJumpAttentionSelectsOldestLocalTab(t *testing.T) { | |||||
| 454 | d.handleInput(sess, ac, []byte("\x1ba")) | 458 | d.handleInput(sess, ac, []byte("\x1ba")) | ||
| 455 | 459 | ||||
| 456 | require.Equal(t, 2, activeTabIndex(sess)) | 460 | require.Equal(t, 2, activeTabIndex(sess)) | ||
| 461 | + | requireFloatingInitialized(t, sess.activeTab()) | |||
| 457 | _ = mustOutputData(t, sends) | 462 | _ = mustOutputData(t, sends) | ||
| 458 | } | 463 | } | ||
| 459 | 464 | ||||
M internal/usecase/daemon/client.go +5 -0
| @@ -443,10 +443,12 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||||
| 443 | 443 | ||||
| 444 | if clientSize.Valid() && wsz != tabSize(clientSize) { | 444 | if clientSize.Valid() && wsz != tabSize(clientSize) { | ||
| 445 | d.resize(sess, ac, clientSize) | 445 | d.resize(sess, ac, clientSize) | ||
| 446 | + | d.activateTab(sess, sess.activeTab()) | |||
| 446 | return | 447 | return | ||
| 447 | } | 448 | } | ||
| 448 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | 449 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | ||
| 449 | d.paint(sess, ac, true) | 450 | d.paint(sess, ac, true) | ||
| 451 | + | d.activateTab(sess, tb) | |||
| 450 | } | 452 | } | ||
| 451 | 453 | ||||
| 452 | // runConnLoop is the per-connection input router: it pumps client messages | 454 | // runConnLoop is the per-connection input router: it pumps client messages | ||
| @@ -594,6 +596,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||||
| 594 | d.applyLayoutLocked(tb) | 596 | d.applyLayoutLocked(tb) | ||
| 595 | tb.mu.Unlock() | 597 | tb.mu.Unlock() | ||
| 596 | } | 598 | } | ||
| 599 | + | // Only the shown tab's floating terminal tracks the client size. This is | |||
| 600 | + | // outside tab.mu so its PTY resize cannot block tab state. | |||
| 601 | + | d.resizeActiveFloating(sess.activeTab()) | |||
| 597 | markSnapshotDirty(sess) | 602 | markSnapshotDirty(sess) | ||
| 598 | if ac != nil { | 603 | if ac != nil { | ||
| 599 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | 604 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | ||
M internal/usecase/daemon/clipboard.go +1 -1
| @@ -91,7 +91,7 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) { | |||||
| 91 | return | 91 | return | ||
| 92 | } | 92 | } | ||
| 93 | tb.mu.Lock() | 93 | tb.mu.Lock() | ||
| 94 | - | p := tb.focusedPane() | 94 | + | p := tb.terminalTargetLocked() |
| 95 | tb.mu.Unlock() | 95 | tb.mu.Unlock() | ||
| 96 | if p == nil { | 96 | if p == nil { | ||
| 97 | return | 97 | return | ||
M internal/usecase/daemon/clipboard_test.go +19 -0
| @@ -13,11 +13,30 @@ import ( | |||||
| 13 | "github.com/stretchr/testify/mock" | 13 | "github.com/stretchr/testify/mock" | ||
| 14 | "github.com/stretchr/testify/require" | 14 | "github.com/stretchr/testify/require" | ||
| 15 | 15 | ||||
| 16 | + | "github.com/bnema/vev/internal/domain" | |||
| 16 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 18 | scopy "github.com/bnema/vev/internal/usecase/copy" | 19 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 19 | ) | 20 | ) | ||
| 20 | 21 | ||||
| 22 | + | func TestInjectClipboardPathTargetsVisibleFloatingPane(t *testing.T) { | |||
| 23 | + | normalWrites := make(chan []byte, 1) | |||
| 24 | + | floatingWrites := make(chan []byte, 1) | |||
| 25 | + | normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites) | |||
| 26 | + | floatingPTY, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites) | |||
| 27 | + | defer releaseNormal() | |||
| 28 | + | defer releaseFloating() | |||
| 29 | + | d, sess, _, _ := newManualSessionWithPTYs(t, normal) | |||
| 30 | + | floating := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 5}) | |||
| 31 | + | floating.screen.Write([]byte("\x1b[?2004h")) | |||
| 32 | + | installTestFloating(sess.activeTab(), floating, true) | |||
| 33 | + | ||||
| 34 | + | d.injectClipboardPath(sess, "/tmp/image.png") | |||
| 35 | + | ||||
| 36 | + | requirePTYWrite(t, floatingWrites, []byte("\x1b[200~/tmp/image.png\x1b[201~")) | |||
| 37 | + | requireNoPTYWrite(t, normalWrites) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 21 | func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) { | 40 | func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) { | ||
| 22 | writes := make(chan []byte, 1) | 41 | writes := make(chan []byte, 1) | ||
| 23 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | 42 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | ||
M internal/usecase/daemon/config.go +25 -8
| @@ -27,6 +27,8 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||||
| 27 | d.bindings.Store(bindings) | 27 | d.bindings.Store(bindings) | ||
| 28 | d.codeOverrides.Store(&overrides) | 28 | d.codeOverrides.Store(&overrides) | ||
| 29 | d.restoreProcessAllowlist.Store(&allowlist) | 29 | d.restoreProcessAllowlist.Store(&allowlist) | ||
| 30 | + | floating := cfg.Floating | |||
| 31 | + | d.floatingConfig.Store(&floating) | |||
| 30 | d.themeMode.Store(uint32(cfg.Theme)) | 32 | d.themeMode.Store(uint32(cfg.Theme)) | ||
| 31 | if d.barScripts != nil { | 33 | if d.barScripts != nil { | ||
| 32 | d.barScripts.mu.Lock() | 34 | d.barScripts.mu.Lock() | ||
| @@ -176,6 +178,13 @@ func (d *Daemon) logConfigWarning(w domain.Warning) { | |||||
| 176 | d.log.Warn("config warning", "msg", w.Msg) | 178 | d.log.Warn("config warning", "msg", w.Msg) | ||
| 177 | } | 179 | } | ||
| 178 | 180 | ||||
| 181 | + | func (d *Daemon) currentFloatingConfig() domain.FloatingConfig { | |||
| 182 | + | if cfg := d.floatingConfig.Load(); cfg != nil { | |||
| 183 | + | return *cfg | |||
| 184 | + | } | |||
| 185 | + | return domain.Defaults().Floating | |||
| 186 | + | } | |||
| 187 | + | ||||
| 179 | func (d *Daemon) codeOverrideSnapshot() map[string]string { | 188 | func (d *Daemon) codeOverrideSnapshot() map[string]string { | ||
| 180 | overrides := d.codeOverrides.Load() | 189 | overrides := d.codeOverrides.Load() | ||
| 181 | if overrides == nil { | 190 | if overrides == nil { | ||
| @@ -223,13 +232,21 @@ func (d *Daemon) reapplyThemeAllSessions() { | |||||
| 223 | d.mu.Unlock() | 232 | d.mu.Unlock() | ||
| 224 | 233 | ||||
| 225 | for _, sess := range sessions { | 234 | for _, sess := range sessions { | ||
| 226 | - | sess.mu.Lock() | 235 | + | d.reapplyThemeSession(sess) |
| 227 | - | ac := sess.client | 236 | + | } |
| 228 | - | sess.mu.Unlock() | 237 | + | } |
| 229 | - | var clientTheme theme.Theme | 238 | + | |
| 230 | - | if ac != nil { | 239 | + | func (d *Daemon) reapplyThemeSession(sess *session) { |
| 231 | - | clientTheme = ac.getClientTheme() | 240 | + | sess.mu.Lock() |
| 232 | - | } | 241 | + | ac := sess.client |
| 233 | - | d.applyHostTheme(sess, ac, d.effectiveTheme(clientTheme), ac == nil) | 242 | + | sess.mu.Unlock() |
| 243 | + | if ac == nil { | |||
| 244 | + | d.applyHostTheme(sess, nil, d.effectiveTheme(theme.Theme{}), true) | |||
| 245 | + | return | |||
| 234 | } | 246 | } | ||
| 247 | + | ac.sendMu.Lock() | |||
| 248 | + | defer ac.sendMu.Unlock() | |||
| 249 | + | sess.themeMu.Lock() | |||
| 250 | + | defer sess.themeMu.Unlock() | |||
| 251 | + | d.applyHostThemeLocked(sess, ac, d.effectiveTheme(ac.getClientTheme()), false) | |||
| 235 | } | 252 | } | ||
M internal/usecase/daemon/config_test.go +18 -0
| @@ -49,6 +49,24 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | |||||
| 49 | require.Equal(t, "NT", cmd.Code) | 49 | require.Equal(t, "NT", cmd.Code) | ||
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | + | func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) { | |||
| 53 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 54 | + | require.Equal(t, domain.Defaults().Floating, d.currentFloatingConfig()) | |||
| 55 | + | ||||
| 56 | + | firstConfig := domain.Defaults() | |||
| 57 | + | firstConfig.Floating = domain.FloatingConfig{Command: "btop", Width: 70, Height: 60} | |||
| 58 | + | d.ApplyConfig(firstConfig) | |||
| 59 | + | first := d.currentFloatingConfig() | |||
| 60 | + | ||||
| 61 | + | secondConfig := domain.Defaults() | |||
| 62 | + | secondConfig.Floating = domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85} | |||
| 63 | + | d.ApplyConfig(secondConfig) | |||
| 64 | + | second := d.currentFloatingConfig() | |||
| 65 | + | ||||
| 66 | + | require.Equal(t, domain.FloatingConfig{Command: "btop", Width: 70, Height: 60}, first) | |||
| 67 | + | require.Equal(t, domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}, second) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 52 | func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) { | 70 | func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) { | ||
| 53 | tests := []struct { | 71 | tests := []struct { | ||
| 54 | name string | 72 | name string | ||
M internal/usecase/daemon/copymode.go +46 -54
| @@ -31,7 +31,6 @@ import ( | |||||
| 31 | "github.com/bnema/vev/internal/ports" | 31 | "github.com/bnema/vev/internal/ports" | ||
| 32 | scopy "github.com/bnema/vev/internal/usecase/copy" | 32 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 33 | "github.com/bnema/vev/internal/usecase/keys" | 33 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 34 | - | "github.com/bnema/vev/internal/usecase/layout" | |||
| 35 | "github.com/bnema/vev/internal/usecase/mouse" | 34 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| 36 | "github.com/bnema/vev/internal/usecase/ui" | 35 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 37 | "github.com/bnema/vev/internal/usecase/visualsearch" | 36 | "github.com/bnema/vev/internal/usecase/visualsearch" | ||
| @@ -40,22 +39,24 @@ import ( | |||||
| 40 | 39 | ||||
| 41 | var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: ui.AnchorBottom, BottomMargin: 1} | 40 | var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: ui.AnchorBottom, BottomMargin: 1} | ||
| 42 | 41 | ||||
| 43 | - | func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | 42 | + | func copyTargetPane(rt *overlayRuntime) *pane { |
| 44 | - | rt := ac.overlays | 43 | + | if rt == nil { |
| 45 | - | tb := sess.activeTab() | 44 | + | return nil |
| 46 | - | if tb == nil { | |||
| 47 | - | return | |||
| 48 | } | 45 | } | ||
| 46 | + | rt.copyMu.Lock() | |||
| 47 | + | defer rt.copyMu.Unlock() | |||
| 48 | + | return rt.copyPane | |||
| 49 | + | } | |||
| 49 | 50 | ||||
| 50 | - | tb.mu.Lock() | 51 | + | func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { |
| 51 | - | p := tb.focusedPane() | 52 | + | rt := ac.overlays |
| 52 | - | tb.mu.Unlock() | 53 | + | p := copyTargetPane(rt) |
| 53 | if p == nil { | 54 | if p == nil { | ||
| 54 | return | 55 | return | ||
| 55 | } | 56 | } | ||
| 56 | p.mu.Lock() | 57 | p.mu.Lock() | ||
| 57 | rt.copyMu.Lock() | 58 | rt.copyMu.Lock() | ||
| 58 | - | if rt.copyMode == nil { | 59 | + | if rt.copyMode == nil || rt.copyPane != p { |
| 59 | rt.copyMu.Unlock() | 60 | rt.copyMu.Unlock() | ||
| 60 | p.mu.Unlock() | 61 | p.mu.Unlock() | ||
| 61 | return | 62 | return | ||
| @@ -86,7 +87,8 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||||
| 86 | return | 87 | return | ||
| 87 | } | 88 | } | ||
| 88 | tb.mu.Lock() | 89 | tb.mu.Lock() | ||
| 89 | - | p := tb.focusedPane() | 90 | + | p := tb.terminalTargetLocked() |
| 91 | + | floatingSource := tb.floating.state == floatingVisible && tb.floating.pane == p | |||
| 90 | tb.mu.Unlock() | 92 | tb.mu.Unlock() | ||
| 91 | if p == nil { | 93 | if p == nil { | ||
| 92 | return | 94 | return | ||
| @@ -96,12 +98,21 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||||
| 96 | p.mu.Unlock() | 98 | p.mu.Unlock() | ||
| 97 | rt.copyMu.Lock() | 99 | rt.copyMu.Lock() | ||
| 98 | rt.copyMode = scopy.NewMode(snap) | 100 | rt.copyMode = scopy.NewMode(snap) | ||
| 101 | + | rt.copyPane = p | |||
| 99 | rt.copySearch = nil | 102 | rt.copySearch = nil | ||
| 100 | rt.copySearchPending = nil | 103 | rt.copySearchPending = nil | ||
| 101 | rt.copyPressRowValid = false | 104 | rt.copyPressRowValid = false | ||
| 102 | rt.copyDragging = false | 105 | rt.copyDragging = false | ||
| 103 | rt.normalMousePressValid = false | 106 | rt.normalMousePressValid = false | ||
| 104 | rt.copyMu.Unlock() | 107 | rt.copyMu.Unlock() | ||
| 108 | + | if floatingSource { | |||
| 109 | + | tb.mu.Lock() | |||
| 110 | + | stillVisible := tb.floating.state == floatingVisible && tb.floating.pane == p | |||
| 111 | + | tb.mu.Unlock() | |||
| 112 | + | if !stillVisible { | |||
| 113 | + | rt.clearCopyModeForPane(p) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 105 | d.paint(sess, ac, true) | 116 | d.paint(sess, ac, true) | ||
| 106 | } | 117 | } | ||
| 107 | 118 | ||||
| @@ -110,14 +121,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |||||
| 110 | if ev.Button != mouse.Left { | 121 | if ev.Button != mouse.Left { | ||
| 111 | return | 122 | return | ||
| 112 | } | 123 | } | ||
| 113 | - | tb := sess.activeTab() | 124 | + | p := copyTargetPane(rt) |
| 114 | - | if tb == nil { | |||
| 115 | - | return | |||
| 116 | - | } | |||
| 117 | - | ||||
| 118 | - | tb.mu.Lock() | |||
| 119 | - | p := tb.focusedPane() | |||
| 120 | - | tb.mu.Unlock() | |||
| 121 | if p == nil { | 125 | if p == nil { | ||
| 122 | return | 126 | return | ||
| 123 | } | 127 | } | ||
| @@ -133,7 +137,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |||||
| 133 | return | 137 | return | ||
| 134 | } | 138 | } | ||
| 135 | rt.copyMu.Lock() | 139 | rt.copyMu.Lock() | ||
| 136 | - | if rt.copyMode == nil { | 140 | + | if rt.copyMode == nil || rt.copyPane != p { |
| 137 | rt.copyMu.Unlock() | 141 | rt.copyMu.Unlock() | ||
| 138 | p.mu.Unlock() | 142 | p.mu.Unlock() | ||
| 139 | return | 143 | return | ||
| @@ -175,20 +179,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||||
| 175 | if sess == nil { | 179 | if sess == nil { | ||
| 176 | return | 180 | return | ||
| 177 | } | 181 | } | ||
| 178 | - | tb := sess.activeTab() | 182 | + | p := copyTargetPane(rt) |
| 179 | - | if tb == nil { | |||
| 180 | - | return | |||
| 181 | - | } | |||
| 182 | - | ||||
| 183 | - | tb.mu.Lock() | |||
| 184 | - | p := tb.focusedPane() | |||
| 185 | - | tb.mu.Unlock() | |||
| 186 | if p == nil { | 183 | if p == nil { | ||
| 187 | return | 184 | return | ||
| 188 | } | 185 | } | ||
| 189 | p.mu.Lock() | 186 | p.mu.Lock() | ||
| 190 | rt.copyMu.Lock() | 187 | rt.copyMu.Lock() | ||
| 191 | - | if rt.copyMode == nil { | 188 | + | if rt.copyMode == nil || rt.copyPane != p { |
| 192 | rt.copyPending = nil | 189 | rt.copyPending = nil | ||
| 193 | d.stopCopyPendingTimerLocked(ac) | 190 | d.stopCopyPendingTimerLocked(ac) | ||
| 194 | rt.copyMu.Unlock() | 191 | rt.copyMu.Unlock() | ||
| @@ -420,36 +417,31 @@ func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame | |||||
| 420 | return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render) | 417 | return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render) | ||
| 421 | } | 418 | } | ||
| 422 | 419 | ||||
| 423 | - | func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) { | 420 | + | func composeCopyClientFrame(mode *scopy.Mode, p *pane, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) { |
| 424 | - | styles := newThemeStyles(bars.theme) | 421 | + | if mode == nil || p == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 { |
| 425 | - | p := tb.focusedPane() | 422 | + | return frame, nil |
| 426 | - | if p == nil { | |||
| 427 | - | return renderer.NewFrame(0, 0), nil | |||
| 428 | } | 423 | } | ||
| 424 | + | styles := newThemeStyles(bars.theme) | |||
| 429 | p.mu.Lock() | 425 | p.mu.Lock() | ||
| 430 | - | paneFrame := p.screen.Frame | 426 | + | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) |
| 431 | - | paneWidth, paneRows := paneFrame.Width, paneFrame.Height | |||
| 432 | - | snap := scopy.NewSnapshot(p.scrollback, paneFrame) | |||
| 433 | p.mu.Unlock() | 427 | p.mu.Unlock() | ||
| 434 | copyFrame := mode.Render(snap, styles.copyStatus, styles.selection) | 428 | copyFrame := mode.Render(snap, styles.copyStatus, styles.selection) | ||
| 435 | - | pl, ok := focusedPlacementLocked(tb) | 429 | + | bodyRows := max(copyFrame.Height-1, 0) |
| 436 | - | if !ok { | 430 | + | for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ { |
| 437 | - | pl = layout.Placement{Content: domain.Rect{Width: paneWidth, Height: paneRows}} | 431 | + | dstX := max(target.X, 0) |
| 438 | - | } | 432 | + | srcX := max(-target.X, 0) |
| 439 | - | width, screenRows := paneWidth, paneRows | 433 | + | width := min(target.Width-srcX, copyFrame.Width-srcX) |
| 440 | - | if len(tb.panes) > 1 && tb.size.Valid() { | 434 | + | width = min(width, frame.Width-dstX) |
| 441 | - | width, screenRows = tb.size.Cols, tb.size.Rows | 435 | + | if width > 0 && target.Y+y >= 0 { |
| 442 | - | } | 436 | + | copy(frame.Row(target.Y + y)[dstX:dstX+width], copyFrame.Row(y)[srcX:srcX+width]) |
| 443 | - | frame := renderer.NewFrame(width, screenRows+2) | 437 | + | } |
| 444 | - | drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, bars.topRight, styles) | |||
| 445 | - | base, _ := composeTabFrame(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme) | |||
| 446 | - | for y := range screenRows { | |||
| 447 | - | copy(frame.Row(y+1), base.Row(y)) | |||
| 448 | } | 438 | } | ||
| 449 | - | for y := 0; y < pl.Content.Height && y < copyFrame.Height-1; y++ { | 439 | + | statusY := frame.Height - 1 |
| 450 | - | copy(frame.Row(pl.Content.Y + 1 + y)[pl.Content.X:pl.Content.X+min(pl.Content.Width, copyFrame.Width)], copyFrame.Row(y)[:min(pl.Content.Width, copyFrame.Width)]) | 440 | + | statusRow := frame.Row(statusY) |
| 441 | + | blank := renderer.BlankCell() | |||
| 442 | + | for x := range statusRow { | |||
| 443 | + | statusRow[x] = blank | |||
| 451 | } | 444 | } | ||
| 452 | - | statusY := screenRows + 1 | 445 | + | copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)]) |
| 453 | - | copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(width, copyFrame.Width)]) | |||
| 454 | return frame, []renderer.Damage{renderer.FullRedraw()} | 446 | return frame, []renderer.Damage{renderer.FullRedraw()} | ||
| 455 | } | 447 | } | ||
M internal/usecase/daemon/copymode_test.go +188 -9
| @@ -17,6 +17,7 @@ import ( | |||||
| 17 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 19 | scopy "github.com/bnema/vev/internal/usecase/copy" | 19 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 20 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 20 | "github.com/bnema/vev/pkg/renderer" | 21 | "github.com/bnema/vev/pkg/renderer" | ||
| 21 | "github.com/bnema/vev/pkg/vt" | 22 | "github.com/bnema/vev/pkg/vt" | ||
| 22 | ) | 23 | ) | ||
| @@ -107,6 +108,9 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||||
| 107 | mode := scopy.NewMode(snap) | 108 | mode := scopy.NewMode(snap) | ||
| 108 | bars := barState{status: sess.statusSegments()} | 109 | bars := barState{status: sess.statusSegments()} | ||
| 109 | 110 | ||||
| 111 | + | base := renderer.NewFrame(80, 25) | |||
| 112 | + | target := domain.Rect{X: 0, Y: 1, Width: 80, Height: 23} | |||
| 113 | + | ||||
| 110 | var wg sync.WaitGroup | 114 | var wg sync.WaitGroup | ||
| 111 | wg.Add(2) | 115 | wg.Add(2) | ||
| 112 | go func() { | 116 | go func() { | ||
| @@ -120,9 +124,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||||
| 120 | go func() { | 124 | go func() { | ||
| 121 | defer wg.Done() | 125 | defer wg.Done() | ||
| 122 | for range 200 { | 126 | for range 200 { | ||
| 123 | - | tb.mu.Lock() | 127 | + | _, _ = composeCopyClientFrame(mode, pane, target, base, bars) |
| 124 | - | _, _ = composeCopyClientFrame(mode, tb, bars) | |||
| 125 | - | tb.mu.Unlock() | |||
| 126 | } | 128 | } | ||
| 127 | }() | 129 | }() | ||
| 128 | wg.Wait() | 130 | wg.Wait() | ||
| @@ -139,12 +141,15 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||||
| 139 | snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame) | 141 | snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame) | ||
| 140 | mode := scopy.NewMode(snap) | 142 | mode := scopy.NewMode(snap) | ||
| 141 | 143 | ||||
| 142 | - | frame, damage := composeCopyClientFrame(mode, tb, barState{status: sess.statusSegments()}) | 144 | + | bars := barState{status: sess.statusSegments()} |
| 143 | - | 145 | + | base, _ := composeClientFrameWithState(bars, tb, true) | |
| 144 | - | require.Equal(t, 12, frame.Width) | 146 | + | frame, damage := composeCopyClientFrame(mode, tb.focusedPane(), domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars) |
| 145 | - | require.Equal(t, 5, frame.Height) | 147 | + | |
| 146 | - | require.Equal(t, " 1 ", rowText(frame.Row(0))) | 148 | + | require.Equal(t, 80, frame.Width) |
| 147 | - | require.Contains(t, rowText(frame.Row(4)), "[SCROLL]") | 149 | + | require.Equal(t, 25, frame.Height) |
| 150 | + | require.Equal(t, " 1", strings.TrimRight(rowText(frame.Row(0)), " ")) | |||
| 151 | + | require.Contains(t, rowText(frame.Row(1)), "live") | |||
| 152 | + | require.Contains(t, rowText(frame.Row(24)), "[SCROLL]") | |||
| 148 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | 153 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | ||
| 149 | } | 154 | } | ||
| 150 | 155 | ||||
| @@ -636,3 +641,177 @@ func TestCursorTailHidesInCopyMode(t *testing.T) { | |||||
| 636 | data := mustOutputData(t, sends) | 641 | data := mustOutputData(t, sends) | ||
| 637 | require.Contains(t, string(data), "\x1b[?25l") | 642 | require.Contains(t, string(data), "\x1b[?25l") | ||
| 638 | } | 643 | } | ||
| 644 | + | ||||
| 645 | + | func installFloatingCopyFixture(t *testing.T, sess *session, size domain.Size) *pane { | |||
| 646 | + | t.Helper() | |||
| 647 | + | floatingPTY, releaseFloating := newBlockingPTY(t) | |||
| 648 | + | t.Cleanup(releaseFloating) | |||
| 649 | + | fp := newPane("floating", floatingPTY, size) | |||
| 650 | + | installTestFloating(sess.activeTab(), fp, true) | |||
| 651 | + | return fp | |||
| 652 | + | } | |||
| 653 | + | ||||
| 654 | + | func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) { | |||
| 655 | + | cases := []struct { | |||
| 656 | + | name string | |||
| 657 | + | useFloating bool | |||
| 658 | + | wantText string | |||
| 659 | + | }{ | |||
| 660 | + | {name: "floating source", useFloating: true, wantText: "flt-old\nflt-live"}, | |||
| 661 | + | {name: "normal source", useFloating: false, wantText: "one-old\none-live"}, | |||
| 662 | + | } | |||
| 663 | + | for _, tc := range cases { | |||
| 664 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 665 | + | normalWrites := make(chan []byte, 1) | |||
| 666 | + | normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites) | |||
| 667 | + | defer releaseNormal() | |||
| 668 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |||
| 669 | + | tb := sess.activeTab() | |||
| 670 | + | second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5}) | |||
| 671 | + | tb.mu.Lock() | |||
| 672 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |||
| 673 | + | tb.panes["pane-2"] = second | |||
| 674 | + | tb.mu.Unlock() | |||
| 675 | + | main := tb.focusedPane() | |||
| 676 | + | var want *pane | |||
| 677 | + | if tc.useFloating { | |||
| 678 | + | want = installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | |||
| 679 | + | want.scrollback.Append(testRow("flt-old")) | |||
| 680 | + | want.screen.Write([]byte("flt-live")) | |||
| 681 | + | } else { | |||
| 682 | + | want = main | |||
| 683 | + | want.scrollback.Append(testRow("one-old")) | |||
| 684 | + | want.screen.Write([]byte("one-live")) | |||
| 685 | + | } | |||
| 686 | + | ||||
| 687 | + | d.enterCopyMode(sess, ac) | |||
| 688 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 689 | + | ac.overlays.copyMu.Lock() | |||
| 690 | + | captured := ac.overlays.copyPane | |||
| 691 | + | ac.overlays.copyMu.Unlock() | |||
| 692 | + | require.Same(t, want, captured) | |||
| 693 | + | ||||
| 694 | + | tb.mu.Lock() | |||
| 695 | + | focusPlacementLocked(tb, "pane-2") | |||
| 696 | + | tb.mu.Unlock() | |||
| 697 | + | ||||
| 698 | + | d.handleInput(sess, ac, []byte{'g', ' ', 'j', 'y'}) | |||
| 699 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 700 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 701 | + | require.NoError(t, err) | |||
| 702 | + | wantOSC := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(tc.wantText)) + "\x07" | |||
| 703 | + | require.Equal(t, wantOSC, string(msg.Data)) | |||
| 704 | + | require.Nil(t, ac.overlays.copyMode) | |||
| 705 | + | select { | |||
| 706 | + | case got := <-normalWrites: | |||
| 707 | + | t.Fatalf("copy-mode input forwarded to normal PTY: %q", got) | |||
| 708 | + | default: | |||
| 709 | + | } | |||
| 710 | + | }) | |||
| 711 | + | } | |||
| 712 | + | } | |||
| 713 | + | ||||
| 714 | + | func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||
| 715 | + | normal, releaseNormal := newBlockingPTY(t) | |||
| 716 | + | defer releaseNormal() | |||
| 717 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |||
| 718 | + | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | |||
| 719 | + | for i := range 30 { | |||
| 720 | + | fp.scrollback.Append(testRow(fmt.Sprintf("old-%02d", i))) | |||
| 721 | + | } | |||
| 722 | + | fp.screen.Write([]byte("live")) | |||
| 723 | + | fp.mu.Lock() | |||
| 724 | + | total := len(scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Rows) | |||
| 725 | + | fp.mu.Unlock() | |||
| 726 | + | ||||
| 727 | + | d.enterCopyMode(sess, ac) | |||
| 728 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 729 | + | require.Equal(t, total-1, ac.overlays.copyMode.Cursor) | |||
| 730 | + | ||||
| 731 | + | d.copyWheel(sess, ac, -3) | |||
| 732 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 733 | + | require.Equal(t, total-4, ac.overlays.copyMode.Cursor) | |||
| 734 | + | ||||
| 735 | + | d.copyWheel(sess, ac, 3) | |||
| 736 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 737 | + | require.Nil(t, ac.overlays.copyMode, "wheel down reaching the captured bottom exits copy mode") | |||
| 738 | + | } | |||
| 739 | + | ||||
| 740 | + | func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) { | |||
| 741 | + | normal, releaseNormal := newBlockingPTY(t) | |||
| 742 | + | defer releaseNormal() | |||
| 743 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |||
| 744 | + | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | |||
| 745 | + | for i := range 5 { | |||
| 746 | + | fp.scrollback.Append(testRow(fmt.Sprintf("old-%d", i))) | |||
| 747 | + | } | |||
| 748 | + | fp.screen.Write([]byte("live")) | |||
| 749 | + | ||||
| 750 | + | d.enterCopyMode(sess, ac) | |||
| 751 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 752 | + | viewportTop := ac.overlays.copyMode.ViewportTop | |||
| 753 | + | inner := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 23}, d.currentFloatingConfig()).Inner | |||
| 754 | + | ||||
| 755 | + | // Copy body row k renders at wire row inner.Y+k+2 (top bar + content | |||
| 756 | + | // offset); clicking a row must select exactly that row. | |||
| 757 | + | press := fmt.Sprintf("\x1b[<0;%d;%dM", inner.X+3, inner.Y+3) | |||
| 758 | + | d.handleInput(sess, ac, []byte(press)) | |||
| 759 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 760 | + | require.Equal(t, viewportTop+1, ac.overlays.copyMode.Cursor) | |||
| 761 | + | ||||
| 762 | + | motion := fmt.Sprintf("\x1b[<32;%d;%dM", inner.X+3, inner.Y+4) | |||
| 763 | + | d.handleInput(sess, ac, []byte(motion)) | |||
| 764 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 765 | + | lo, hi, ok := ac.overlays.copyMode.SelectedBounds() | |||
| 766 | + | require.True(t, ok) | |||
| 767 | + | require.Equal(t, viewportTop+1, lo) | |||
| 768 | + | require.Equal(t, viewportTop+2, hi) | |||
| 769 | + | } | |||
| 770 | + | ||||
| 771 | + | func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) { | |||
| 772 | + | normal, releaseNormal := newBlockingPTY(t) | |||
| 773 | + | defer releaseNormal() | |||
| 774 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |||
| 775 | + | tb := sess.activeTab() | |||
| 776 | + | fp := installFloatingCopyFixture(t, sess, domain.Size{Cols: 20, Rows: 3}) | |||
| 777 | + | fp.screen.Write([]byte("flt-live")) | |||
| 778 | + | ||||
| 779 | + | d.enterCopyMode(sess, ac) | |||
| 780 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 781 | + | tb.mu.Lock() | |||
| 782 | + | generation := tb.floating.generation | |||
| 783 | + | tb.mu.Unlock() | |||
| 784 | + | ||||
| 785 | + | d.reapFloating(sess, tb, fp, generation) | |||
| 786 | + | ||||
| 787 | + | ac.overlays.copyMu.Lock() | |||
| 788 | + | require.Nil(t, ac.overlays.copyMode) | |||
| 789 | + | require.Nil(t, ac.overlays.copyPane) | |||
| 790 | + | ac.overlays.copyMu.Unlock() | |||
| 791 | + | data := mustOutputData(t, sends) | |||
| 792 | + | require.NotContains(t, string(data), "[SCROLL]") | |||
| 793 | + | require.NotContains(t, string(data), "flt-live") | |||
| 794 | + | } | |||
| 795 | + | ||||
| 796 | + | func TestMouseDragCopyEntryCapturesSourceForYank(t *testing.T) { | |||
| 797 | + | pty, _ := newBlockingPTY(t) | |||
| 798 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | |||
| 799 | + | pane := sess.tabs[0].focusedPane() | |||
| 800 | + | copy(pane.screen.Frame.Row(0), testRow("alpha")) | |||
| 801 | + | copy(pane.screen.Frame.Row(1), testRow("bravo")) | |||
| 802 | + | ||||
| 803 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | |||
| 804 | + | mustOutputData(t, sends) | |||
| 805 | + | require.NotNil(t, ac.overlays.copyMode) | |||
| 806 | + | ac.overlays.copyMu.Lock() | |||
| 807 | + | captured := ac.overlays.copyPane | |||
| 808 | + | ac.overlays.copyMu.Unlock() | |||
| 809 | + | require.Same(t, pane, captured, "drag entry must capture the copy source pane") | |||
| 810 | + | ||||
| 811 | + | d.handleInput(sess, ac, []byte("y")) | |||
| 812 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 813 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 814 | + | require.NoError(t, err) | |||
| 815 | + | want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("alpha\nbravo")) + "\x07" | |||
| 816 | + | require.Equal(t, want, string(msg.Data)) | |||
| 817 | + | } | |||
M internal/usecase/daemon/daemon.go +3 -0
| @@ -135,6 +135,7 @@ type Daemon struct { | |||||
| 135 | bindings atomic.Pointer[keys.Bindings] | 135 | bindings atomic.Pointer[keys.Bindings] | ||
| 136 | codeOverrides atomic.Pointer[map[string]string] | 136 | codeOverrides atomic.Pointer[map[string]string] | ||
| 137 | restoreProcessAllowlist atomic.Pointer[map[string]struct{}] | 137 | restoreProcessAllowlist atomic.Pointer[map[string]struct{}] | ||
| 138 | + | floatingConfig atomic.Pointer[domain.FloatingConfig] | |||
| 138 | themeMode atomic.Uint32 | 139 | themeMode atomic.Uint32 | ||
| 139 | barScripts *barScriptState | 140 | barScripts *barScriptState | ||
| 140 | resumeParkGrace time.Duration | 141 | resumeParkGrace time.Duration | ||
| @@ -315,6 +316,8 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 315 | running: make(map[domain.SessionID]bool), | 316 | running: make(map[domain.SessionID]bool), | ||
| 316 | }, | 317 | }, | ||
| 317 | } | 318 | } | ||
| 319 | + | defaultFloating := domain.Defaults().Floating | |||
| 320 | + | d.floatingConfig.Store(&defaultFloating) | |||
| 318 | for _, o := range opts { | 321 | for _, o := range opts { | ||
| 319 | o(d) | 322 | o(d) | ||
| 320 | } | 323 | } | ||
M internal/usecase/daemon/daemon_test.go +66 -3
| @@ -105,10 +105,66 @@ func newBlockingPTYWithWrites(t *testing.T, writes chan<- []byte) (*portsmocks.M | |||||
| 105 | return p, unblock | 105 | return p, unblock | ||
| 106 | } | 106 | } | ||
| 107 | 107 | ||||
| 108 | + | // quietPTY is an independently closable PTY for background floating prewarms. | |||
| 109 | + | // It intentionally has no testify expectations: tests using the factories below | |||
| 110 | + | // are asserting their regular panes, not incidental asynchronous prewarms. | |||
| 111 | + | type quietPTY struct{ done chan struct{} } | |||
| 112 | + | ||||
| 113 | + | func newQuietPTY() *quietPTY { return &quietPTY{done: make(chan struct{})} } | |||
| 114 | + | func (p *quietPTY) Read([]byte) (int, error) { <-p.done; return 0, io.EOF } | |||
| 115 | + | func (*quietPTY) Write(b []byte) (int, error) { return len(b), nil } | |||
| 116 | + | func (p *quietPTY) Close() error { | |||
| 117 | + | select { | |||
| 118 | + | case <-p.done: | |||
| 119 | + | default: | |||
| 120 | + | close(p.done) | |||
| 121 | + | } | |||
| 122 | + | return nil | |||
| 123 | + | } | |||
| 124 | + | func (*quietPTY) Resize(domain.Size) error { return nil } | |||
| 125 | + | func (*quietPTY) Pid() int { return 0 } | |||
| 126 | + | func (*quietPTY) ForegroundPgid() (int, error) { return 0, nil } | |||
| 127 | + | ||||
| 128 | + | // blockingOpenFactory keeps activation tests deterministically in Warming | |||
| 129 | + | // without starting pane reader/scheduler goroutines. | |||
| 130 | + | type blockingOpenFactory struct { | |||
| 131 | + | release chan struct{} | |||
| 132 | + | once sync.Once | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func newBlockingOpenFactory(t *testing.T, d *Daemon) *blockingOpenFactory { | |||
| 136 | + | t.Helper() | |||
| 137 | + | f := &blockingOpenFactory{release: make(chan struct{})} | |||
| 138 | + | t.Cleanup(func() { | |||
| 139 | + | f.once.Do(func() { close(f.release) }) | |||
| 140 | + | d.sessWg.Wait() | |||
| 141 | + | }) | |||
| 142 | + | return f | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func (f *blockingOpenFactory) Open(string, []string, []string, string, domain.Size) (ports.PTY, error) { | |||
| 146 | + | <-f.release | |||
| 147 | + | return nil, io.ErrClosedPipe | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // newFactory keeps the supplied fixture for normal panes. Floating prewarms | |||
| 151 | + | // have a distinct (smaller) geometry and receive independent quiet PTYs, so | |||
| 152 | + | // they cannot consume test output or add post-test mock calls. | |||
| 108 | func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory { | 153 | func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory { | ||
| 109 | t.Helper() | 154 | t.Helper() | ||
| 110 | f := portsmocks.NewMockPTYFactory(t) | 155 | f := portsmocks.NewMockPTYFactory(t) | ||
| 111 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Maybe() | 156 | + | var normal domain.Size |
| 157 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 158 | + | func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) { | |||
| 159 | + | if !normal.Valid() { | |||
| 160 | + | normal = sz | |||
| 161 | + | } | |||
| 162 | + | if sz == normal { | |||
| 163 | + | return p, nil | |||
| 164 | + | } | |||
| 165 | + | return newQuietPTY(), nil | |||
| 166 | + | }, | |||
| 167 | + | ).Maybe() | |||
| 112 | return f | 168 | return f | ||
| 113 | } | 169 | } | ||
| 114 | 170 | ||||
| @@ -116,13 +172,20 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory { | |||||
| 116 | t.Helper() | 172 | t.Helper() | ||
| 117 | f := portsmocks.NewMockPTYFactory(t) | 173 | f := portsmocks.NewMockPTYFactory(t) | ||
| 118 | var mu sync.Mutex | 174 | var mu sync.Mutex | ||
| 175 | + | var normal domain.Size | |||
| 119 | next := 0 | 176 | next := 0 | ||
| 120 | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | 177 | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | ||
| 121 | - | func(string, []string, []string, string, domain.Size) (ports.PTY, error) { | 178 | + | func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) { |
| 122 | mu.Lock() | 179 | mu.Lock() | ||
| 123 | defer mu.Unlock() | 180 | defer mu.Unlock() | ||
| 181 | + | if !normal.Valid() { | |||
| 182 | + | normal = sz | |||
| 183 | + | } | |||
| 184 | + | if sz != normal { | |||
| 185 | + | return newQuietPTY(), nil | |||
| 186 | + | } | |||
| 124 | if next >= len(ptys) { | 187 | if next >= len(ptys) { | ||
| 125 | - | t.Fatalf("unexpected PTY open #%d", next+1) | 188 | + | return newQuietPTY(), nil |
| 126 | } | 189 | } | ||
| 127 | p := ptys[next] | 190 | p := ptys[next] | ||
| 128 | next++ | 191 | next++ | ||
A internal/usecase/daemon/floating.go +410 -0
| @@ -0,0 +1,410 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // floatingState describes the lifecycle of a tab's independent floating pane. | |||
| 14 | + | // A floating slot is always guarded by its owning tab.mu. | |||
| 15 | + | type floatingState uint8 | |||
| 16 | + | ||||
| 17 | + | const ( | |||
| 18 | + | floatingUninitialized floatingState = iota | |||
| 19 | + | floatingWarming | |||
| 20 | + | floatingHidden | |||
| 21 | + | floatingVisible | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // floatingSlot holds the runtime which is deliberately outside tab.tree and | |||
| 25 | + | // tab.panes. generation identifies an asynchronous launch and its installed | |||
| 26 | + | // pane, preventing stale launch completions and exits from changing new state. | |||
| 27 | + | type floatingSlot struct { | |||
| 28 | + | state floatingState | |||
| 29 | + | pane *pane | |||
| 30 | + | desiredVisible bool | |||
| 31 | + | generation uint64 | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // beginFloatingWarmLocked starts a single background launch. The caller must | |||
| 35 | + | // hold tb.mu and must only call it for an uninitialized slot. | |||
| 36 | + | func (tb *tab) beginFloatingWarmLocked(desiredVisible bool) uint64 { | |||
| 37 | + | if tb == nil || tb.floating.state != floatingUninitialized { | |||
| 38 | + | return 0 | |||
| 39 | + | } | |||
| 40 | + | tb.floating.generation++ | |||
| 41 | + | tb.floating.state = floatingWarming | |||
| 42 | + | tb.floating.desiredVisible = desiredVisible | |||
| 43 | + | return tb.floating.generation | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // toggleFloatingLocked applies the user action. Its result says whether a new | |||
| 47 | + | // PTY launch is required and returns the generation associated with that launch | |||
| 48 | + | // or current slot. | |||
| 49 | + | func (tb *tab) toggleFloatingLocked() (bool, uint64) { | |||
| 50 | + | if tb == nil { | |||
| 51 | + | return false, 0 | |||
| 52 | + | } | |||
| 53 | + | switch tb.floating.state { | |||
| 54 | + | case floatingUninitialized: | |||
| 55 | + | generation := tb.beginFloatingWarmLocked(true) | |||
| 56 | + | return generation != 0, generation | |||
| 57 | + | case floatingWarming: | |||
| 58 | + | tb.floating.desiredVisible = !tb.floating.desiredVisible | |||
| 59 | + | case floatingHidden: | |||
| 60 | + | tb.floating.state = floatingVisible | |||
| 61 | + | case floatingVisible: | |||
| 62 | + | tb.floating.state = floatingHidden | |||
| 63 | + | } | |||
| 64 | + | return false, tb.floating.generation | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // installFloatingLocked installs a completed launch only when it is still the | |||
| 68 | + | // current warming generation. Stale completions must close their PTY elsewhere. | |||
| 69 | + | func (tb *tab) installFloatingLocked(p *pane, generation uint64) bool { | |||
| 70 | + | if tb == nil || p == nil || tb.floating.state != floatingWarming || tb.floating.generation != generation { | |||
| 71 | + | return false | |||
| 72 | + | } | |||
| 73 | + | tb.floating.pane = p | |||
| 74 | + | if tb.floating.desiredVisible { | |||
| 75 | + | tb.floating.state = floatingVisible | |||
| 76 | + | } else { | |||
| 77 | + | tb.floating.state = floatingHidden | |||
| 78 | + | } | |||
| 79 | + | return true | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // failFloatingLocked clears only the current warming launch. It deliberately | |||
| 83 | + | // leaves generation intact; the next launch advances it. | |||
| 84 | + | func (tb *tab) failFloatingLocked(generation uint64) bool { | |||
| 85 | + | if tb == nil || tb.floating.state != floatingWarming || tb.floating.generation != generation { | |||
| 86 | + | return false | |||
| 87 | + | } | |||
| 88 | + | tb.floating.state = floatingUninitialized | |||
| 89 | + | tb.floating.pane = nil | |||
| 90 | + | tb.floating.desiredVisible = false | |||
| 91 | + | return true | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // clearFloatingLocked clears a matching installed runtime. Matching both pane | |||
| 95 | + | // identity and generation prevents an old reader from reaping a newer pane. | |||
| 96 | + | func (tb *tab) clearFloatingLocked(p *pane, generation uint64) bool { | |||
| 97 | + | if tb == nil || p == nil || tb.floating.pane != p || tb.floating.generation != generation || | |||
| 98 | + | (tb.floating.state != floatingHidden && tb.floating.state != floatingVisible) { | |||
| 99 | + | return false | |||
| 100 | + | } | |||
| 101 | + | tb.takeFloatingLocked() | |||
| 102 | + | return true | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // takeFloatingLocked invalidates the slot and detaches its installed pane. The | |||
| 106 | + | // caller must hold tb.mu and must close the returned pane after unlocking. | |||
| 107 | + | func (tb *tab) takeFloatingLocked() *pane { | |||
| 108 | + | if tb == nil { | |||
| 109 | + | return nil | |||
| 110 | + | } | |||
| 111 | + | p := tb.floating.pane | |||
| 112 | + | tb.floating.generation++ | |||
| 113 | + | tb.floating.state = floatingUninitialized | |||
| 114 | + | tb.floating.pane = nil | |||
| 115 | + | tb.floating.desiredVisible = false | |||
| 116 | + | return p | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // terminalTargetLocked chooses the visible floating terminal ahead of the | |||
| 120 | + | // normal layout target. The caller must hold tb.mu. | |||
| 121 | + | func (tb *tab) terminalTargetLocked() *pane { | |||
| 122 | + | if tb != nil && tb.floating.state == floatingVisible && tb.floating.pane != nil { | |||
| 123 | + | return tb.floating.pane | |||
| 124 | + | } | |||
| 125 | + | return tb.focusedPane() | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // visibleFloatingSnapshotLocked captures the visible popup and its current | |||
| 129 | + | // geometry under the tab lock, so input cannot mix a pane from one state with | |||
| 130 | + | // coordinates from another. The caller must hold tb.mu. | |||
| 131 | + | func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane, floatingGeometry, bool) { | |||
| 132 | + | if tb == nil || tb.floating.state != floatingVisible || tb.floating.pane == nil { | |||
| 133 | + | return nil, floatingGeometry{}, false | |||
| 134 | + | } | |||
| 135 | + | p := tb.floating.pane | |||
| 136 | + | desired := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg) | |||
| 137 | + | p.mu.Lock() | |||
| 138 | + | geometry := p.committedFloatingGeometryLocked(desired) | |||
| 139 | + | p.mu.Unlock() | |||
| 140 | + | if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 { | |||
| 141 | + | return nil, floatingGeometry{}, false | |||
| 142 | + | } | |||
| 143 | + | return p, geometry, true | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // ensureFloatingWarm starts the background prewarm exactly once for a tab. | |||
| 147 | + | func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) { | |||
| 148 | + | if d == nil || sess == nil || tb == nil || d.ptys == nil { | |||
| 149 | + | return | |||
| 150 | + | } | |||
| 151 | + | d.startFloating(sess, tb, false) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // activateTab performs the work associated with making a tab the destination. | |||
| 155 | + | // It is deliberately idempotent: every tab transition can use this single | |||
| 156 | + | // hook, while inactive restored tabs remain cold until actually selected. | |||
| 157 | + | func (d *Daemon) activateTab(sess *session, tb *tab) { | |||
| 158 | + | if d == nil || sess == nil || tb == nil { | |||
| 159 | + | return | |||
| 160 | + | } | |||
| 161 | + | // A headless session has no actual terminal destination. Deferring warmup | |||
| 162 | + | // until firstPaint keeps restored tabs cold and avoids launching children | |||
| 163 | + | // merely because a tab was manipulated during teardown. | |||
| 164 | + | sess.mu.Lock() | |||
| 165 | + | attached := sess.client != nil | |||
| 166 | + | sess.mu.Unlock() | |||
| 167 | + | if !attached { | |||
| 168 | + | return | |||
| 169 | + | } | |||
| 170 | + | d.ensureFloatingWarm(sess, tb) | |||
| 171 | + | d.resizeInstalledFloating(tb) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // toggleFloating changes only this tab's slot. Opening an uninitialized slot | |||
| 175 | + | // launches asynchronously; installed slots are retained when hidden. | |||
| 176 | + | func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error { | |||
| 177 | + | if d == nil || sess == nil { | |||
| 178 | + | return fmt.Errorf("floating pane: session required") | |||
| 179 | + | } | |||
| 180 | + | tb := sess.activeTab() | |||
| 181 | + | if tb == nil { | |||
| 182 | + | return layout.ErrNotFound | |||
| 183 | + | } | |||
| 184 | + | cfg := d.currentFloatingConfig() | |||
| 185 | + | tb.mu.Lock() | |||
| 186 | + | start, generation := tb.toggleFloatingLocked() | |||
| 187 | + | // desiredVisible is meaningful only while a launch is warming. Installed | |||
| 188 | + | // slots derive their resize and paint work from the resulting slot state. | |||
| 189 | + | visible := tb.floating.state == floatingVisible | |||
| 190 | + | tb.mu.Unlock() | |||
| 191 | + | if start { | |||
| 192 | + | d.launchFloating(sess, tb, cfg, generation, true) | |||
| 193 | + | if ac != nil { | |||
| 194 | + | d.paint(sess, ac, true) | |||
| 195 | + | } | |||
| 196 | + | return nil | |||
| 197 | + | } | |||
| 198 | + | if visible { | |||
| 199 | + | d.resizeActiveFloating(tb) | |||
| 200 | + | } | |||
| 201 | + | if ac != nil { | |||
| 202 | + | d.paint(sess, ac, true) | |||
| 203 | + | } | |||
| 204 | + | return nil | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // startFloating starts a prewarm if its slot remains uninitialized. | |||
| 208 | + | func (d *Daemon) startFloating(sess *session, tb *tab, visible bool) { | |||
| 209 | + | cfg := d.currentFloatingConfig() | |||
| 210 | + | tb.mu.Lock() | |||
| 211 | + | generation := tb.beginFloatingWarmLocked(visible) | |||
| 212 | + | tb.mu.Unlock() | |||
| 213 | + | if generation != 0 { | |||
| 214 | + | d.launchFloating(sess, tb, cfg, generation, visible) | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // floatingLaunchSpec is an immutable snapshot of one launch. It is built | |||
| 219 | + | // before the worker starts so config reloads cannot affect that launch. | |||
| 220 | + | type floatingLaunchSpec struct { | |||
| 221 | + | sessionName string | |||
| 222 | + | cwd string | |||
| 223 | + | size domain.Size | |||
| 224 | + | geometry floatingGeometry | |||
| 225 | + | paneStableID string | |||
| 226 | + | env []string | |||
| 227 | + | command string | |||
| 228 | + | args []string | |||
| 229 | + | fallback string | |||
| 230 | + | parentCtx context.Context | |||
| 231 | + | userOpen bool | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | // launchFloating snapshots launch inputs, then accounts for a worker through | |||
| 235 | + | // Open, pane initialization, publication, and reader/scheduler startup. | |||
| 236 | + | func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfig, generation uint64, userOpen bool) { | |||
| 237 | + | spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, userOpen) | |||
| 238 | + | if err != nil { | |||
| 239 | + | d.failFloatingLaunch(tb, generation, userOpen, spec.sessionName, err) | |||
| 240 | + | return | |||
| 241 | + | } | |||
| 242 | + | // Count the launch before its goroutine starts. It remains counted through | |||
| 243 | + | // install, including the reader/scheduler Adds, so shutdown cannot return | |||
| 244 | + | // before a late Open completion has either been rejected or fully joined. | |||
| 245 | + | d.sessWg.Go(func() { d.openAndInstallFloating(sess, tb, spec, generation) }) | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) { | |||
| 249 | + | sess.mu.Lock() | |||
| 250 | + | name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx | |||
| 251 | + | sess.mu.Unlock() | |||
| 252 | + | tb.mu.Lock() | |||
| 253 | + | geometry := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg) | |||
| 254 | + | size := rectSize(geometry.Inner) | |||
| 255 | + | if !size.Valid() { | |||
| 256 | + | geometry = floatingGeometry{Bounds: domain.Rect{Width: 1, Height: 1}, Inner: domain.Rect{Width: 1, Height: 1}} | |||
| 257 | + | size = rectSize(geometry.Inner) | |||
| 258 | + | } | |||
| 259 | + | focused := tb.focusedPane() | |||
| 260 | + | tabStableID, tabCtx := tb.stableID, tb.ctx | |||
| 261 | + | tb.mu.Unlock() | |||
| 262 | + | if focused != nil && d.procCwd != nil && focused.pty != nil { | |||
| 263 | + | if live, err := d.procCwd(focused.pty.Pid()); err == nil && live != "" { | |||
| 264 | + | cwd = live | |||
| 265 | + | } | |||
| 266 | + | } | |||
| 267 | + | if d.dirOrHome != nil { | |||
| 268 | + | cwd = d.dirOrHome(cwd) | |||
| 269 | + | } | |||
| 270 | + | paneStableID, err := newStableID("p") | |||
| 271 | + | if err != nil { | |||
| 272 | + | return floatingLaunchSpec{sessionName: name}, err | |||
| 273 | + | } | |||
| 274 | + | if tabCtx == nil { | |||
| 275 | + | tabCtx = sessCtx | |||
| 276 | + | } | |||
| 277 | + | if tabCtx == nil { | |||
| 278 | + | tabCtx = context.Background() | |||
| 279 | + | } | |||
| 280 | + | command, args := d.shell, append([]string(nil), d.shellArgs...) | |||
| 281 | + | if cfg.Command != "" { | |||
| 282 | + | args = []string{"-lc", cfg.Command} | |||
| 283 | + | } | |||
| 284 | + | return floatingLaunchSpec{ | |||
| 285 | + | sessionName: name, | |||
| 286 | + | cwd: cwd, | |||
| 287 | + | size: size, | |||
| 288 | + | geometry: geometry, | |||
| 289 | + | paneStableID: paneStableID, | |||
| 290 | + | env: d.childEnv(name, tabStableID, paneStableID, term), | |||
| 291 | + | command: command, | |||
| 292 | + | args: args, | |||
| 293 | + | fallback: floatingCommandFallback(cfg.Command, d.shell), | |||
| 294 | + | parentCtx: tabCtx, | |||
| 295 | + | userOpen: userOpen, | |||
| 296 | + | }, nil | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | // openAndInstallFloating runs entirely in the launch worker. No PTY operation | |||
| 300 | + | // occurs under tb.mu; the pane is fully initialized before publication. | |||
| 301 | + | func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLaunchSpec, generation uint64) { | |||
| 302 | + | pty, err := d.ptys.Open(spec.command, spec.args, spec.env, spec.cwd, spec.size) | |||
| 303 | + | if err != nil { | |||
| 304 | + | d.failFloatingLaunch(tb, generation, spec.userOpen, spec.sessionName, err) | |||
| 305 | + | return | |||
| 306 | + | } | |||
| 307 | + | p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size) | |||
| 308 | + | p.rect = spec.geometry.Inner | |||
| 309 | + | p.popupGeometry = spec.geometry | |||
| 310 | + | p.title.displayFallback = spec.fallback | |||
| 311 | + | p.ctx, p.cancel = context.WithCancel(spec.parentCtx) | |||
| 312 | + | // The reader may run as soon as install returns; make its exit policy | |||
| 313 | + | // immutable before publishing the pane to the slot. | |||
| 314 | + | p.onExit = func() { d.reapFloating(sess, tb, p, generation) } | |||
| 315 | + | d.installFloating(sess, tb, p, generation) | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | func (d *Daemon) failFloatingLaunch(tb *tab, generation uint64, userOpen bool, sessionName string, err error) { | |||
| 319 | + | tb.mu.Lock() | |||
| 320 | + | current := tb.failFloatingLocked(generation) | |||
| 321 | + | tb.mu.Unlock() | |||
| 322 | + | if current { | |||
| 323 | + | kind := "background-prewarm" | |||
| 324 | + | if userOpen { | |||
| 325 | + | kind = "user-open" | |||
| 326 | + | } | |||
| 327 | + | d.log.Warn("floating pty spawn failed", "err", err, "session", sessionName, "kind", kind) | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | // installFloating installs only the current generation. A stale successful | |||
| 332 | + | // launch is cancelled and closed outside the tab lock. | |||
| 333 | + | func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uint64) { | |||
| 334 | + | tb.mu.Lock() | |||
| 335 | + | installed := tb.installFloatingLocked(p, generation) | |||
| 336 | + | visible := installed && tb.floating.state == floatingVisible | |||
| 337 | + | tb.mu.Unlock() | |||
| 338 | + | if !installed { | |||
| 339 | + | closeFloatingPane(p) | |||
| 340 | + | return | |||
| 341 | + | } | |||
| 342 | + | d.reapplyThemeSession(sess) | |||
| 343 | + | d.startPaneGoroutines(sess, tb, p) | |||
| 344 | + | if visible { | |||
| 345 | + | d.resizeActiveFloating(tb) | |||
| 346 | + | sess.mu.Lock() | |||
| 347 | + | ac := sess.client | |||
| 348 | + | sess.mu.Unlock() | |||
| 349 | + | if ac != nil { | |||
| 350 | + | d.paint(sess, ac, true) | |||
| 351 | + | } | |||
| 352 | + | } | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | // reapFloating ignores old readers and restores the background only when the | |||
| 356 | + | // exiting popup was visible. | |||
| 357 | + | func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64) { | |||
| 358 | + | tb.mu.Lock() | |||
| 359 | + | visible := tb.floating.state == floatingVisible && tb.floating.pane == p && tb.floating.generation == generation | |||
| 360 | + | cleared := tb.clearFloatingLocked(p, generation) | |||
| 361 | + | tb.mu.Unlock() | |||
| 362 | + | if !cleared { | |||
| 363 | + | return | |||
| 364 | + | } | |||
| 365 | + | closeFloatingPane(p) | |||
| 366 | + | sess.mu.Lock() | |||
| 367 | + | ac := sess.client | |||
| 368 | + | sess.mu.Unlock() | |||
| 369 | + | copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p) | |||
| 370 | + | if ac != nil && (visible || copyCleared) { | |||
| 371 | + | d.paint(sess, ac, true) | |||
| 372 | + | } | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | // teardownFloating invalidates first, then releases resources outside tb.mu. | |||
| 376 | + | func (d *Daemon) teardownFloating(tb *tab, ac *attachedClient) { | |||
| 377 | + | if tb == nil { | |||
| 378 | + | return | |||
| 379 | + | } | |||
| 380 | + | tb.mu.Lock() | |||
| 381 | + | p := tb.takeFloatingLocked() | |||
| 382 | + | tb.mu.Unlock() | |||
| 383 | + | if ac != nil { | |||
| 384 | + | ac.overlays.clearCopyModeForPane(p) | |||
| 385 | + | } | |||
| 386 | + | closeFloatingPane(p) | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | // closeFloatingPane releases a floating runtime at most once. It is called | |||
| 390 | + | // only after a generation/identity check has detached the pane from its slot. | |||
| 391 | + | func closeFloatingPane(p *pane) { | |||
| 392 | + | if p == nil { | |||
| 393 | + | return | |||
| 394 | + | } | |||
| 395 | + | p.floatingCloseOnce.Do(func() { | |||
| 396 | + | if p.cancel != nil { | |||
| 397 | + | p.cancel() | |||
| 398 | + | } | |||
| 399 | + | if p.pty != nil { | |||
| 400 | + | _ = p.pty.Close() | |||
| 401 | + | } | |||
| 402 | + | }) | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | func floatingCommandFallback(command, shell string) string { | |||
| 406 | + | if fields := strings.Fields(command); len(fields) > 0 { | |||
| 407 | + | return filepath.Base(fields[0]) | |||
| 408 | + | } | |||
| 409 | + | return filepath.Base(shell) | |||
| 410 | + | } | |||
A internal/usecase/daemon/floating_render.go +224 -0
| @@ -0,0 +1,224 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 6 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // floatingGeometry describes a popup's outer frame and terminal content area. | |||
| 11 | + | // Bounds and Inner are both relative to the supplied content rectangle. | |||
| 12 | + | type floatingGeometry struct { | |||
| 13 | + | Bounds domain.Rect | |||
| 14 | + | Inner domain.Rect | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func (g floatingGeometry) valid() bool { | |||
| 18 | + | return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0 | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // committedFloatingGeometryLocked returns the geometry whose PTY resize was | |||
| 22 | + | // last committed. Newly constructed test panes fall back to the requested | |||
| 23 | + | // geometry until their first resize. The caller holds p.mu. | |||
| 24 | + | func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry { | |||
| 25 | + | if p.popupGeometry.valid() { | |||
| 26 | + | return p.popupGeometry | |||
| 27 | + | } | |||
| 28 | + | return fallback | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // floatingAxisGeometry is the canonical percentage and border calculation for | |||
| 32 | + | // one popup axis. Popups smaller than three cells spend no cells on borders. | |||
| 33 | + | type floatingAxisGeometry struct { | |||
| 34 | + | BoundsSize int | |||
| 35 | + | InnerSize int | |||
| 36 | + | BorderOffset int | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry { | |||
| 40 | + | percent = min(max(percent, 1), 100) | |||
| 41 | + | bounds := min(max(available*percent/100, 1), available) | |||
| 42 | + | axis := floatingAxisGeometry{BoundsSize: bounds, InnerSize: bounds} | |||
| 43 | + | if bounds >= 3 { | |||
| 44 | + | axis.InnerSize -= 2 | |||
| 45 | + | axis.BorderOffset = 1 | |||
| 46 | + | } | |||
| 47 | + | return axis | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // calculateFloatingGeometry centers a percentage-sized popup in content. | |||
| 51 | + | // Launch sizing derives from Inner too, so rendering and PTY geometry share | |||
| 52 | + | // the same per-axis percentage and tiny-border rules. | |||
| 53 | + | func calculateFloatingGeometry(content domain.Rect, cfg domain.FloatingConfig) floatingGeometry { | |||
| 54 | + | if content.Width <= 0 || content.Height <= 0 { | |||
| 55 | + | return floatingGeometry{} | |||
| 56 | + | } | |||
| 57 | + | x := calculateFloatingAxisGeometry(content.Width, cfg.Width) | |||
| 58 | + | y := calculateFloatingAxisGeometry(content.Height, cfg.Height) | |||
| 59 | + | bounds := domain.Rect{ | |||
| 60 | + | X: content.X + (content.Width-x.BoundsSize)/2, | |||
| 61 | + | Y: content.Y + (content.Height-y.BoundsSize)/2, | |||
| 62 | + | Width: x.BoundsSize, | |||
| 63 | + | Height: y.BoundsSize, | |||
| 64 | + | } | |||
| 65 | + | return floatingGeometry{ | |||
| 66 | + | Bounds: bounds, | |||
| 67 | + | Inner: domain.Rect{ | |||
| 68 | + | X: bounds.X + x.BorderOffset, | |||
| 69 | + | Y: bounds.Y + y.BorderOffset, | |||
| 70 | + | Width: x.InnerSize, | |||
| 71 | + | Height: y.InnerSize, | |||
| 72 | + | }, | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // composeFloatingFrame applies the popup to a copy of the normal composed | |||
| 77 | + | // destination. In particular it never writes the VT screen frame: that frame | |||
| 78 | + | // is shared with the PTY reader and is the source for future renders. | |||
| 79 | + | func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, cfg domain.FloatingConfig, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage) { | |||
| 80 | + | var frame renderer.Frame | |||
| 81 | + | if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height { | |||
| 82 | + | frame = cache.floatingFrame | |||
| 83 | + | for y := 0; y < base.Height; y++ { | |||
| 84 | + | copy(frame.Row(y), base.Row(y)) | |||
| 85 | + | } | |||
| 86 | + | } else { | |||
| 87 | + | frame = base.Clone() | |||
| 88 | + | if cache != nil { | |||
| 89 | + | cache.floatingFrame = frame | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme) | |||
| 93 | + | desiredGeometry := calculateFloatingGeometry(content, cfg) | |||
| 94 | + | ||||
| 95 | + | // The PTY reader mutates both the frame and its damage under p.mu. Keep | |||
| 96 | + | // every read of them, including the blit, in the same critical section. | |||
| 97 | + | // In particular, Frame is not safe to retain as an alias after unlocking. | |||
| 98 | + | p.mu.Lock() | |||
| 99 | + | geometry := p.committedFloatingGeometryLocked(desiredGeometry) | |||
| 100 | + | title := p.displayTitleLocked() | |||
| 101 | + | titleGeneration := p.title.generation | |||
| 102 | + | screenDamage := p.screen.Damage() | |||
| 103 | + | blitPaneFrame(frame, geometry.Inner, p.screen.Frame, false, theme) | |||
| 104 | + | damage := append([]renderer.Damage(nil), baseDamage...) | |||
| 105 | + | for _, d := range screenDamage { | |||
| 106 | + | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | |||
| 107 | + | } | |||
| 108 | + | p.screen.ClearDamage() | |||
| 109 | + | p.mu.Unlock() | |||
| 110 | + | ||||
| 111 | + | // The committed geometry, rather than the requested config geometry, is | |||
| 112 | + | // what was actually rendered. Track it so movement (including a same-size | |||
| 113 | + | // resize) cannot reuse a cached popup at its former position. | |||
| 114 | + | popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation || cache.floatingGeometry != geometry | |||
| 115 | + | titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration | |||
| 116 | + | drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border) | |||
| 117 | + | if cache != nil { | |||
| 118 | + | cache.floating = p | |||
| 119 | + | cache.floatingGeneration = generation | |||
| 120 | + | cache.floatingGeometry = geometry | |||
| 121 | + | cache.floatingTitleGeneration = titleGeneration | |||
| 122 | + | } | |||
| 123 | + | if full || popupChanged { | |||
| 124 | + | return frame, []renderer.Damage{renderer.FullRedraw()} | |||
| 125 | + | } | |||
| 126 | + | if titleChanged && geometry.Bounds.Height >= 3 { | |||
| 127 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1}) | |||
| 128 | + | } | |||
| 129 | + | return frame, damage | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) { | |||
| 133 | + | if bounds.Width <= 0 || bounds.Height <= 0 { | |||
| 134 | + | return | |||
| 135 | + | } | |||
| 136 | + | // Each axis omits its borders independently for tiny popups, matching | |||
| 137 | + | // floatingInnerSize and leaving every cell available to the terminal. | |||
| 138 | + | if bounds.Height >= 3 { | |||
| 139 | + | for x := bounds.X; x < bounds.X+bounds.Width; x++ { | |||
| 140 | + | frame.Set(x, bounds.Y, renderer.Cell{Rune: '─', Style: style}) | |||
| 141 | + | frame.Set(x, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '─', Style: style}) | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | if bounds.Width >= 3 { | |||
| 145 | + | for y := bounds.Y; y < bounds.Y+bounds.Height; y++ { | |||
| 146 | + | frame.Set(bounds.X, y, renderer.Cell{Rune: '│', Style: style}) | |||
| 147 | + | frame.Set(bounds.X+bounds.Width-1, y, renderer.Cell{Rune: '│', Style: style}) | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | if bounds.Width >= 3 && bounds.Height >= 3 { | |||
| 151 | + | frame.Set(bounds.X, bounds.Y, renderer.Cell{Rune: '┌', Style: style}) | |||
| 152 | + | frame.Set(bounds.X+bounds.Width-1, bounds.Y, renderer.Cell{Rune: '┐', Style: style}) | |||
| 153 | + | frame.Set(bounds.X, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '└', Style: style}) | |||
| 154 | + | frame.Set(bounds.X+bounds.Width-1, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '┘', Style: style}) | |||
| 155 | + | ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize | |||
| 160 | + | // is intentionally outside pane.mu; failed resizes do not publish any state. | |||
| 161 | + | func (d *Daemon) resizeFloatingPane(p *pane, geometry floatingGeometry) bool { | |||
| 162 | + | inner := geometry.Inner | |||
| 163 | + | if p == nil || !geometry.valid() { | |||
| 164 | + | return false | |||
| 165 | + | } | |||
| 166 | + | p.resizeMu.Lock() | |||
| 167 | + | defer p.resizeMu.Unlock() | |||
| 168 | + | ||||
| 169 | + | p.mu.Lock() | |||
| 170 | + | old := p.rect | |||
| 171 | + | pty := p.pty | |||
| 172 | + | p.mu.Unlock() | |||
| 173 | + | if old.Width == inner.Width && old.Height == inner.Height { | |||
| 174 | + | // Position and outer bounds can change while the terminal dimensions do | |||
| 175 | + | // not. Commit that geometry without needlessly resetting the PTY/screen. | |||
| 176 | + | p.mu.Lock() | |||
| 177 | + | p.rect = inner | |||
| 178 | + | p.popupGeometry = geometry | |||
| 179 | + | p.mu.Unlock() | |||
| 180 | + | return true | |||
| 181 | + | } | |||
| 182 | + | if pty != nil { | |||
| 183 | + | if err := pty.Resize(rectSize(inner)); err != nil { | |||
| 184 | + | d.log.Warn("floating pty resize failed", "err", err) | |||
| 185 | + | return false | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | p.mu.Lock() | |||
| 189 | + | p.screen.Resize(inner.Width, inner.Height) | |||
| 190 | + | p.rect = inner | |||
| 191 | + | p.popupGeometry = geometry | |||
| 192 | + | p.mu.Unlock() | |||
| 193 | + | return true | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // resizeInstalledFloating updates an installed floating pane, including a | |||
| 197 | + | // retained hidden pane when its tab becomes active. | |||
| 198 | + | func (d *Daemon) resizeInstalledFloating(tb *tab) bool { | |||
| 199 | + | if d == nil || tb == nil { | |||
| 200 | + | return false | |||
| 201 | + | } | |||
| 202 | + | tb.mu.Lock() | |||
| 203 | + | p := tb.floating.pane | |||
| 204 | + | content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} | |||
| 205 | + | tb.mu.Unlock() | |||
| 206 | + | if p == nil { | |||
| 207 | + | return false | |||
| 208 | + | } | |||
| 209 | + | return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig())) | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // resizeActiveFloating updates a visible floating pane during a client resize. | |||
| 213 | + | func (d *Daemon) resizeActiveFloating(tb *tab) bool { | |||
| 214 | + | if d == nil || tb == nil { | |||
| 215 | + | return false | |||
| 216 | + | } | |||
| 217 | + | tb.mu.Lock() | |||
| 218 | + | visible := tb.floating.state == floatingVisible | |||
| 219 | + | tb.mu.Unlock() | |||
| 220 | + | if !visible { | |||
| 221 | + | return false | |||
| 222 | + | } | |||
| 223 | + | return d.resizeInstalledFloating(tb) | |||
| 224 | + | } | |||
A internal/usecase/daemon/floating_render_test.go +444 -0
| @@ -0,0 +1,444 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "io" | |||
| 6 | + | "strings" | |||
| 7 | + | "sync" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/domain" | |||
| 13 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 14 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestCalculateFloatingGeometry(t *testing.T) { | |||
| 18 | + | tests := []struct { | |||
| 19 | + | name string | |||
| 20 | + | content domain.Rect | |||
| 21 | + | cfg domain.FloatingConfig | |||
| 22 | + | want floatingGeometry | |||
| 23 | + | }{ | |||
| 24 | + | {"eighty percent centered", domain.Rect{X: 3, Y: 1, Width: 101, Height: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Bounds: domain.Rect{X: 13, Y: 6, Width: 80, Height: 40}, Inner: domain.Rect{X: 14, Y: 7, Width: 78, Height: 38}}}, | |||
| 25 | + | {"one percent clamps to one", domain.Rect{Y: 1, Width: 100, Height: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Bounds: domain.Rect{X: 49, Y: 10, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 10, Width: 1, Height: 1}}}, | |||
| 26 | + | {"full size", domain.Rect{Y: 1, Width: 100, Height: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Y: 1, Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 2, Width: 98, Height: 18}}}, | |||
| 27 | + | {"tiny axes omit borders", domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}}, | |||
| 28 | + | {"percent clamps", domain.Rect{Width: 10, Height: 10}, domain.FloatingConfig{Width: 101, Height: -1}, floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 1, Y: 4}, Inner: domain.Rect{X: 1, Width: 8, Y: 4, Height: 1}}}, | |||
| 29 | + | } | |||
| 30 | + | for _, tt := range tests { | |||
| 31 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 32 | + | require.Equal(t, tt.want, calculateFloatingGeometry(tt.content, tt.cfg)) | |||
| 33 | + | }) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | |||
| 38 | + | tests := []struct { | |||
| 39 | + | name string | |||
| 40 | + | available, percent int | |||
| 41 | + | want floatingAxisGeometry | |||
| 42 | + | }{ | |||
| 43 | + | {name: "one percent", available: 100, percent: 1, want: floatingAxisGeometry{BoundsSize: 1, InnerSize: 1}}, | |||
| 44 | + | {name: "full size reserves borders", available: 100, percent: 100, want: floatingAxisGeometry{BoundsSize: 100, InnerSize: 98, BorderOffset: 1}}, | |||
| 45 | + | {name: "two cells omits borders", available: 2, percent: 100, want: floatingAxisGeometry{BoundsSize: 2, InnerSize: 2}}, | |||
| 46 | + | {name: "three cells leaves one inner cell", available: 3, percent: 100, want: floatingAxisGeometry{BoundsSize: 3, InnerSize: 1, BorderOffset: 1}}, | |||
| 47 | + | {name: "percentage clamps", available: 10, percent: 101, want: floatingAxisGeometry{BoundsSize: 10, InnerSize: 8, BorderOffset: 1}}, | |||
| 48 | + | } | |||
| 49 | + | for _, tt := range tests { | |||
| 50 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 51 | + | require.Equal(t, tt.want, calculateFloatingAxisGeometry(tt.available, tt.percent)) | |||
| 52 | + | }) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) { | |||
| 57 | + | p := newPane("floating", nil, domain.Size{Cols: 6, Rows: 3}) | |||
| 58 | + | p.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'}) | |||
| 59 | + | p.title.processName, p.title.terminalTitle, p.title.generation = "nvim", "project/main.go", 1 | |||
| 60 | + | base := renderer.NewFrame(40, 12) | |||
| 61 | + | base.Set(2, 2, renderer.Cell{Rune: 'B'}) | |||
| 62 | + | content := domain.Rect{Y: 1, Width: 40, Height: 10} | |||
| 63 | + | cache := &composedFrameCache{} | |||
| 64 | + | frame, _ := composeFloatingFrame(base, nil, p, 1, content, domain.FloatingConfig{Width: 80, Height: 80}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |||
| 65 | + | require.Equal(t, 'B', base.At(2, 2).Rune, "backdrop must only touch the composed destination") | |||
| 66 | + | geometry := calculateFloatingGeometry(content, domain.FloatingConfig{Width: 80, Height: 80}) | |||
| 67 | + | require.Equal(t, '┌', frame.At(geometry.Bounds.X, geometry.Bounds.Y).Rune) | |||
| 68 | + | require.Equal(t, 'F', frame.At(geometry.Inner.X, geometry.Inner.Y).Rune) | |||
| 69 | + | var gotTitle strings.Builder | |||
| 70 | + | for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ { | |||
| 71 | + | gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune) | |||
| 72 | + | } | |||
| 73 | + | require.Equal(t, "nvim: project/main.go", strings.TrimRight(gotTitle.String(), "─")) | |||
| 74 | + | ||||
| 75 | + | p.mu.Lock() | |||
| 76 | + | p.title.generation++ | |||
| 77 | + | p.mu.Unlock() | |||
| 78 | + | _, damage := composeFloatingFrame(base, nil, p, 1, content, domain.FloatingConfig{Width: 80, Height: 80}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |||
| 79 | + | require.Len(t, damage, 1) | |||
| 80 | + | require.Equal(t, geometry.Bounds.Y, damage[0].Y) | |||
| 81 | + | require.Equal(t, 1, damage[0].Height) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestComposeFloatingFrameGeometryChangesInvalidateCache(t *testing.T) { | |||
| 85 | + | initial := floatingGeometry{ | |||
| 86 | + | Bounds: domain.Rect{X: 4, Y: 2, Width: 8, Height: 5}, | |||
| 87 | + | Inner: domain.Rect{X: 5, Y: 3, Width: 6, Height: 3}, | |||
| 88 | + | } | |||
| 89 | + | tests := []struct { | |||
| 90 | + | name string | |||
| 91 | + | next floatingGeometry | |||
| 92 | + | }{ | |||
| 93 | + | { | |||
| 94 | + | name: "position change", | |||
| 95 | + | next: floatingGeometry{Bounds: domain.Rect{X: 12, Y: 4, Width: 8, Height: 5}, Inner: domain.Rect{X: 13, Y: 5, Width: 6, Height: 3}}, | |||
| 96 | + | }, | |||
| 97 | + | { | |||
| 98 | + | name: "bounds change with same inner size", | |||
| 99 | + | next: floatingGeometry{Bounds: domain.Rect{X: 10, Y: 2, Width: 10, Height: 7}, Inner: domain.Rect{X: 12, Y: 4, Width: 6, Height: 3}}, | |||
| 100 | + | }, | |||
| 101 | + | } | |||
| 102 | + | for _, tt := range tests { | |||
| 103 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 104 | + | p := newPane("floating", nil, domain.Size{Cols: 6, Rows: 3}) | |||
| 105 | + | p.rect, p.popupGeometry = initial.Inner, initial | |||
| 106 | + | cache := &composedFrameCache{} | |||
| 107 | + | base := renderer.NewFrame(30, 12) | |||
| 108 | + | content := domain.Rect{Y: 1, Width: 30, Height: 10} | |||
| 109 | + | _, damage := composeFloatingFrame(base, nil, p, 1, content, domain.FloatingConfig{}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |||
| 110 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |||
| 111 | + | ||||
| 112 | + | p.mu.Lock() | |||
| 113 | + | p.rect, p.popupGeometry = tt.next.Inner, tt.next | |||
| 114 | + | p.mu.Unlock() | |||
| 115 | + | _, damage = composeFloatingFrame(base, nil, p, 1, content, domain.FloatingConfig{}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |||
| 116 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |||
| 117 | + | require.Equal(t, tt.next, cache.floatingGeometry) | |||
| 118 | + | }) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func TestResizeFloatingPaneCommitsSameSizeGeometryWithoutPTYResize(t *testing.T) { | |||
| 123 | + | initial := floatingGeometry{ | |||
| 124 | + | Bounds: domain.Rect{X: 1, Y: 1, Width: 8, Height: 5}, | |||
| 125 | + | Inner: domain.Rect{X: 2, Y: 2, Width: 6, Height: 3}, | |||
| 126 | + | } | |||
| 127 | + | tests := []struct { | |||
| 128 | + | name string | |||
| 129 | + | next floatingGeometry | |||
| 130 | + | }{ | |||
| 131 | + | { | |||
| 132 | + | name: "position change", | |||
| 133 | + | next: floatingGeometry{Bounds: domain.Rect{X: 9, Y: 4, Width: 8, Height: 5}, Inner: domain.Rect{X: 10, Y: 5, Width: 6, Height: 3}}, | |||
| 134 | + | }, | |||
| 135 | + | { | |||
| 136 | + | name: "bounds change", | |||
| 137 | + | next: floatingGeometry{Bounds: domain.Rect{X: 3, Y: 1, Width: 10, Height: 7}, Inner: domain.Rect{X: 5, Y: 3, Width: 6, Height: 3}}, | |||
| 138 | + | }, | |||
| 139 | + | } | |||
| 140 | + | for _, tt := range tests { | |||
| 141 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 142 | + | pty := &resizePTY{} | |||
| 143 | + | p := newPane("floating", pty, rectSize(initial.Inner)) | |||
| 144 | + | p.rect, p.popupGeometry = initial.Inner, initial | |||
| 145 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 146 | + | ||||
| 147 | + | require.True(t, d.resizeFloatingPane(p, tt.next)) | |||
| 148 | + | require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY") | |||
| 149 | + | require.Equal(t, tt.next.Inner, p.rect) | |||
| 150 | + | require.Equal(t, tt.next, p.popupGeometry) | |||
| 151 | + | require.Equal(t, 6, p.screen.Frame.Width) | |||
| 152 | + | require.Equal(t, 3, p.screen.Frame.Height) | |||
| 153 | + | }) | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestComposeFloatingFrameRendersPaneOwnedCommandFallback(t *testing.T) { | |||
| 158 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 159 | + | d.shell = "/usr/bin/fish" | |||
| 160 | + | p := newPane("floating", nil, domain.Size{Cols: 6, Rows: 3}) | |||
| 161 | + | p.title.displayFallback = floatingCommandFallback("btop --utf", d.shell) | |||
| 162 | + | require.Equal(t, "btop", d.refreshPaneDisplayTitle(nil, p, true)) | |||
| 163 | + | ||||
| 164 | + | base := renderer.NewFrame(40, 12) | |||
| 165 | + | content := domain.Rect{Y: 1, Width: 40, Height: 10} | |||
| 166 | + | cfg := domain.FloatingConfig{Width: 80, Height: 80} | |||
| 167 | + | frame, _ := composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, false) | |||
| 168 | + | geometry := calculateFloatingGeometry(content, cfg) | |||
| 169 | + | var gotTitle strings.Builder | |||
| 170 | + | for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ { | |||
| 171 | + | gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune) | |||
| 172 | + | } | |||
| 173 | + | require.Equal(t, "btop", strings.TrimRight(gotTitle.String(), "─")) | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) { | |||
| 177 | + | p := newPane("floating", nil, domain.Size{Cols: 80, Rows: 24}) | |||
| 178 | + | base := renderer.NewFrame(80, 24) | |||
| 179 | + | content := domain.Rect{Width: 80, Height: 24} | |||
| 180 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |||
| 181 | + | ||||
| 182 | + | start := make(chan struct{}) | |||
| 183 | + | var wg sync.WaitGroup | |||
| 184 | + | wg.Add(2) | |||
| 185 | + | go func() { | |||
| 186 | + | defer wg.Done() | |||
| 187 | + | <-start | |||
| 188 | + | for range 500 { | |||
| 189 | + | // This is the same pane lock used by ptyReader around Screen.Write. | |||
| 190 | + | p.mu.Lock() | |||
| 191 | + | p.screen.Write([]byte("\x1b[Hreader")) | |||
| 192 | + | p.mu.Unlock() | |||
| 193 | + | } | |||
| 194 | + | }() | |||
| 195 | + | go func() { | |||
| 196 | + | defer wg.Done() | |||
| 197 | + | <-start | |||
| 198 | + | cache := &composedFrameCache{} | |||
| 199 | + | for range 500 { | |||
| 200 | + | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |||
| 201 | + | } | |||
| 202 | + | }() | |||
| 203 | + | close(start) | |||
| 204 | + | wg.Wait() | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) { | |||
| 208 | + | frame := renderer.NewFrame(2, 1) | |||
| 209 | + | drawFloatingBorder(frame, domain.Rect{Width: 2, Height: 1}, "title", renderer.Style{}) | |||
| 210 | + | for _, cell := range frame.Cells { | |||
| 211 | + | require.Equal(t, renderer.BlankCell().Rune, cell.Rune) | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func TestRenderHiddenFloatingSkipsPreviewAndActiveClientAndClearsDamage(t *testing.T) { | |||
| 216 | + | normalPTY, releaseNormal := newBlockingPTY(t) | |||
| 217 | + | defer releaseNormal() | |||
| 218 | + | d, sess, _, activeSends := newManualSessionWithPTYs(t, normalPTY) | |||
| 219 | + | previewTransport, previewSends := newCapturingTransport(t) | |||
| 220 | + | preview := &attachedClient{tr: previewTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 221 | + | preview.initOverlays() | |||
| 222 | + | preview.setSession(sess) | |||
| 223 | + | ||||
| 224 | + | tb := sess.activeTab() | |||
| 225 | + | floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 8}) | |||
| 226 | + | floating.screen.Write([]byte("hidden output")) | |||
| 227 | + | tb.mu.Lock() | |||
| 228 | + | generation := tb.beginFloatingWarmLocked(false) | |||
| 229 | + | require.True(t, tb.installFloatingLocked(floating, generation)) | |||
| 230 | + | tb.previewClient = preview | |||
| 231 | + | tb.mu.Unlock() | |||
| 232 | + | ||||
| 233 | + | d.render(sess, tb, floating) | |||
| 234 | + | ||||
| 235 | + | floating.mu.Lock() | |||
| 236 | + | require.Empty(t, floating.screen.Damage()) | |||
| 237 | + | floating.mu.Unlock() | |||
| 238 | + | select { | |||
| 239 | + | case frame := <-previewSends: | |||
| 240 | + | t.Fatalf("hidden floating output painted picker preview: %#v", frame) | |||
| 241 | + | default: | |||
| 242 | + | } | |||
| 243 | + | select { | |||
| 244 | + | case frame := <-activeSends: | |||
| 245 | + | t.Fatalf("hidden floating output painted active client: %#v", frame) | |||
| 246 | + | default: | |||
| 247 | + | } | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | func BenchmarkComposeFloatingFrameCached(b *testing.B) { | |||
| 251 | + | p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18}) | |||
| 252 | + | p.title.displayFallback = "float" | |||
| 253 | + | base := renderer.NewFrame(80, 24) | |||
| 254 | + | content := domain.Rect{Y: 1, Width: 80, Height: 22} | |||
| 255 | + | cfg := domain.FloatingConfig{Width: 80, Height: 80} | |||
| 256 | + | cache := &composedFrameCache{} | |||
| 257 | + | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |||
| 258 | + | p.screen.ClearDamage() | |||
| 259 | + | ||||
| 260 | + | b.ReportAllocs() | |||
| 261 | + | b.ResetTimer() | |||
| 262 | + | for b.Loop() { | |||
| 263 | + | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | func TestComposeFloatingFrameCachedDoesNotAllocate(t *testing.T) { | |||
| 268 | + | p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18}) | |||
| 269 | + | p.title.displayFallback = "float" | |||
| 270 | + | base := renderer.NewFrame(80, 24) | |||
| 271 | + | content := domain.Rect{Y: 1, Width: 80, Height: 22} | |||
| 272 | + | cfg := domain.FloatingConfig{Width: 80, Height: 80} | |||
| 273 | + | cache := &composedFrameCache{} | |||
| 274 | + | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |||
| 275 | + | p.screen.ClearDamage() | |||
| 276 | + | ||||
| 277 | + | allocs := testing.AllocsPerRun(100, func() { | |||
| 278 | + | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |||
| 279 | + | }) | |||
| 280 | + | require.Zero(t, allocs, "cached floating composition must not allocate without damage or title changes") | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||
| 284 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 285 | + | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 50, Height: 50}}) | |||
| 286 | + | tb := newTab(nil, domain.Size{Cols: 80, Rows: 24}) | |||
| 287 | + | sess := &session{tabs: []*tab{tb}} | |||
| 288 | + | ||||
| 289 | + | initialGeometry := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 24}, d.currentFloatingConfig()) | |||
| 290 | + | currentGeometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 40}, d.currentFloatingConfig()) | |||
| 291 | + | initial := initialGeometry.Inner | |||
| 292 | + | current := currentGeometry.Inner | |||
| 293 | + | pty := &resizePTY{errs: []error{errors.New("first show fails"), nil}} | |||
| 294 | + | floating := newPane("floating", pty, rectSize(initial)) | |||
| 295 | + | floating.rect = initial | |||
| 296 | + | floating.popupGeometry = initialGeometry | |||
| 297 | + | ||||
| 298 | + | // Prewarming installs a hidden pane at the initial geometry. | |||
| 299 | + | tb.mu.Lock() | |||
| 300 | + | generation := tb.beginFloatingWarmLocked(false) | |||
| 301 | + | require.True(t, tb.installFloatingLocked(floating, generation)) | |||
| 302 | + | require.Equal(t, floatingHidden, tb.floating.state) | |||
| 303 | + | require.False(t, tb.floating.desiredVisible) | |||
| 304 | + | tb.mu.Unlock() | |||
| 305 | + | // A client resize changes tab geometry but must leave the hidden PTY alone. | |||
| 306 | + | d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42}) | |||
| 307 | + | require.Empty(t, pty.sizes()) | |||
| 308 | + | ||||
| 309 | + | // Showing attempts the current size before paint. A failed resize leaves the | |||
| 310 | + | // old screen and rect untouched, so a later hide/show can retry. | |||
| 311 | + | require.NoError(t, d.toggleFloating(sess, nil)) | |||
| 312 | + | require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes()) | |||
| 313 | + | require.Equal(t, initial, floating.rect) | |||
| 314 | + | require.Equal(t, initial.Width, floating.screen.Frame.Width) | |||
| 315 | + | require.Equal(t, initial.Height, floating.screen.Frame.Height) | |||
| 316 | + | require.Equal(t, initialGeometry, floating.popupGeometry) | |||
| 317 | + | ||||
| 318 | + | require.NoError(t, d.toggleFloating(sess, nil)) // hide | |||
| 319 | + | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | |||
| 320 | + | require.Equal(t, []domain.Size{rectSize(current), rectSize(current)}, pty.sizes()) | |||
| 321 | + | require.Equal(t, current, floating.rect) | |||
| 322 | + | require.Equal(t, current.Width, floating.screen.Frame.Width) | |||
| 323 | + | require.Equal(t, current.Height, floating.screen.Frame.Height) | |||
| 324 | + | require.Equal(t, currentGeometry, floating.popupGeometry) | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) { | |||
| 328 | + | cfg := domain.FloatingConfig{Width: 50, Height: 50} | |||
| 329 | + | oldContent := domain.Rect{Y: 1, Width: 80, Height: 24} | |||
| 330 | + | newContent := domain.Rect{Y: 1, Width: 100, Height: 40} | |||
| 331 | + | oldGeometry := calculateFloatingGeometry(oldContent, cfg) | |||
| 332 | + | newGeometry := calculateFloatingGeometry(newContent, cfg) | |||
| 333 | + | pty := &resizePTY{err: errors.New("resize failed")} | |||
| 334 | + | p := newPane("floating", pty, rectSize(oldGeometry.Inner)) | |||
| 335 | + | p.rect = oldGeometry.Inner | |||
| 336 | + | p.popupGeometry = oldGeometry | |||
| 337 | + | tb := newTab(nil, domain.Size{Cols: newContent.Width, Rows: newContent.Height}) | |||
| 338 | + | installTestFloating(tb, p, true) | |||
| 339 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 340 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 341 | + | ||||
| 342 | + | require.False(t, d.resizeFloatingPane(p, newGeometry)) | |||
| 343 | + | tb.mu.Lock() | |||
| 344 | + | _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | |||
| 345 | + | copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, cfg) | |||
| 346 | + | tb.mu.Unlock() | |||
| 347 | + | require.True(t, visible) | |||
| 348 | + | require.Equal(t, oldGeometry, inputGeometry) | |||
| 349 | + | require.Equal(t, oldGeometry.Inner, copyRect) | |||
| 350 | + | ||||
| 351 | + | base := renderer.NewFrame(newContent.Width, newContent.Height+2) | |||
| 352 | + | frame, _ := composeFloatingFrame(base, nil, p, 1, newContent, cfg, tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, true) | |||
| 353 | + | require.Equal(t, '┌', frame.At(oldGeometry.Bounds.X, oldGeometry.Bounds.Y).Rune) | |||
| 354 | + | if newGeometry.Bounds.X != oldGeometry.Bounds.X || newGeometry.Bounds.Y != oldGeometry.Bounds.Y { | |||
| 355 | + | require.NotEqual(t, '┌', frame.At(newGeometry.Bounds.X, newGeometry.Bounds.Y).Rune) | |||
| 356 | + | } | |||
| 357 | + | } | |||
| 358 | + | ||||
| 359 | + | func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |||
| 360 | + | t.Run("failure preserves state", func(t *testing.T) { | |||
| 361 | + | pty := &resizePTY{err: errors.New("nope")} | |||
| 362 | + | p := newPane("floating", pty, domain.Size{Cols: 5, Rows: 4}) | |||
| 363 | + | p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4} | |||
| 364 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 365 | + | requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}} | |||
| 366 | + | require.False(t, d.resizeFloatingPane(p, requested)) | |||
| 367 | + | require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect) | |||
| 368 | + | require.Equal(t, floatingGeometry{}, p.popupGeometry) | |||
| 369 | + | require.Equal(t, 5, p.screen.Frame.Width) | |||
| 370 | + | require.Equal(t, 4, p.screen.Frame.Height) | |||
| 371 | + | }) | |||
| 372 | + | ||||
| 373 | + | t.Run("competing resizes serialize", func(t *testing.T) { | |||
| 374 | + | pty := &resizePTY{entered: make(chan struct{}), release: make(chan struct{})} | |||
| 375 | + | p := newPane("floating", pty, domain.Size{Cols: 2, Rows: 2}) | |||
| 376 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 377 | + | first := floatingGeometry{Bounds: domain.Rect{Width: 6, Height: 5}, Inner: domain.Rect{X: 1, Y: 1, Width: 4, Height: 3}} | |||
| 378 | + | second := floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 8}, Inner: domain.Rect{X: 1, Y: 1, Width: 8, Height: 6}} | |||
| 379 | + | done1 := make(chan bool, 1) | |||
| 380 | + | done2 := make(chan bool, 1) | |||
| 381 | + | secondStarted := make(chan struct{}) | |||
| 382 | + | go func() { done1 <- d.resizeFloatingPane(p, first) }() | |||
| 383 | + | <-pty.entered | |||
| 384 | + | go func() { | |||
| 385 | + | close(secondStarted) | |||
| 386 | + | done2 <- d.resizeFloatingPane(p, second) | |||
| 387 | + | }() | |||
| 388 | + | <-secondStarted | |||
| 389 | + | close(pty.release) | |||
| 390 | + | require.True(t, <-done1) | |||
| 391 | + | require.True(t, <-done2) | |||
| 392 | + | require.Equal(t, second.Inner, p.rect) | |||
| 393 | + | require.Equal(t, second, p.popupGeometry) | |||
| 394 | + | require.Equal(t, 1, pty.maxConcurrentCalls()) | |||
| 395 | + | require.Equal(t, []domain.Size{{Cols: 4, Rows: 3}, {Cols: 8, Rows: 6}}, pty.sizes()) | |||
| 396 | + | }) | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | type resizePTY struct { | |||
| 400 | + | mu sync.Mutex | |||
| 401 | + | resizes []domain.Size | |||
| 402 | + | err error | |||
| 403 | + | errs []error | |||
| 404 | + | entered chan struct{} | |||
| 405 | + | release chan struct{} | |||
| 406 | + | activeCalls int | |||
| 407 | + | maxConcurrent int | |||
| 408 | + | } | |||
| 409 | + | ||||
| 410 | + | func (p *resizePTY) Resize(sz domain.Size) error { | |||
| 411 | + | p.mu.Lock() | |||
| 412 | + | p.resizes = append(p.resizes, sz) | |||
| 413 | + | p.activeCalls++ | |||
| 414 | + | p.maxConcurrent = max(p.maxConcurrent, p.activeCalls) | |||
| 415 | + | n := len(p.resizes) | |||
| 416 | + | err := p.err | |||
| 417 | + | if n <= len(p.errs) { | |||
| 418 | + | err = p.errs[n-1] | |||
| 419 | + | } | |||
| 420 | + | p.mu.Unlock() | |||
| 421 | + | if n == 1 && p.entered != nil { | |||
| 422 | + | close(p.entered) | |||
| 423 | + | <-p.release | |||
| 424 | + | } | |||
| 425 | + | p.mu.Lock() | |||
| 426 | + | p.activeCalls-- | |||
| 427 | + | p.mu.Unlock() | |||
| 428 | + | return err | |||
| 429 | + | } | |||
| 430 | + | func (p *resizePTY) maxConcurrentCalls() int { | |||
| 431 | + | p.mu.Lock() | |||
| 432 | + | defer p.mu.Unlock() | |||
| 433 | + | return p.maxConcurrent | |||
| 434 | + | } | |||
| 435 | + | func (p *resizePTY) sizes() []domain.Size { | |||
| 436 | + | p.mu.Lock() | |||
| 437 | + | defer p.mu.Unlock() | |||
| 438 | + | return append([]domain.Size(nil), p.resizes...) | |||
| 439 | + | } | |||
| 440 | + | func (*resizePTY) Read([]byte) (int, error) { return 0, io.EOF } | |||
| 441 | + | func (*resizePTY) Write(b []byte) (int, error) { return len(b), nil } | |||
| 442 | + | func (*resizePTY) Close() error { return nil } | |||
| 443 | + | func (*resizePTY) Pid() int { return 0 } | |||
| 444 | + | func (*resizePTY) ForegroundPgid() (int, error) { return 0, nil } | |||
A internal/usecase/daemon/floating_test.go +607 -0
| @@ -0,0 +1,607 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "io" | |||
| 7 | + | "log/slog" | |||
| 8 | + | "strings" | |||
| 9 | + | "sync" | |||
| 10 | + | "testing" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ||||
| 16 | + | "github.com/bnema/vev/internal/domain" | |||
| 17 | + | "github.com/bnema/vev/internal/ports" | |||
| 18 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 19 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 20 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 21 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | type gatedFloatingOpen struct { | |||
| 25 | + | command string | |||
| 26 | + | args []string | |||
| 27 | + | dir string | |||
| 28 | + | size domain.Size | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func newGatedOpenFactory(t *testing.T, result ports.PTY, openErr error) (*portsmocks.MockPTYFactory, <-chan gatedFloatingOpen, func()) { | |||
| 32 | + | t.Helper() | |||
| 33 | + | factory := portsmocks.NewMockPTYFactory(t) | |||
| 34 | + | opened := make(chan gatedFloatingOpen, 1) | |||
| 35 | + | release := make(chan struct{}) | |||
| 36 | + | var releaseOnce sync.Once | |||
| 37 | + | releaseOpen := func() { releaseOnce.Do(func() { close(release) }) } | |||
| 38 | + | t.Cleanup(releaseOpen) | |||
| 39 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 40 | + | func(command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) { | |||
| 41 | + | opened <- gatedFloatingOpen{command: command, args: append([]string(nil), args...), dir: dir, size: size} | |||
| 42 | + | <-release | |||
| 43 | + | return result, openErr | |||
| 44 | + | }).Once() | |||
| 45 | + | return factory, opened, releaseOpen | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestFloatingSlotTransitions(t *testing.T) { | |||
| 49 | + | first := &pane{} | |||
| 50 | + | second := &pane{} | |||
| 51 | + | tests := []struct { | |||
| 52 | + | name string | |||
| 53 | + | run func(t *testing.T, tb *tab) | |||
| 54 | + | }{ | |||
| 55 | + | { | |||
| 56 | + | name: "warming double toggle retains hidden completion", | |||
| 57 | + | run: func(t *testing.T, tb *tab) { | |||
| 58 | + | tb.mu.Lock() | |||
| 59 | + | generation := tb.beginFloatingWarmLocked(false) | |||
| 60 | + | start, gotGeneration := tb.toggleFloatingLocked() | |||
| 61 | + | if start || gotGeneration != generation || !tb.floating.desiredVisible { | |||
| 62 | + | t.Fatalf("first warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible) | |||
| 63 | + | } | |||
| 64 | + | start, gotGeneration = tb.toggleFloatingLocked() | |||
| 65 | + | if start || gotGeneration != generation || tb.floating.desiredVisible { | |||
| 66 | + | t.Fatalf("second warming toggle = start %v, generation %d, desired %v", start, gotGeneration, tb.floating.desiredVisible) | |||
| 67 | + | } | |||
| 68 | + | if !tb.installFloatingLocked(first, generation) || tb.floating.state != floatingHidden || tb.floating.pane != first { | |||
| 69 | + | t.Fatal("warming completion did not retain a hidden pane") | |||
| 70 | + | } | |||
| 71 | + | tb.mu.Unlock() | |||
| 72 | + | }, | |||
| 73 | + | }, | |||
| 74 | + | { | |||
| 75 | + | name: "open and hide retain same pane", | |||
| 76 | + | run: func(t *testing.T, tb *tab) { | |||
| 77 | + | tb.mu.Lock() | |||
| 78 | + | generation := tb.beginFloatingWarmLocked(false) | |||
| 79 | + | if !tb.installFloatingLocked(first, generation) { | |||
| 80 | + | t.Fatal("install failed") | |||
| 81 | + | } | |||
| 82 | + | start, _ := tb.toggleFloatingLocked() | |||
| 83 | + | if start || tb.floating.state != floatingVisible || tb.floating.pane != first { | |||
| 84 | + | t.Fatal("hidden open did not retain pane") | |||
| 85 | + | } | |||
| 86 | + | tb.toggleFloatingLocked() | |||
| 87 | + | if tb.floating.state != floatingHidden || tb.floating.pane != first { | |||
| 88 | + | t.Fatal("visible hide did not retain pane") | |||
| 89 | + | } | |||
| 90 | + | tb.mu.Unlock() | |||
| 91 | + | }, | |||
| 92 | + | }, | |||
| 93 | + | { | |||
| 94 | + | name: "current launch failure clears desired visibility", | |||
| 95 | + | run: func(t *testing.T, tb *tab) { | |||
| 96 | + | tb.mu.Lock() | |||
| 97 | + | generation := tb.beginFloatingWarmLocked(true) | |||
| 98 | + | if !tb.failFloatingLocked(generation) || tb.floating.state != floatingUninitialized || tb.floating.desiredVisible || tb.floating.pane != nil { | |||
| 99 | + | t.Fatal("current launch failure did not clear slot") | |||
| 100 | + | } | |||
| 101 | + | tb.mu.Unlock() | |||
| 102 | + | }, | |||
| 103 | + | }, | |||
| 104 | + | { | |||
| 105 | + | name: "current exit clears and stale completion is ignored", | |||
| 106 | + | run: func(t *testing.T, tb *tab) { | |||
| 107 | + | tb.mu.Lock() | |||
| 108 | + | generation := tb.beginFloatingWarmLocked(true) | |||
| 109 | + | if !tb.installFloatingLocked(first, generation) { | |||
| 110 | + | t.Fatal("install failed") | |||
| 111 | + | } | |||
| 112 | + | if !tb.clearFloatingLocked(first, generation) || tb.floating.state != floatingUninitialized || tb.floating.pane != nil { | |||
| 113 | + | t.Fatal("current exit did not clear slot") | |||
| 114 | + | } | |||
| 115 | + | newGeneration := tb.beginFloatingWarmLocked(true) | |||
| 116 | + | if tb.installFloatingLocked(second, generation) { | |||
| 117 | + | t.Fatal("stale completion installed") | |||
| 118 | + | } | |||
| 119 | + | if tb.floating.state != floatingWarming || tb.floating.generation != newGeneration || tb.floating.pane != nil { | |||
| 120 | + | t.Fatal("stale completion changed newer warming state") | |||
| 121 | + | } | |||
| 122 | + | tb.mu.Unlock() | |||
| 123 | + | }, | |||
| 124 | + | }, | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | for _, tt := range tests { | |||
| 128 | + | t.Run(tt.name, func(t *testing.T) { tt.run(t, &tab{}) }) | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) { | |||
| 133 | + | pty := portsmocks.NewMockPTY(t) | |||
| 134 | + | readerStarted := make(chan struct{}) | |||
| 135 | + | release := make(chan struct{}) | |||
| 136 | + | var releaseOnce sync.Once | |||
| 137 | + | unblock := func() { releaseOnce.Do(func() { close(release) }) } | |||
| 138 | + | pty.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) { | |||
| 139 | + | close(readerStarted) | |||
| 140 | + | <-release | |||
| 141 | + | return 0, context.Canceled | |||
| 142 | + | }).Once() | |||
| 143 | + | pty.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Once() | |||
| 144 | + | factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil) | |||
| 145 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 146 | + | d.shell = "/bin/custom-shell" | |||
| 147 | + | cwd := t.TempDir() | |||
| 148 | + | tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40}) | |||
| 149 | + | tb.ctx, tb.cancel = context.WithCancel(t.Context()) | |||
| 150 | + | sess := &session{name: "work", cwd: cwd, tabs: []*tab{tb}, ctx: t.Context()} | |||
| 151 | + | d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}}) | |||
| 152 | + | d.ensureFloatingWarm(sess, tb) | |||
| 153 | + | // Open has started while this goroutine owns tab.mu: an external factory | |||
| 154 | + | // call under that lock would deadlock this channel-controlled test. | |||
| 155 | + | tb.mu.Lock() | |||
| 156 | + | var openCall gatedFloatingOpen | |||
| 157 | + | select { | |||
| 158 | + | case openCall = <-opened: | |||
| 159 | + | case <-time.After(time.Second): | |||
| 160 | + | t.Fatal("floating Open waited for tab.mu") | |||
| 161 | + | } | |||
| 162 | + | tb.mu.Unlock() | |||
| 163 | + | d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "later", Width: 90, Height: 90}}) | |||
| 164 | + | allowOpen() | |||
| 165 | + | select { | |||
| 166 | + | case <-readerStarted: // install started exactly one reader after the async Open | |||
| 167 | + | case <-time.After(time.Second): | |||
| 168 | + | t.Fatal("floating pane was not installed") | |||
| 169 | + | } | |||
| 170 | + | tb.mu.Lock() | |||
| 171 | + | floating := tb.floating.pane | |||
| 172 | + | tb.mu.Unlock() | |||
| 173 | + | require.NotNil(t, floating) | |||
| 174 | + | assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background) | |||
| 175 | + | assertPaneColorScheme(t, floating, false) | |||
| 176 | + | require.Equal(t, "/bin/custom-shell", openCall.command) | |||
| 177 | + | require.Equal(t, []string{"-lc", "btop --utf"}, openCall.args) | |||
| 178 | + | require.Equal(t, cwd, openCall.dir) | |||
| 179 | + | require.Equal(t, domain.Size{Cols: 48, Rows: 18}, openCall.size) | |||
| 180 | + | d.teardownFloating(tb, nil) | |||
| 181 | + | unblock() | |||
| 182 | + | d.sessWg.Wait() | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) { | |||
| 186 | + | floatingPTY := portsmocks.NewMockPTY(t) | |||
| 187 | + | readerStarted := make(chan struct{}) | |||
| 188 | + | readerRelease := make(chan struct{}) | |||
| 189 | + | var releaseOnce sync.Once | |||
| 190 | + | releaseReader := func() { releaseOnce.Do(func() { close(readerRelease) }) } | |||
| 191 | + | var closeCount int | |||
| 192 | + | floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) { | |||
| 193 | + | close(readerStarted) | |||
| 194 | + | <-readerRelease | |||
| 195 | + | return 0, io.EOF | |||
| 196 | + | }).Once() | |||
| 197 | + | floatingPTY.EXPECT().Close().RunAndReturn(func() error { | |||
| 198 | + | closeCount++ | |||
| 199 | + | releaseReader() | |||
| 200 | + | return nil | |||
| 201 | + | }).Once() | |||
| 202 | + | factory, opened, releaseOpen := newGatedOpenFactory(t, floatingPTY, nil) | |||
| 203 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 204 | + | tb := newFloatingTestTab(t) | |||
| 205 | + | sessCtx, cancel := context.WithCancel(t.Context()) | |||
| 206 | + | sess := &session{id: "floating-ownership", name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancel} | |||
| 207 | + | d.mu.Lock() | |||
| 208 | + | d.sessions[sess.id] = sess | |||
| 209 | + | d.mu.Unlock() | |||
| 210 | + | ||||
| 211 | + | // Keep a known worker alive while the waiter starts. Releasing it after | |||
| 212 | + | // Open is blocked deterministically exposes an uncounted launch worker. | |||
| 213 | + | d.sessWg.Add(1) | |||
| 214 | + | joined := waitGroupDone(&d.sessWg) | |||
| 215 | + | d.startFloating(sess, tb, true) | |||
| 216 | + | select { | |||
| 217 | + | case <-opened: | |||
| 218 | + | case <-time.After(time.Second): | |||
| 219 | + | t.Fatal("floating Open did not start") | |||
| 220 | + | } | |||
| 221 | + | d.sessWg.Done() | |||
| 222 | + | // Waiting while Open is blocked must retain the launch worker. Otherwise | |||
| 223 | + | // daemon shutdown can finish and a late install can Add reader goroutines. | |||
| 224 | + | launchUnaccounted := false | |||
| 225 | + | select { | |||
| 226 | + | case <-joined: | |||
| 227 | + | launchUnaccounted = true | |||
| 228 | + | case <-time.After(time.Second): | |||
| 229 | + | } | |||
| 230 | + | releaseOpen() | |||
| 231 | + | select { | |||
| 232 | + | case <-readerStarted: | |||
| 233 | + | case <-time.After(time.Second): | |||
| 234 | + | t.Fatal("floating reader did not start") | |||
| 235 | + | } | |||
| 236 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |||
| 237 | + | select { | |||
| 238 | + | case <-waitGroupDone(&d.sessWg): | |||
| 239 | + | case <-time.After(time.Second): | |||
| 240 | + | t.Fatal("floating launch, reader, and scheduler did not join") | |||
| 241 | + | } | |||
| 242 | + | tb.mu.Lock() | |||
| 243 | + | require.Nil(t, tb.floating.pane, "shutdown leaked an installed floating pane") | |||
| 244 | + | require.Equal(t, floatingUninitialized, tb.floating.state) | |||
| 245 | + | tb.mu.Unlock() | |||
| 246 | + | require.False(t, launchUnaccounted, "blocked floating launch was not accounted by sessWg") | |||
| 247 | + | require.Equal(t, 1, closeCount, "floating PTY must close exactly once") | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *testing.T) { | |||
| 251 | + | pty := portsmocks.NewMockPTY(t) | |||
| 252 | + | closed := make(chan struct{}) | |||
| 253 | + | pty.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once() | |||
| 254 | + | factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil) | |||
| 255 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 256 | + | tb := newFloatingTestTab(t) | |||
| 257 | + | sess := &session{name: "work", tabs: []*tab{tb}, ctx: t.Context()} | |||
| 258 | + | d.ensureFloatingWarm(sess, tb) | |||
| 259 | + | <-opened | |||
| 260 | + | d.teardownFloating(tb, nil) // invalidate before the delayed Open returns | |||
| 261 | + | allowOpen() | |||
| 262 | + | select { | |||
| 263 | + | case <-closed: | |||
| 264 | + | case <-time.After(time.Second): | |||
| 265 | + | t.Fatal("stale completion did not close its PTY") | |||
| 266 | + | } | |||
| 267 | + | d.sessWg.Wait() | |||
| 268 | + | ||||
| 269 | + | first := &pane{} | |||
| 270 | + | second := &pane{} | |||
| 271 | + | tb.mu.Lock() | |||
| 272 | + | g1 := tb.beginFloatingWarmLocked(true) | |||
| 273 | + | require.True(t, tb.installFloatingLocked(first, g1)) | |||
| 274 | + | g2 := tb.clearFloatingLocked(first, g1) | |||
| 275 | + | require.True(t, g2) | |||
| 276 | + | generation := tb.beginFloatingWarmLocked(true) | |||
| 277 | + | require.True(t, tb.installFloatingLocked(second, generation)) | |||
| 278 | + | tb.mu.Unlock() | |||
| 279 | + | d.reapFloating(sess, tb, first, g1) | |||
| 280 | + | tb.mu.Lock() | |||
| 281 | + | require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement") | |||
| 282 | + | tb.mu.Unlock() | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | func TestFloatingReapAndTeardownCloseMatchingPaneOnce(t *testing.T) { | |||
| 286 | + | pty, _ := newBlockingPTY(t) | |||
| 287 | + | tb := newFloatingTestTab(t) | |||
| 288 | + | p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10}) | |||
| 289 | + | tb.mu.Lock() | |||
| 290 | + | g := tb.beginFloatingWarmLocked(true) | |||
| 291 | + | require.True(t, tb.installFloatingLocked(p, g)) | |||
| 292 | + | tb.mu.Unlock() | |||
| 293 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 294 | + | d.reapFloating(&session{}, tb, p, g) | |||
| 295 | + | d.teardownFloating(tb, nil) | |||
| 296 | + | pty.AssertNumberOfCalls(t, "Close", 1) | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | func TestFloatingTeardownClosesInstalledPaneOnce(t *testing.T) { | |||
| 300 | + | pty, _ := newBlockingPTY(t) | |||
| 301 | + | tb := newFloatingTestTab(t) | |||
| 302 | + | p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10}) | |||
| 303 | + | tb.mu.Lock() | |||
| 304 | + | g := tb.beginFloatingWarmLocked(false) | |||
| 305 | + | require.True(t, tb.installFloatingLocked(p, g)) | |||
| 306 | + | tb.mu.Unlock() | |||
| 307 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 308 | + | d.teardownFloating(tb, nil) | |||
| 309 | + | d.teardownFloating(tb, nil) | |||
| 310 | + | pty.AssertNumberOfCalls(t, "Close", 1) | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | func TestFloatingTeardownClearsCapturedCopyMode(t *testing.T) { | |||
| 314 | + | pty, _ := newBlockingPTY(t) | |||
| 315 | + | tb := newFloatingTestTab(t) | |||
| 316 | + | p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10}) | |||
| 317 | + | tb.mu.Lock() | |||
| 318 | + | generation := tb.beginFloatingWarmLocked(true) | |||
| 319 | + | require.True(t, tb.installFloatingLocked(p, generation)) | |||
| 320 | + | tb.mu.Unlock() | |||
| 321 | + | ac := &attachedClient{} | |||
| 322 | + | ac.initOverlays() | |||
| 323 | + | ac.overlays.copyMu.Lock() | |||
| 324 | + | ac.overlays.copyMode = &scopy.Mode{} | |||
| 325 | + | ac.overlays.copyPane = p | |||
| 326 | + | ac.overlays.copyMu.Unlock() | |||
| 327 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 328 | + | ||||
| 329 | + | d.teardownFloating(tb, ac) | |||
| 330 | + | ||||
| 331 | + | require.False(t, ac.overlays.copyActive()) | |||
| 332 | + | ac.overlays.copyMu.Lock() | |||
| 333 | + | require.Nil(t, ac.overlays.copyPane) | |||
| 334 | + | ac.overlays.copyMu.Unlock() | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) { | |||
| 338 | + | newCase := func(t *testing.T, state floatingState) (*Daemon, *session, *attachedClient, chan ports.Frame, *tab, *pane, func()) { | |||
| 339 | + | t.Helper() | |||
| 340 | + | normalPTY, releaseNormal := newBlockingPTY(t) | |||
| 341 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, normalPTY) | |||
| 342 | + | tb := sess.activeTab() | |||
| 343 | + | require.NotNil(t, tb) | |||
| 344 | + | tb.mu.Lock() | |||
| 345 | + | normal := tb.focusedPane() | |||
| 346 | + | normal.mu.Lock() | |||
| 347 | + | normal.screen.Write([]byte("underlying-cell")) | |||
| 348 | + | normal.mu.Unlock() | |||
| 349 | + | ||||
| 350 | + | floatingPTY := portsmocks.NewMockPTY(t) | |||
| 351 | + | floatingPTY.EXPECT().Close().Return(nil).Maybe() | |||
| 352 | + | releaseEOF := make(chan struct{}) | |||
| 353 | + | floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) { | |||
| 354 | + | <-releaseEOF | |||
| 355 | + | return 0, io.EOF | |||
| 356 | + | }).Once() | |||
| 357 | + | floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8}) | |||
| 358 | + | generation := tb.beginFloatingWarmLocked(state == floatingVisible) | |||
| 359 | + | require.True(t, tb.installFloatingLocked(floating, generation)) | |||
| 360 | + | if state == floatingHidden { | |||
| 361 | + | require.Equal(t, floatingHidden, tb.floating.state) | |||
| 362 | + | } | |||
| 363 | + | tb.mu.Unlock() | |||
| 364 | + | ||||
| 365 | + | // Establish a renderer shadow first. The EOF repaint must reset it and | |||
| 366 | + | // redraw the underlying cell rather than depending on popup damage. | |||
| 367 | + | d.paint(sess, ac, true) | |||
| 368 | + | baseline := awaitFrame(t, sends, ports.MsgOutput) | |||
| 369 | + | baselineOutput, err := ports.UnmarshalOutput(baseline.Payload) | |||
| 370 | + | require.NoError(t, err) | |||
| 371 | + | require.Contains(t, string(baselineOutput.Data), "underlying-cell") | |||
| 372 | + | ||||
| 373 | + | reaped := make(chan struct{}) | |||
| 374 | + | floating.onExit = func() { | |||
| 375 | + | d.reapFloating(sess, tb, floating, generation) | |||
| 376 | + | close(reaped) | |||
| 377 | + | } | |||
| 378 | + | d.sessWg.Add(1) | |||
| 379 | + | go d.ptyReader(sess, tb, floating) | |||
| 380 | + | return d, sess, ac, sends, tb, floating, func() { | |||
| 381 | + | close(releaseEOF) | |||
| 382 | + | <-reaped | |||
| 383 | + | d.sessWg.Wait() | |||
| 384 | + | releaseNormal() | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | t.Run("visible matching EOF clears and fully repaints underlying content", func(t *testing.T) { | |||
| 389 | + | _, _, _, sends, tb, floating, exit := newCase(t, floatingVisible) | |||
| 390 | + | exit() | |||
| 391 | + | ||||
| 392 | + | tb.mu.Lock() | |||
| 393 | + | require.Equal(t, floatingUninitialized, tb.floating.state) | |||
| 394 | + | require.Nil(t, tb.floating.pane) | |||
| 395 | + | tb.mu.Unlock() | |||
| 396 | + | repaint := awaitFrame(t, sends, ports.MsgOutput) | |||
| 397 | + | output, err := ports.UnmarshalOutput(repaint.Payload) | |||
| 398 | + | require.NoError(t, err) | |||
| 399 | + | require.Zero(t, output.BaseStateNum, "visible EOF must force a dependency-free full repaint") | |||
| 400 | + | require.Contains(t, string(output.Data), "underlying-cell", "repaint must restore cells previously covered by the popup") | |||
| 401 | + | require.NotNil(t, floating) | |||
| 402 | + | }) | |||
| 403 | + | ||||
| 404 | + | t.Run("hidden matching EOF clears without repaint", func(t *testing.T) { | |||
| 405 | + | _, _, _, sends, tb, _, exit := newCase(t, floatingHidden) | |||
| 406 | + | exit() | |||
| 407 | + | ||||
| 408 | + | tb.mu.Lock() | |||
| 409 | + | require.Equal(t, floatingUninitialized, tb.floating.state) | |||
| 410 | + | require.Nil(t, tb.floating.pane) | |||
| 411 | + | tb.mu.Unlock() | |||
| 412 | + | select { | |||
| 413 | + | case frame := <-sends: | |||
| 414 | + | t.Fatalf("hidden EOF unexpectedly repainted: %#v", frame) | |||
| 415 | + | default: | |||
| 416 | + | } | |||
| 417 | + | }) | |||
| 418 | + | ||||
| 419 | + | t.Run("stale EOF is ignored without repaint", func(t *testing.T) { | |||
| 420 | + | _, _, _, sends, tb, stale, exit := newCase(t, floatingHidden) | |||
| 421 | + | tb.mu.Lock() | |||
| 422 | + | generation := tb.floating.generation | |||
| 423 | + | require.True(t, tb.clearFloatingLocked(stale, generation)) | |||
| 424 | + | current := newPane(layout.PaneID("replacement"), newBlockingPanePTY(t), domain.Size{Cols: 20, Rows: 8}) | |||
| 425 | + | currentGeneration := tb.beginFloatingWarmLocked(true) | |||
| 426 | + | require.True(t, tb.installFloatingLocked(current, currentGeneration)) | |||
| 427 | + | tb.mu.Unlock() | |||
| 428 | + | exit() | |||
| 429 | + | ||||
| 430 | + | tb.mu.Lock() | |||
| 431 | + | require.Same(t, current, tb.floating.pane) | |||
| 432 | + | require.Equal(t, floatingVisible, tb.floating.state) | |||
| 433 | + | tb.mu.Unlock() | |||
| 434 | + | select { | |||
| 435 | + | case frame := <-sends: | |||
| 436 | + | t.Fatalf("stale EOF unexpectedly repainted: %#v", frame) | |||
| 437 | + | default: | |||
| 438 | + | } | |||
| 439 | + | }) | |||
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | func newFloatingTestTab(t *testing.T) *tab { | |||
| 443 | + | t.Helper() | |||
| 444 | + | tb := newTabWithStableID("t_test", "p_test", newBlockingPanePTY(t), domain.Size{Cols: 80, Rows: 24}) | |||
| 445 | + | tb.ctx, tb.cancel = context.WithCancel(t.Context()) | |||
| 446 | + | return tb | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | func newBlockingPanePTY(t *testing.T) *portsmocks.MockPTY { | |||
| 450 | + | t.Helper() | |||
| 451 | + | p, _ := newBlockingPTY(t) | |||
| 452 | + | return p | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) { | |||
| 456 | + | tests := []struct { | |||
| 457 | + | name string | |||
| 458 | + | sessionCwd string | |||
| 459 | + | liveCwd string | |||
| 460 | + | wantCwd string | |||
| 461 | + | }{ | |||
| 462 | + | {name: "valid session cwd", sessionCwd: "/valid", wantCwd: "/valid"}, | |||
| 463 | + | {name: "invalid session cwd falls home", sessionCwd: "/invalid", wantCwd: "/home/test"}, | |||
| 464 | + | {name: "live focused cwd", sessionCwd: "/invalid", liveCwd: "/live", wantCwd: "/live"}, | |||
| 465 | + | } | |||
| 466 | + | for _, tt := range tests { | |||
| 467 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 468 | + | factory := portsmocks.NewMockPTYFactory(t) | |||
| 469 | + | opened := make(chan string, 1) | |||
| 470 | + | factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 471 | + | func(_ string, _ []string, _ []string, cwd string, _ domain.Size) (ports.PTY, error) { | |||
| 472 | + | opened <- cwd | |||
| 473 | + | return nil, io.ErrUnexpectedEOF | |||
| 474 | + | }).Once() | |||
| 475 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 476 | + | d.dirOrHome = func(cwd string) string { | |||
| 477 | + | if cwd == "/invalid" { | |||
| 478 | + | return "/home/test" | |||
| 479 | + | } | |||
| 480 | + | return cwd | |||
| 481 | + | } | |||
| 482 | + | if tt.liveCwd != "" { | |||
| 483 | + | d.procCwd = func(int) (string, error) { return tt.liveCwd, nil } | |||
| 484 | + | } | |||
| 485 | + | tb := newFloatingTestTab(t) | |||
| 486 | + | sess := &session{name: "work", cwd: tt.sessionCwd, tabs: []*tab{tb}, ctx: t.Context()} | |||
| 487 | + | d.startFloating(sess, tb, false) | |||
| 488 | + | select { | |||
| 489 | + | case got := <-opened: | |||
| 490 | + | require.Equal(t, tt.wantCwd, got) | |||
| 491 | + | case <-time.After(time.Second): | |||
| 492 | + | t.Fatal("floating PTY was not opened") | |||
| 493 | + | } | |||
| 494 | + | d.sessWg.Wait() | |||
| 495 | + | }) | |||
| 496 | + | } | |||
| 497 | + | } | |||
| 498 | + | ||||
| 499 | + | type floatingLogHandler struct { | |||
| 500 | + | slog.Handler | |||
| 501 | + | handled chan struct{} | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | func (h floatingLogHandler) Handle(ctx context.Context, r slog.Record) error { | |||
| 505 | + | err := h.Handler.Handle(ctx, r) | |||
| 506 | + | close(h.handled) | |||
| 507 | + | return err | |||
| 508 | + | } | |||
| 509 | + | ||||
| 510 | + | func TestFloatingLaunchFailureCapturesSessionNameBeforeConcurrentRename(t *testing.T) { | |||
| 511 | + | factory, opened, release := newGatedOpenFactory(t, nil, io.ErrUnexpectedEOF) | |||
| 512 | + | var logs bytes.Buffer | |||
| 513 | + | handled := make(chan struct{}) | |||
| 514 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 515 | + | d.log = slog.New(floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled}) | |||
| 516 | + | tb := newFloatingTestTab(t) | |||
| 517 | + | sess := &session{name: "captured", tabs: []*tab{tb}, ctx: t.Context()} | |||
| 518 | + | d.startFloating(sess, tb, false) | |||
| 519 | + | <-opened | |||
| 520 | + | stopRename := make(chan struct{}) | |||
| 521 | + | renameDone := make(chan struct{}) | |||
| 522 | + | go func() { | |||
| 523 | + | defer close(renameDone) | |||
| 524 | + | for { | |||
| 525 | + | select { | |||
| 526 | + | case <-stopRename: | |||
| 527 | + | return | |||
| 528 | + | default: | |||
| 529 | + | } | |||
| 530 | + | sess.mu.Lock() | |||
| 531 | + | sess.name = "renamed" | |||
| 532 | + | sess.mu.Unlock() | |||
| 533 | + | } | |||
| 534 | + | }() | |||
| 535 | + | release() | |||
| 536 | + | select { | |||
| 537 | + | case <-handled: | |||
| 538 | + | case <-time.After(time.Second): | |||
| 539 | + | t.Fatal("floating launch failure was not logged") | |||
| 540 | + | } | |||
| 541 | + | close(stopRename) | |||
| 542 | + | <-renameDone | |||
| 543 | + | d.sessWg.Wait() | |||
| 544 | + | require.True(t, strings.Contains(logs.String(), "session=captured"), logs.String()) | |||
| 545 | + | } | |||
| 546 | + | ||||
| 547 | + | func TestFloatingInstallLogsSessionNameSafelyDuringRename(t *testing.T) { | |||
| 548 | + | floatingPTY, _ := newBlockingPTY(t) | |||
| 549 | + | factory, opened, allowOpen := newGatedOpenFactory(t, floatingPTY, nil) | |||
| 550 | + | var logs bytes.Buffer | |||
| 551 | + | handled := make(chan struct{}) | |||
| 552 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 553 | + | d.log = slog.New(floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled}) | |||
| 554 | + | tb := newFloatingTestTab(t) | |||
| 555 | + | sess := &session{name: "captured", tabs: []*tab{tb}, ctx: t.Context()} | |||
| 556 | + | ||||
| 557 | + | d.startFloating(sess, tb, false) | |||
| 558 | + | <-opened | |||
| 559 | + | renamed := make(chan struct{}) | |||
| 560 | + | stopRename := make(chan struct{}) | |||
| 561 | + | renameDone := make(chan struct{}) | |||
| 562 | + | go func() { | |||
| 563 | + | defer close(renameDone) | |||
| 564 | + | sess.mu.Lock() | |||
| 565 | + | sess.name = "renamed" | |||
| 566 | + | sess.mu.Unlock() | |||
| 567 | + | close(renamed) | |||
| 568 | + | for { | |||
| 569 | + | select { | |||
| 570 | + | case <-stopRename: | |||
| 571 | + | return | |||
| 572 | + | default: | |||
| 573 | + | } | |||
| 574 | + | sess.mu.Lock() | |||
| 575 | + | sess.name = "renamed" | |||
| 576 | + | sess.mu.Unlock() | |||
| 577 | + | } | |||
| 578 | + | }() | |||
| 579 | + | <-renamed | |||
| 580 | + | allowOpen() | |||
| 581 | + | select { | |||
| 582 | + | case <-handled: | |||
| 583 | + | case <-time.After(time.Second): | |||
| 584 | + | t.Fatal("installed floating pane was not logged") | |||
| 585 | + | } | |||
| 586 | + | close(stopRename) | |||
| 587 | + | <-renameDone | |||
| 588 | + | require.Contains(t, logs.String(), "session=renamed", logs.String()) | |||
| 589 | + | ||||
| 590 | + | d.teardownFloating(tb, nil) | |||
| 591 | + | d.sessWg.Wait() | |||
| 592 | + | } | |||
| 593 | + | ||||
| 594 | + | func TestFloatingToggleUsesVisibleTarget(t *testing.T) { | |||
| 595 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 596 | + | tb := newFloatingTestTab(t) | |||
| 597 | + | sess := &session{tabs: []*tab{tb}, ctx: t.Context()} | |||
| 598 | + | tb.mu.Lock() | |||
| 599 | + | g := tb.beginFloatingWarmLocked(false) | |||
| 600 | + | floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8}) | |||
| 601 | + | require.True(t, tb.installFloatingLocked(floating, g)) | |||
| 602 | + | tb.mu.Unlock() | |||
| 603 | + | require.NoError(t, d.toggleFloating(sess, nil)) | |||
| 604 | + | tb.mu.Lock() | |||
| 605 | + | require.Same(t, floating, tb.terminalTargetLocked()) | |||
| 606 | + | tb.mu.Unlock() | |||
| 607 | + | } | |||
M internal/usecase/daemon/input.go +52 -1
| @@ -74,6 +74,30 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 74 | if rt.copySearchActive() { | 74 | if rt.copySearchActive() { | ||
| 75 | return | 75 | return | ||
| 76 | } | 76 | } | ||
| 77 | + | copyPane := copyTargetPane(rt) | |||
| 78 | + | tb.mu.Lock() | |||
| 79 | + | floatingPane, geometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |||
| 80 | + | floatingCopy := floatingVisible && copyPane == floatingPane | |||
| 81 | + | tb.mu.Unlock() | |||
| 82 | + | if floatingCopy { | |||
| 83 | + | contentRow := ev.Row - 1 | |||
| 84 | + | if !pointInRect(ev.Col, contentRow, geometry.Inner) { | |||
| 85 | + | return | |||
| 86 | + | } | |||
| 87 | + | // Match the multi-pane translation: hand copyMouse a zero-based | |||
| 88 | + | // popup row so a click selects exactly the row under the pointer. | |||
| 89 | + | ev.Row = contentRow | |||
| 90 | + | ev = translateMouseEvent(ev, geometry.Inner.X, geometry.Inner.Y) | |||
| 91 | + | switch ev.Button { | |||
| 92 | + | case mouse.Left: | |||
| 93 | + | d.copyMouse(sess, ac, ev) | |||
| 94 | + | case mouse.WheelUp: | |||
| 95 | + | d.copyWheel(sess, ac, -3) | |||
| 96 | + | case mouse.WheelDown: | |||
| 97 | + | d.copyWheel(sess, ac, 3) | |||
| 98 | + | } | |||
| 99 | + | return | |||
| 100 | + | } | |||
| 77 | if ev.Button == mouse.Left { | 101 | if ev.Button == mouse.Left { | ||
| 78 | contentRow := ev.Row - 1 | 102 | contentRow := ev.Row - 1 | ||
| 79 | tb.mu.Lock() | 103 | tb.mu.Lock() | ||
| @@ -120,6 +144,17 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 120 | 144 | ||||
| 121 | tb.mu.Lock() | 145 | tb.mu.Lock() | ||
| 122 | contentRow := ev.Row - 1 | 146 | contentRow := ev.Row - 1 | ||
| 147 | + | floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |||
| 148 | + | if floatingVisible { | |||
| 149 | + | if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) { | |||
| 150 | + | tb.mu.Unlock() | |||
| 151 | + | return | |||
| 152 | + | } | |||
| 153 | + | tb.mu.Unlock() | |||
| 154 | + | ev = translateMouseEvent(ev, floatingGeometry.Inner.X, floatingGeometry.Inner.Y) | |||
| 155 | + | d.handleTerminalMouse(sess, ac, floating, ev, true, true) | |||
| 156 | + | return | |||
| 157 | + | } | |||
| 123 | pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow) | 158 | pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow) | ||
| 124 | multi := len(tb.panes) > 1 | 159 | multi := len(tb.panes) > 1 | ||
| 125 | focusedID := layout.PaneID("") | 160 | focusedID := layout.PaneID("") | ||
| @@ -177,6 +212,14 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 177 | return | 212 | return | ||
| 178 | } | 213 | } | ||
| 179 | } | 214 | } | ||
| 215 | + | d.handleTerminalMouse(sess, ac, p, ev, translated, hoveredFocused) | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, ev mouse.Event, translated, hoveredFocused bool) { | |||
| 219 | + | if p == nil { | |||
| 220 | + | return | |||
| 221 | + | } | |||
| 222 | + | rt := ac.overlays | |||
| 180 | p.mu.Lock() | 223 | p.mu.Lock() | ||
| 181 | childRows := p.screen.Frame.Height | 224 | childRows := p.screen.Frame.Height | ||
| 182 | mouseMode, mouseSGR := p.screen.MouseMode() | 225 | mouseMode, mouseSGR := p.screen.MouseMode() | ||
| @@ -236,6 +279,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 236 | mode.StartSelectionAt(snap, pressTop+pressRow) | 279 | mode.StartSelectionAt(snap, pressTop+pressRow) | ||
| 237 | mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row) | 280 | mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row) | ||
| 238 | rt.copyMode = mode | 281 | rt.copyMode = mode | ||
| 282 | + | rt.copyPane = p | |||
| 239 | rt.copyPressRow = pressTop + pressRow | 283 | rt.copyPressRow = pressTop + pressRow | ||
| 240 | rt.copyPressRowValid = true | 284 | rt.copyPressRowValid = true | ||
| 241 | rt.copyDragging = true | 285 | rt.copyDragging = true | ||
| @@ -285,7 +329,9 @@ func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) { | |||||
| 285 | if _, err := p.pty.Write(data); err != nil { | 329 | if _, err := p.pty.Write(data); err != nil { | ||
| 286 | name := "" | 330 | name := "" | ||
| 287 | if sess != nil { | 331 | if sess != nil { | ||
| 332 | + | sess.mu.Lock() | |||
| 288 | name = sess.name | 333 | name = sess.name | ||
| 334 | + | sess.mu.Unlock() | |||
| 289 | } | 335 | } | ||
| 290 | d.log.Error("pty write failed", "err", err, "session", name) | 336 | d.log.Error("pty write failed", "err", err, "session", name) | ||
| 291 | } | 337 | } | ||
| @@ -348,7 +394,7 @@ func (h daemonKeyHandler) Forward(data []byte) { | |||||
| 348 | return | 394 | return | ||
| 349 | } | 395 | } | ||
| 350 | tb.mu.Lock() | 396 | tb.mu.Lock() | ||
| 351 | - | p := tb.focusedPane() | 397 | + | p := tb.terminalTargetLocked() |
| 352 | tb.mu.Unlock() | 398 | tb.mu.Unlock() | ||
| 353 | h.d.writeToPane(sess, p, data) | 399 | h.d.writeToPane(sess, p, data) | ||
| 354 | } | 400 | } | ||
| @@ -363,6 +409,10 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||||
| 363 | h.d.enterPalette(sess, h.ac) | 409 | h.d.enterPalette(sess, h.ac) | ||
| 364 | case keys.ActionJumpAttention: | 410 | case keys.ActionJumpAttention: | ||
| 365 | h.d.jumpAttention(sess, h.ac) | 411 | h.d.jumpAttention(sess, h.ac) | ||
| 412 | + | case keys.ActionToggleFloatingPane: | |||
| 413 | + | if err := h.d.toggleFloating(sess, h.ac); err != nil { | |||
| 414 | + | h.d.log.Warn("toggle floating pane failed", "err", err) | |||
| 415 | + | } | |||
| 366 | case keys.ActionFocusPaneLeft: | 416 | case keys.ActionFocusPaneLeft: | ||
| 367 | _ = h.d.focusDir(sess, h.ac, layout.Left) | 417 | _ = h.d.focusDir(sess, h.ac, layout.Left) | ||
| 368 | case keys.ActionFocusPaneRight: | 418 | case keys.ActionFocusPaneRight: | ||
| @@ -376,6 +426,7 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||||
| 376 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | 426 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | ||
| 377 | idx := int(action - keys.ActionSwitchTab1) | 427 | idx := int(action - keys.ActionSwitchTab1) | ||
| 378 | if sess.switchTab(idx) { | 428 | if sess.switchTab(idx) { | ||
| 429 | + | h.d.activateTab(sess, sess.activeTab()) | |||
| 379 | h.d.paint(sess, h.ac, true) | 430 | h.d.paint(sess, h.ac, true) | ||
| 380 | } | 431 | } | ||
| 381 | } | 432 | } | ||
M internal/usecase/daemon/input_test.go +179 -0
| @@ -2,6 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "encoding/base64" | 4 | "encoding/base64" | ||
| 5 | + | "fmt" | |||
| 5 | "strings" | 6 | "strings" | ||
| 6 | "sync" | 7 | "sync" | ||
| 7 | "testing" | 8 | "testing" | ||
| @@ -14,6 +15,7 @@ import ( | |||||
| 14 | "github.com/bnema/vev/internal/ports" | 15 | "github.com/bnema/vev/internal/ports" | ||
| 15 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 16 | scopy "github.com/bnema/vev/internal/usecase/copy" | 17 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 18 | + | "github.com/bnema/vev/internal/usecase/keys" | |||
| 17 | "github.com/bnema/vev/internal/usecase/layout" | 19 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 18 | ) | 20 | ) | ||
| 19 | 21 | ||||
| @@ -60,6 +62,22 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) { | |||||
| 60 | require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond) | 62 | require.Eventually(t, func() bool { return len(writes3) == 1 }, 2*time.Second, 5*time.Millisecond) | ||
| 61 | require.Equal(t, []byte("C"), <-writes3) | 63 | require.Equal(t, []byte("C"), <-writes3) | ||
| 62 | 64 | ||||
| 65 | + | d.mu.Lock() | |||
| 66 | + | var sess *session | |||
| 67 | + | for _, candidate := range d.sessions { | |||
| 68 | + | sess = candidate | |||
| 69 | + | break | |||
| 70 | + | } | |||
| 71 | + | d.mu.Unlock() | |||
| 72 | + | require.NotNil(t, sess) | |||
| 73 | + | sess.mu.Lock() | |||
| 74 | + | tabs := append([]*tab(nil), sess.tabs...) | |||
| 75 | + | sess.mu.Unlock() | |||
| 76 | + | require.Len(t, tabs, 3) | |||
| 77 | + | for _, tb := range tabs { | |||
| 78 | + | requireFloatingInitialized(t, tb) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 63 | releaseConn() | 81 | releaseConn() | ||
| 64 | releasePTY1() | 82 | releasePTY1() | ||
| 65 | releasePTY2() | 83 | releasePTY2() | ||
| @@ -79,6 +97,157 @@ func TestAltCForwardsToPTY(t *testing.T) { | |||||
| 79 | releasePTY() | 97 | releasePTY() | ||
| 80 | } | 98 | } | ||
| 81 | 99 | ||||
| 100 | + | func requireFloatingInitialized(t *testing.T, tb *tab) { | |||
| 101 | + | t.Helper() | |||
| 102 | + | tb.mu.Lock() | |||
| 103 | + | defer tb.mu.Unlock() | |||
| 104 | + | require.NotEqual(t, floatingUninitialized, tb.floating.state) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func installTestFloating(tb *tab, p *pane, visible bool) { | |||
| 108 | + | tb.mu.Lock() | |||
| 109 | + | defer tb.mu.Unlock() | |||
| 110 | + | tb.floating.pane = p | |||
| 111 | + | tb.floating.generation++ | |||
| 112 | + | if visible { | |||
| 113 | + | tb.floating.state = floatingVisible | |||
| 114 | + | } else { | |||
| 115 | + | tb.floating.state = floatingHidden | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestFloatingKeyboardRoutesVisibleAndHiddenInput(t *testing.T) { | |||
| 120 | + | for _, tc := range []struct { | |||
| 121 | + | name string | |||
| 122 | + | visible bool | |||
| 123 | + | input []byte | |||
| 124 | + | }{ | |||
| 125 | + | {name: "visible ordinary bytes", visible: true, input: []byte("hello")}, | |||
| 126 | + | {name: "visible escape", visible: true, input: []byte("\x1b")}, | |||
| 127 | + | {name: "hidden ordinary bytes", visible: false, input: []byte("hello")}, | |||
| 128 | + | {name: "hidden escape", visible: false, input: []byte("\x1b")}, | |||
| 129 | + | } { | |||
| 130 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 131 | + | normalWrites := make(chan []byte, 1) | |||
| 132 | + | floatingWrites := make(chan []byte, 1) | |||
| 133 | + | normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites) | |||
| 134 | + | floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites) | |||
| 135 | + | defer releaseNormal() | |||
| 136 | + | defer releaseFloating() | |||
| 137 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |||
| 138 | + | installTestFloating(sess.activeTab(), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), tc.visible) | |||
| 139 | + | ||||
| 140 | + | daemonKeyHandler{d: d, ac: ac}.Forward(tc.input) | |||
| 141 | + | if tc.visible { | |||
| 142 | + | requirePTYWrite(t, floatingWrites, tc.input) | |||
| 143 | + | requireNoPTYWrite(t, normalWrites) | |||
| 144 | + | } else { | |||
| 145 | + | requirePTYWrite(t, normalWrites, tc.input) | |||
| 146 | + | requireNoPTYWrite(t, floatingWrites) | |||
| 147 | + | } | |||
| 148 | + | }) | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func TestFloatingBracketedPasteAndGlobalActions(t *testing.T) { | |||
| 153 | + | normalWrites := make(chan []byte, 1) | |||
| 154 | + | floatingWrites := make(chan []byte, 1) | |||
| 155 | + | normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites) | |||
| 156 | + | floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites) | |||
| 157 | + | defer releaseNormal() | |||
| 158 | + | defer releaseFloating() | |||
| 159 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |||
| 160 | + | installTestFloating(sess.activeTab(), newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true) | |||
| 161 | + | ||||
| 162 | + | paste := []byte("\x1b[200~paste\x1bf\x1b[201~") | |||
| 163 | + | d.handleInput(sess, ac, paste) | |||
| 164 | + | requirePTYWrite(t, floatingWrites, paste) | |||
| 165 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 166 | + | require.True(t, ac.overlays.paletteActive(), "global palette action must still intercept") | |||
| 167 | + | requireNoPTYWrite(t, normalWrites) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func TestFloatingStaysTerminalTargetAfterDirectionalFocus(t *testing.T) { | |||
| 171 | + | writes := make(chan []byte, 1) | |||
| 172 | + | floatingWrites := make(chan []byte, 1) | |||
| 173 | + | normal, releaseNormal := newBlockingPTYWithWrites(t, writes) | |||
| 174 | + | floating, releaseFloating := newBlockingPTYWithWrites(t, floatingWrites) | |||
| 175 | + | defer releaseNormal() | |||
| 176 | + | defer releaseFloating() | |||
| 177 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |||
| 178 | + | tb := sess.activeTab() | |||
| 179 | + | second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5}) | |||
| 180 | + | tb.mu.Lock() | |||
| 181 | + | tb.size = domain.Size{Cols: 81, Rows: 5} | |||
| 182 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |||
| 183 | + | tb.panes["pane-2"] = second | |||
| 184 | + | tb.mu.Unlock() | |||
| 185 | + | installTestFloating(tb, newPane("floating", floating, domain.Size{Cols: 20, Rows: 5}), true) | |||
| 186 | + | ||||
| 187 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionFocusPaneRight) | |||
| 188 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |||
| 189 | + | daemonKeyHandler{d: d, ac: ac}.Forward([]byte("x")) | |||
| 190 | + | requirePTYWrite(t, floatingWrites, []byte("x")) | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func TestFloatingVisibilityRemainsIndependentAcrossTabSwitches(t *testing.T) { | |||
| 194 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 195 | + | first := sess.activeTab() | |||
| 196 | + | second := newTab(nil, first.size) | |||
| 197 | + | installTestFloating(first, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | |||
| 198 | + | installTestFloating(second, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), false) | |||
| 199 | + | sess.mu.Lock() | |||
| 200 | + | sess.tabs = append(sess.tabs, second) | |||
| 201 | + | sess.mu.Unlock() | |||
| 202 | + | ||||
| 203 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2) | |||
| 204 | + | second.mu.Lock() | |||
| 205 | + | require.Equal(t, floatingHidden, second.floating.state) | |||
| 206 | + | second.mu.Unlock() | |||
| 207 | + | ||||
| 208 | + | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab1) | |||
| 209 | + | first.mu.Lock() | |||
| 210 | + | require.Equal(t, floatingVisible, first.floating.state) | |||
| 211 | + | first.mu.Unlock() | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) { | |||
| 215 | + | normal := portsmocks.NewMockPTY(t) | |||
| 216 | + | floatingPTY := portsmocks.NewMockPTY(t) | |||
| 217 | + | floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once() | |||
| 218 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |||
| 219 | + | tb := sess.activeTab() | |||
| 220 | + | tb.mu.Lock() | |||
| 221 | + | tb.size = domain.Size{Cols: 100, Rows: 20} | |||
| 222 | + | tb.mu.Unlock() | |||
| 223 | + | floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14}) | |||
| 224 | + | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |||
| 225 | + | installTestFloating(tb, floating, true) | |||
| 226 | + | geometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 20}, d.currentFloatingConfig()) | |||
| 227 | + | raw := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2)) | |||
| 228 | + | ||||
| 229 | + | d.handleInput(sess, ac, raw) | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) { | |||
| 233 | + | normal := portsmocks.NewMockPTY(t) | |||
| 234 | + | floatingPTY := portsmocks.NewMockPTY(t) | |||
| 235 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |||
| 236 | + | tb := sess.activeTab() | |||
| 237 | + | tb.mu.Lock() | |||
| 238 | + | tb.size = domain.Size{Cols: 100, Rows: 20} | |||
| 239 | + | tb.mu.Unlock() | |||
| 240 | + | floating := newPane("floating", floatingPTY, domain.Size{Cols: 78, Rows: 14}) | |||
| 241 | + | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |||
| 242 | + | installTestFloating(tb, floating, true) | |||
| 243 | + | geometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 20}, d.currentFloatingConfig()) | |||
| 244 | + | ||||
| 245 | + | border := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+2)) | |||
| 246 | + | outside := []byte("\x1b[<0;1;2M") | |||
| 247 | + | d.handleInput(sess, ac, border) | |||
| 248 | + | d.handleInput(sess, ac, outside) | |||
| 249 | + | } | |||
| 250 | + | ||||
| 82 | func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) { | 251 | func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) { | ||
| 83 | writes := make(chan []byte, 1) | 252 | writes := make(chan []byte, 1) | ||
| 84 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | 253 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | ||
| @@ -196,6 +365,16 @@ func TestOverlayInputPrecedence(t *testing.T) { | |||||
| 196 | } | 365 | } | ||
| 197 | } | 366 | } | ||
| 198 | 367 | ||||
| 368 | + | func requirePTYWrite(t *testing.T, writes chan []byte, want []byte) { | |||
| 369 | + | t.Helper() | |||
| 370 | + | select { | |||
| 371 | + | case got := <-writes: | |||
| 372 | + | require.Equal(t, want, got) | |||
| 373 | + | case <-time.After(time.Second): | |||
| 374 | + | t.Fatalf("PTY did not receive %q", want) | |||
| 375 | + | } | |||
| 376 | + | } | |||
| 377 | + | ||||
| 199 | func requireNoPTYWrite(t *testing.T, writes chan []byte) { | 378 | func requireNoPTYWrite(t *testing.T, writes chan []byte) { | ||
| 200 | t.Helper() | 379 | t.Helper() | ||
| 201 | select { | 380 | select { | ||
M internal/usecase/daemon/overlay_runtime.go +17 -0
| @@ -30,6 +30,7 @@ type overlayRuntime struct { | |||||
| 30 | 30 | ||||
| 31 | copyMu sync.Mutex | 31 | copyMu sync.Mutex | ||
| 32 | copyMode *scopy.Mode | 32 | copyMode *scopy.Mode | ||
| 33 | + | copyPane *pane | |||
| 33 | copyPending []byte | 34 | copyPending []byte | ||
| 34 | copyESC pendingByteTimer | 35 | copyESC pendingByteTimer | ||
| 35 | copySearch *visualsearch.Model | 36 | copySearch *visualsearch.Model | ||
| @@ -101,10 +102,24 @@ func (rt *overlayRuntime) copySearchActive() bool { | |||||
| 101 | 102 | ||||
| 102 | func (rt *overlayRuntime) clearCopyModeLocked() { | 103 | func (rt *overlayRuntime) clearCopyModeLocked() { | ||
| 103 | rt.copyMode = nil | 104 | rt.copyMode = nil | ||
| 105 | + | rt.copyPane = nil | |||
| 104 | rt.copySearch = nil | 106 | rt.copySearch = nil | ||
| 105 | rt.copySearchPending = nil | 107 | rt.copySearchPending = nil | ||
| 106 | } | 108 | } | ||
| 107 | 109 | ||||
| 110 | + | func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | |||
| 111 | + | if rt == nil || p == nil { | |||
| 112 | + | return false | |||
| 113 | + | } | |||
| 114 | + | rt.copyMu.Lock() | |||
| 115 | + | defer rt.copyMu.Unlock() | |||
| 116 | + | if rt.copyMode == nil || rt.copyPane != p { | |||
| 117 | + | return false | |||
| 118 | + | } | |||
| 119 | + | rt.clearCopyModeLocked() | |||
| 120 | + | return true | |||
| 121 | + | } | |||
| 122 | + | ||||
| 108 | func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool { | 123 | func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool { | ||
| 109 | if rt == nil || rt.ac == nil { | 124 | if rt == nil || rt.ac == nil { | ||
| 110 | return false | 125 | return false | ||
| @@ -134,6 +149,7 @@ type overlayRenderSnapshot struct { | |||||
| 134 | 149 | ||||
| 135 | copyActive bool | 150 | copyActive bool | ||
| 136 | copyMode *scopy.Mode | 151 | copyMode *scopy.Mode | ||
| 152 | + | copyPane *pane | |||
| 137 | copySearchModel *visualsearch.Model | 153 | copySearchModel *visualsearch.Model | ||
| 138 | copyFeedback string | 154 | copyFeedback string | ||
| 139 | 155 | ||||
| @@ -164,6 +180,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||||
| 164 | 180 | ||||
| 165 | rt.copyMu.Lock() | 181 | rt.copyMu.Lock() | ||
| 166 | snap.copyActive = rt.copyMode != nil | 182 | snap.copyActive = rt.copyMode != nil | ||
| 183 | + | snap.copyPane = rt.copyPane | |||
| 167 | if rt.copyMode != nil { | 184 | if rt.copyMode != nil { | ||
| 168 | copyModeValue := *rt.copyMode | 185 | copyModeValue := *rt.copyMode | ||
| 169 | copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...) | 186 | copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...) | ||
M internal/usecase/daemon/overlay_runtime_test.go +23 -0
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | 5 | ||||
| 6 | "github.com/stretchr/testify/require" | 6 | "github.com/stretchr/testify/require" | ||
| 7 | 7 | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | scopy "github.com/bnema/vev/internal/usecase/copy" | 9 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 9 | promptui "github.com/bnema/vev/internal/usecase/prompt" | 10 | promptui "github.com/bnema/vev/internal/usecase/prompt" | ||
| 10 | "github.com/bnema/vev/internal/usecase/visualsearch" | 11 | "github.com/bnema/vev/internal/usecase/visualsearch" | ||
| @@ -143,3 +144,25 @@ func TestOverlayRuntimeHandleInputInactive(t *testing.T) { | |||||
| 143 | 144 | ||||
| 144 | require.False(t, ac.overlays.HandleInput(&Daemon{}, []byte("x"))) | 145 | require.False(t, ac.overlays.HandleInput(&Daemon{}, []byte("x"))) | ||
| 145 | } | 146 | } | ||
| 147 | + | ||||
| 148 | + | func TestOverlayRuntimeSnapshotCarriesCopyPaneAndClearReleasesIt(t *testing.T) { | |||
| 149 | + | ac := &attachedClient{} | |||
| 150 | + | ac.initOverlays() | |||
| 151 | + | p := newPane("floating", nil, domain.Size{Cols: 4, Rows: 2}) | |||
| 152 | + | snap := scopy.Snapshot{Rows: [][]renderer.Cell{testRow("row")}, Width: 4, Height: 2} | |||
| 153 | + | ||||
| 154 | + | ac.overlays.copyMu.Lock() | |||
| 155 | + | ac.overlays.copyMode = scopy.NewMode(snap) | |||
| 156 | + | ac.overlays.copyPane = p | |||
| 157 | + | ac.overlays.copyMu.Unlock() | |||
| 158 | + | ||||
| 159 | + | renderSnap := ac.overlays.SnapshotForRender() | |||
| 160 | + | require.Same(t, p, renderSnap.copyPane) | |||
| 161 | + | renderSnap.Unlock() | |||
| 162 | + | ||||
| 163 | + | ac.overlays.copyMu.Lock() | |||
| 164 | + | ac.overlays.clearCopyModeLocked() | |||
| 165 | + | require.Nil(t, ac.overlays.copyPane) | |||
| 166 | + | require.Nil(t, ac.overlays.copyMode) | |||
| 167 | + | ac.overlays.copyMu.Unlock() | |||
| 168 | + | } | |||
M internal/usecase/daemon/palette.go +10 -2
| @@ -230,17 +230,25 @@ func (e paletteExec) FocusPaneDown() error { | |||||
| 230 | } | 230 | } | ||
| 231 | 231 | ||||
| 232 | func (e paletteExec) NextTab() error { | 232 | func (e paletteExec) NextTab() error { | ||
| 233 | - | e.sess.switchRelative(1) | 233 | + | if e.sess.switchRelative(1) { |
| 234 | + | e.d.activateTab(e.sess, e.sess.activeTab()) | |||
| 235 | + | } | |||
| 234 | e.d.paint(e.sess, e.ac, true) | 236 | e.d.paint(e.sess, e.ac, true) | ||
| 235 | return nil | 237 | return nil | ||
| 236 | } | 238 | } | ||
| 237 | 239 | ||||
| 238 | func (e paletteExec) PrevTab() error { | 240 | func (e paletteExec) PrevTab() error { | ||
| 239 | - | e.sess.switchRelative(-1) | 241 | + | if e.sess.switchRelative(-1) { |
| 242 | + | e.d.activateTab(e.sess, e.sess.activeTab()) | |||
| 243 | + | } | |||
| 240 | e.d.paint(e.sess, e.ac, true) | 244 | e.d.paint(e.sess, e.ac, true) | ||
| 241 | return nil | 245 | return nil | ||
| 242 | } | 246 | } | ||
| 243 | 247 | ||||
| 248 | + | func (e paletteExec) ToggleFloatingPane() error { | |||
| 249 | + | return e.d.toggleFloating(e.sess, e.ac) | |||
| 250 | + | } | |||
| 251 | + | ||||
| 244 | func (e paletteExec) BackSession() error { | 252 | func (e paletteExec) BackSession() error { | ||
| 245 | e.d.navigateRecentSession(e.sess, e.ac, 1) | 253 | e.d.navigateRecentSession(e.sess, e.ac, 1) | ||
| 246 | return nil | 254 | return nil | ||
M internal/usecase/daemon/palette_test.go +29 -5
| @@ -18,6 +18,10 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |||||
| 18 | p1, release1 := newBlockingPTY(t) | 18 | p1, release1 := newBlockingPTY(t) | ||
| 19 | p2, release2 := newBlockingPTY(t) | 19 | p2, release2 := newBlockingPTY(t) | ||
| 20 | d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2) | 20 | d, sess, ac, sends := newManualSessionWithPTYs(t, p1, p2) | ||
| 21 | + | sess.mu.Lock() | |||
| 22 | + | sess.client = ac | |||
| 23 | + | sess.mu.Unlock() | |||
| 24 | + | d.ptys = newBlockingOpenFactory(t, d) | |||
| 21 | defer release1() | 25 | defer release1() | ||
| 22 | defer release2() | 26 | defer release2() | ||
| 23 | 27 | ||||
| @@ -31,6 +35,7 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |||||
| 31 | d.handleInput(sess, ac, []byte("NXT\r")) | 35 | d.handleInput(sess, ac, []byte("NXT\r")) | ||
| 32 | require.False(t, ac.overlays.paletteActive()) | 36 | require.False(t, ac.overlays.paletteActive()) | ||
| 33 | require.Equal(t, 1, activeTabIndex(sess)) | 37 | require.Equal(t, 1, activeTabIndex(sess)) | ||
| 38 | + | requireFloatingInitialized(t, sess.activeTab()) | |||
| 34 | awaitFrame(t, sends, ports.MsgOutput) | 39 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 35 | 40 | ||||
| 36 | d.handleInput(sess, ac, []byte("\x1b ")) | 41 | d.handleInput(sess, ac, []byte("\x1b ")) | ||
| @@ -40,15 +45,31 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |||||
| 40 | awaitFrame(t, sends, ports.MsgOutput) | 45 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 41 | } | 46 | } | ||
| 42 | 47 | ||||
| 48 | + | func TestPaletteFLTExecutesFloatingToggle(t *testing.T) { | |||
| 49 | + | p, release := newBlockingPTY(t) | |||
| 50 | + | defer release() | |||
| 51 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 52 | + | tb := sess.activeTab() | |||
| 53 | + | installTestFloating(tb, newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), false) | |||
| 54 | + | ||||
| 55 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 56 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 57 | + | d.handleInput(sess, ac, []byte("FLT\r")) | |||
| 58 | + | ||||
| 59 | + | require.False(t, ac.overlays.paletteActive()) | |||
| 60 | + | tb.mu.Lock() | |||
| 61 | + | require.Equal(t, floatingVisible, tb.floating.state) | |||
| 62 | + | tb.mu.Unlock() | |||
| 63 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 43 | func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) { | 66 | func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) { | ||
| 44 | p1, release1 := newBlockingPTY(t) | 67 | p1, release1 := newBlockingPTY(t) | ||
| 45 | p2, release2 := newBlockingPTY(t) | 68 | p2, release2 := newBlockingPTY(t) | ||
| 46 | d, sess, ac, sends := newManualSessionWithPTYs(t, p1) | 69 | d, sess, ac, sends := newManualSessionWithPTYs(t, p1) | ||
| 47 | defer release1() | 70 | defer release1() | ||
| 48 | defer release2() | 71 | defer release2() | ||
| 49 | - | ptys := portsmocks.NewMockPTYFactory(t) | 72 | + | d.ptys = newFactorySeq(t, p2) |
| 50 | - | ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p2, nil).Once() | |||
| 51 | - | d.ptys = ptys | |||
| 52 | 73 | ||||
| 53 | d.handleInput(sess, ac, []byte("\x1b ")) | 74 | d.handleInput(sess, ac, []byte("\x1b ")) | ||
| 54 | awaitFrame(t, sends, ports.MsgOutput) | 75 | awaitFrame(t, sends, ports.MsgOutput) | ||
| @@ -83,6 +104,9 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) { | |||||
| 83 | 104 | ||||
| 84 | func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) { | 105 | func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) { | ||
| 85 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | 106 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | ||
| 107 | + | sess.mu.Lock() | |||
| 108 | + | sess.client = ac | |||
| 109 | + | sess.mu.Unlock() | |||
| 86 | defer releases[0]() | 110 | defer releases[0]() | ||
| 87 | defer releases[1]() | 111 | defer releases[1]() | ||
| 88 | 112 | ||||
| @@ -112,12 +136,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||||
| 112 | for i, cmd := range commands { | 136 | for i, cmd := range commands { | ||
| 113 | codes[i] = cmd.Code | 137 | codes[i] = cmd.Code | ||
| 114 | } | 138 | } | ||
| 115 | - | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "RNT", "DET"}, codes) | 139 | + | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "RNT", "DET"}, codes) |
| 116 | } | 140 | } | ||
| 117 | 141 | ||||
| 118 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | 142 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | ||
| 119 | d := &Daemon{} | 143 | d := &Daemon{} | ||
| 120 | - | codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"} | 144 | + | codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"} |
| 121 | 145 | ||||
| 122 | var wg sync.WaitGroup | 146 | var wg sync.WaitGroup | ||
| 123 | for range 50 { | 147 | for range 50 { | ||
M internal/usecase/daemon/pane.go +20 -16
| @@ -3,7 +3,6 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "sync" | 5 | "sync" | ||
| 6 | - | "time" | |||
| 7 | 6 | ||||
| 8 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/internal/ports" | 8 | "github.com/bnema/vev/internal/ports" | ||
| @@ -17,21 +16,25 @@ import ( | |||||
| 17 | // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | 16 | // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | ||
| 18 | // The PTY reader takes only pane.mu, so child output never waits on client IO. | 17 | // The PTY reader takes only pane.mu, so child output never waits on client IO. | ||
| 19 | type pane struct { | 18 | type pane struct { | ||
| 20 | - | id layout.PaneID | 19 | + | id layout.PaneID |
| 21 | - | stableID string | 20 | + | stableID string |
| 22 | - | pty ports.PTY | 21 | + | pty ports.PTY |
| 23 | - | mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title | 22 | + | mu sync.Mutex // guards screen, scrollback, syncGen, rect, and title |
| 24 | - | screen *vt.Screen | 23 | + | resizeMu sync.Mutex // serializes PTY resizes without holding mu |
| 25 | - | scrollback *scopy.Scrollback | 24 | + | screen *vt.Screen |
| 26 | - | dirty chan struct{} | 25 | + | scrollback *scopy.Scrollback |
| 27 | - | flush chan struct{} | 26 | + | dirty chan struct{} |
| 28 | - | syncGen uint64 | 27 | + | flush chan struct{} |
| 29 | - | rect domain.Rect | 28 | + | syncGen uint64 |
| 30 | - | title string | 29 | + | rect domain.Rect |
| 31 | - | titleAt time.Time | 30 | + | popupGeometry floatingGeometry // last geometry committed after a successful floating resize |
| 32 | - | titleValid bool | 31 | + | title paneTitleState |
| 33 | - | ctx context.Context | 32 | + | ctx context.Context |
| 34 | - | cancel context.CancelFunc | 33 | + | cancel context.CancelFunc |
| 34 | + | floatingCloseOnce sync.Once // used only by floating lifecycle teardown paths | |||
| 35 | + | // onExit is set before the reader starts and never changed. Floating panes | |||
| 36 | + | // use it to reap their independent slot without touching the layout tree. | |||
| 37 | + | onExit func() | |||
| 35 | } | 38 | } | ||
| 36 | 39 | ||||
| 37 | func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane { | 40 | func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane { | ||
| @@ -51,6 +54,7 @@ func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz do | |||||
| 51 | dirty: make(chan struct{}, 1), | 54 | dirty: make(chan struct{}, 1), | ||
| 52 | flush: make(chan struct{}, 1), | 55 | flush: make(chan struct{}, 1), | ||
| 53 | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, | 56 | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, | ||
| 57 | + | title: paneTitleState{displayFallback: "sh"}, | |||
| 54 | } | 58 | } | ||
| 55 | } | 59 | } | ||
| 56 | 60 | ||||
M internal/usecase/daemon/pane_titles.go +93 -14
| @@ -10,6 +10,69 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | const paneTitleCacheTTL = time.Second | 11 | const paneTitleCacheTTL = time.Second | ||
| 12 | 12 | ||||
| 13 | + | // paneTitleState is guarded by pane.mu. generation changes whenever the | |||
| 14 | + | // displayed title can change, allowing cached composition to damage only a | |||
| 15 | + | // title row rather than the entire stacked layout. | |||
| 16 | + | type paneTitleState struct { | |||
| 17 | + | processName string | |||
| 18 | + | processNameAt time.Time | |||
| 19 | + | processNameValid bool | |||
| 20 | + | terminalTitle string | |||
| 21 | + | displayFallback string | |||
| 22 | + | generation uint64 | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func formatPaneTitle(processName, terminalTitle, fallback string) string { | |||
| 26 | + | if processName != "" && terminalTitle != "" { | |||
| 27 | + | return processName + ": " + terminalTitle | |||
| 28 | + | } | |||
| 29 | + | if processName != "" { | |||
| 30 | + | return processName | |||
| 31 | + | } | |||
| 32 | + | if terminalTitle != "" { | |||
| 33 | + | return terminalTitle | |||
| 34 | + | } | |||
| 35 | + | return fallback | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func (p *pane) formattedTitleLocked(fallback string) string { | |||
| 39 | + | return formatPaneTitle(p.title.processName, p.title.terminalTitle, fallback) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func (p *pane) displayTitleLocked() string { | |||
| 43 | + | return p.formattedTitleLocked(p.title.displayFallback) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // setDisplayFallback updates the fallback owned by a pane. It must not damage | |||
| 47 | + | // cached composition unless that fallback changes the displayed title. | |||
| 48 | + | func (p *pane) setDisplayFallback(fallback string) { | |||
| 49 | + | p.mu.Lock() | |||
| 50 | + | oldTitle := p.displayTitleLocked() | |||
| 51 | + | p.title.displayFallback = fallback | |||
| 52 | + | if oldTitle != p.displayTitleLocked() { | |||
| 53 | + | p.title.generation++ | |||
| 54 | + | } | |||
| 55 | + | p.mu.Unlock() | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // refreshTerminalTitleLocked synchronizes the title retained by the VT parser. | |||
| 59 | + | // It must be called while p.mu is held, including by ptyReader around Write. | |||
| 60 | + | func (p *pane) refreshTerminalTitleLocked() { | |||
| 61 | + | title := p.screen.TerminalTitle() | |||
| 62 | + | if p.title.terminalTitle != title { | |||
| 63 | + | p.title.terminalTitle = title | |||
| 64 | + | p.title.generation++ | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (d *Daemon) paneTitleFallback() string { | |||
| 69 | + | fallback := filepath.Base(d.shell) | |||
| 70 | + | if fallback == "." || fallback == string(filepath.Separator) || fallback == "" { | |||
| 71 | + | return d.shell | |||
| 72 | + | } | |||
| 73 | + | return fallback | |||
| 74 | + | } | |||
| 75 | + | ||||
| 13 | func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string { | 76 | func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string { | ||
| 14 | return d.refreshPaneTitleCached(sess, id, false) | 77 | return d.refreshPaneTitleCached(sess, id, false) | ||
| 15 | } | 78 | } | ||
| @@ -18,11 +81,9 @@ func (d *Daemon) refreshPaneTitleOnFocus(sess *session, id layout.PaneID) string | |||||
| 18 | return d.refreshPaneTitleCached(sess, id, true) | 81 | return d.refreshPaneTitleCached(sess, id, true) | ||
| 19 | } | 82 | } | ||
| 20 | 83 | ||||
| 84 | + | // refreshPaneTitleCached retains the ID lookup used by normal layout panes. | |||
| 21 | func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool) string { | 85 | func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool) string { | ||
| 22 | - | fallback := filepath.Base(d.shell) | 86 | + | fallback := d.paneTitleFallback() |
| 23 | - | if fallback == "." || fallback == string(filepath.Separator) || fallback == "" { | |||
| 24 | - | fallback = d.shell | |||
| 25 | - | } | |||
| 26 | if sess == nil { | 87 | if sess == nil { | ||
| 27 | return fallback | 88 | return fallback | ||
| 28 | } | 89 | } | ||
| @@ -36,29 +97,47 @@ func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force b | |||||
| 36 | if p == nil { | 97 | if p == nil { | ||
| 37 | return fallback | 98 | return fallback | ||
| 38 | } | 99 | } | ||
| 100 | + | p.setDisplayFallback(fallback) | |||
| 101 | + | return d.refreshPaneDisplayTitle(sess, p, force) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // refreshPaneDisplayTitle refreshes only the process-name portion of p's | |||
| 105 | + | // display title. It deliberately releases pane.mu before querying the PTY so | |||
| 106 | + | // callers that already render under pane locking cannot recurse on that lock. | |||
| 107 | + | func (d *Daemon) refreshPaneDisplayTitle(_ *session, p *pane, force bool) string { | |||
| 108 | + | if p == nil { | |||
| 109 | + | return d.paneTitleFallback() | |||
| 110 | + | } | |||
| 39 | now := d.clock.Now() | 111 | now := d.clock.Now() | ||
| 40 | p.mu.Lock() | 112 | p.mu.Lock() | ||
| 41 | - | if !force && p.titleValid && now.Sub(p.titleAt) < paneTitleCacheTTL { | 113 | + | if !force && p.title.processNameValid && now.Sub(p.title.processNameAt) < paneTitleCacheTTL { |
| 42 | - | title := p.title | 114 | + | title := p.displayTitleLocked() |
| 43 | p.mu.Unlock() | 115 | p.mu.Unlock() | ||
| 44 | - | if title == "" { | |||
| 45 | - | return fallback | |||
| 46 | - | } | |||
| 47 | return title | 116 | return title | ||
| 48 | } | 117 | } | ||
| 49 | p.mu.Unlock() | 118 | p.mu.Unlock() | ||
| 50 | - | title := fallback | 119 | + | |
| 120 | + | // A failed lookup means no process name was discovered. Keep that state | |||
| 121 | + | // distinct from the display fallback: an OSC title must then stand alone, | |||
| 122 | + | // and future floating panes can supply their command fallback at format time. | |||
| 123 | + | processName := "" | |||
| 51 | if d.procComm != nil && p.pty != nil { | 124 | if d.procComm != nil && p.pty != nil { | ||
| 52 | if pgid, err := p.pty.ForegroundPgid(); err == nil && pgid > 0 { | 125 | if pgid, err := p.pty.ForegroundPgid(); err == nil && pgid > 0 { | ||
| 53 | if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" { | 126 | if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" { | ||
| 54 | - | title = strings.TrimSpace(comm) | 127 | + | processName = strings.TrimSpace(comm) |
| 55 | } | 128 | } | ||
| 56 | } | 129 | } | ||
| 57 | } | 130 | } | ||
| 131 | + | ||||
| 58 | p.mu.Lock() | 132 | p.mu.Lock() | ||
| 59 | - | p.title = title | 133 | + | oldTitle := p.displayTitleLocked() |
| 60 | - | p.titleAt = now | 134 | + | p.title.processName = processName |
| 61 | - | p.titleValid = true | 135 | + | p.title.processNameAt = now |
| 136 | + | p.title.processNameValid = true | |||
| 137 | + | title := p.displayTitleLocked() | |||
| 138 | + | if oldTitle != title { | |||
| 139 | + | p.title.generation++ | |||
| 140 | + | } | |||
| 62 | p.mu.Unlock() | 141 | p.mu.Unlock() | ||
| 63 | return title | 142 | return title | ||
| 64 | } | 143 | } | ||
M internal/usecase/daemon/pane_titles_test.go +111 -8
| @@ -8,12 +8,47 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 10 | 10 | ||||
| 11 | + | "github.com/bnema/vev/internal/domain" | |||
| 11 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 12 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| 15 | + | func TestFormatPaneTitle(t *testing.T) { | |||
| 16 | + | tests := []struct { | |||
| 17 | + | name, process, terminal, fallback, want string | |||
| 18 | + | }{ | |||
| 19 | + | {name: "process and terminal", process: "nvim", terminal: "project/main.go", fallback: "sh", want: "nvim: project/main.go"}, | |||
| 20 | + | {name: "process only", process: "nvim", fallback: "sh", want: "nvim"}, | |||
| 21 | + | {name: "terminal only", terminal: "project/main.go", fallback: "sh", want: "project/main.go"}, | |||
| 22 | + | {name: "fallback", fallback: "fish", want: "fish"}, | |||
| 23 | + | } | |||
| 24 | + | for _, tt := range tests { | |||
| 25 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 26 | + | require.Equal(t, tt.want, formatPaneTitle(tt.process, tt.terminal, tt.fallback)) | |||
| 27 | + | }) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestPaneTerminalTitleGenerationChangesOnlyWhenTitleChanges(t *testing.T) { | |||
| 32 | + | p := newPane("pane", nil, domain.Size{Cols: 20, Rows: 5}) | |||
| 33 | + | p.mu.Lock() | |||
| 34 | + | p.screen.Write([]byte("\x1b]2;project/main.go\a")) | |||
| 35 | + | p.refreshTerminalTitleLocked() | |||
| 36 | + | first := p.title.generation | |||
| 37 | + | p.refreshTerminalTitleLocked() | |||
| 38 | + | second := p.title.generation | |||
| 39 | + | p.screen.Write([]byte("\x1b]0;other\a")) | |||
| 40 | + | p.refreshTerminalTitleLocked() | |||
| 41 | + | third := p.title.generation | |||
| 42 | + | p.mu.Unlock() | |||
| 43 | + | ||||
| 44 | + | require.Equal(t, uint64(1), first) | |||
| 45 | + | require.Equal(t, first, second) | |||
| 46 | + | require.Equal(t, first+1, third) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 14 | func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) { | 49 | func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) { | ||
| 15 | pty := portsmocks.NewMockPTY(t) | 50 | pty := portsmocks.NewMockPTY(t) | ||
| 16 | - | pty.EXPECT().ForegroundPgid().Return(1234, nil).Once() | 51 | + | pty.EXPECT().ForegroundPgid().Return(1234, nil).Twice() |
| 17 | _, sess, _, _ := newManualSessionWithPTYs(t, pty) | 52 | _, sess, _, _ := newManualSessionWithPTYs(t, pty) | ||
| 18 | clk := portsmocks.NewMockClock(t) | 53 | clk := portsmocks.NewMockClock(t) | ||
| 19 | clk.EXPECT().Now().Return(time.Time{}).Maybe() | 54 | clk.EXPECT().Now().Return(time.Time{}).Maybe() | ||
| @@ -25,9 +60,17 @@ func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) { | |||||
| 25 | } | 60 | } | ||
| 26 | 61 | ||||
| 27 | title := d.refreshPaneTitle(sess, "pane-1") | 62 | title := d.refreshPaneTitle(sess, "pane-1") | ||
| 28 | - | ||||
| 29 | require.Equal(t, "vim", title) | 63 | require.Equal(t, "vim", title) | ||
| 30 | - | require.Equal(t, "vim", sess.activeTab().focusedPane().title) | 64 | + | |
| 65 | + | p := sess.activeTab().focusedPane() | |||
| 66 | + | p.mu.Lock() | |||
| 67 | + | p.screen.Write([]byte("\x1b]2;project/main.go\a")) | |||
| 68 | + | p.refreshTerminalTitleLocked() | |||
| 69 | + | p.mu.Unlock() | |||
| 70 | + | title = d.refreshPaneDisplayTitle(sess, p, true) | |||
| 71 | + | ||||
| 72 | + | require.Equal(t, "vim: project/main.go", title) | |||
| 73 | + | require.Equal(t, "vim", p.title.processName) | |||
| 31 | } | 74 | } | ||
| 32 | 75 | ||||
| 33 | func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) { | 76 | func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) { | ||
| @@ -53,18 +96,78 @@ func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) { | |||||
| 53 | require.Equal(t, int32(2), calls.Load(), "focus refresh should bypass TTL") | 96 | require.Equal(t, int32(2), calls.Load(), "focus refresh should bypass TTL") | ||
| 54 | } | 97 | } | ||
| 55 | 98 | ||||
| 56 | - | func TestRefreshPaneTitleFallsBackToShellBase(t *testing.T) { | 99 | + | func TestRefreshFloatingPaneTitlePreservesConfiguredCommandFallback(t *testing.T) { |
| 100 | + | pty := portsmocks.NewMockPTY(t) | |||
| 101 | + | pty.EXPECT().ForegroundPgid().Return(0, errors.New("no foreground process")).Twice() | |||
| 102 | + | clk := portsmocks.NewMockClock(t) | |||
| 103 | + | clk.EXPECT().Now().Return(time.Time{}).Maybe() | |||
| 104 | + | d := newTestDaemon(t, nil, clk) | |||
| 105 | + | d.shell = "/usr/bin/fish" | |||
| 106 | + | d.procComm = func(int) (string, error) { return "", errors.New("unused") } | |||
| 107 | + | p := newPane("floating", pty, domain.Size{Cols: 20, Rows: 5}) | |||
| 108 | + | p.title.displayFallback = floatingCommandFallback("btop --utf", d.shell) | |||
| 109 | + | ||||
| 110 | + | require.Equal(t, "btop", d.refreshPaneDisplayTitle(nil, p, false)) | |||
| 111 | + | require.Equal(t, "btop", d.refreshPaneDisplayTitle(nil, p, false), "cached refresh must retain the pane-owned fallback") | |||
| 112 | + | require.Equal(t, "btop", d.refreshPaneDisplayTitle(nil, p, true), "process refresh must retain the pane-owned fallback") | |||
| 113 | + | p.mu.Lock() | |||
| 114 | + | require.Equal(t, "btop", p.title.displayFallback) | |||
| 115 | + | p.mu.Unlock() | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestRefreshFloatingPaneTitleUsesShellFallbackForEmptyCommand(t *testing.T) { | |||
| 119 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 120 | + | d.shell = "/usr/local/bin/zsh" | |||
| 121 | + | p := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}) | |||
| 122 | + | p.title.displayFallback = floatingCommandFallback("", d.shell) | |||
| 123 | + | ||||
| 124 | + | require.Equal(t, "zsh", d.refreshPaneDisplayTitle(nil, p, true)) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func TestRefreshPaneTitleUpdatesNormalPaneShellFallback(t *testing.T) { | |||
| 128 | + | d, sess, _, _ := newManualSessionWithPTYs(t, nil) | |||
| 129 | + | p := sess.activeTab().focusedPane() | |||
| 130 | + | ||||
| 131 | + | d.shell = "/usr/bin/fish" | |||
| 132 | + | require.Equal(t, "fish", d.refreshPaneTitle(sess, "pane-1")) | |||
| 133 | + | p.mu.Lock() | |||
| 134 | + | firstGeneration := p.title.generation | |||
| 135 | + | p.mu.Unlock() | |||
| 136 | + | require.Equal(t, "fish", d.refreshPaneTitle(sess, "pane-1")) | |||
| 137 | + | p.mu.Lock() | |||
| 138 | + | require.Equal(t, firstGeneration, p.title.generation, "unchanged displayed title must not damage the title row") | |||
| 139 | + | p.mu.Unlock() | |||
| 140 | + | ||||
| 141 | + | d.shell = "/bin/zsh" | |||
| 142 | + | require.Equal(t, "zsh", d.refreshPaneTitle(sess, "pane-1")) | |||
| 143 | + | p.mu.Lock() | |||
| 144 | + | require.Equal(t, firstGeneration+1, p.title.generation) | |||
| 145 | + | p.mu.Unlock() | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestRefreshPaneTitleLookupFailureKeepsProcessNameEmpty(t *testing.T) { | |||
| 57 | pty := portsmocks.NewMockPTY(t) | 149 | pty := portsmocks.NewMockPTY(t) | ||
| 58 | - | pty.EXPECT().ForegroundPgid().Return(0, errors.New("no foreground process")).Once() | 150 | + | pty.EXPECT().ForegroundPgid().Return(0, errors.New("no foreground process")).Twice() |
| 59 | _, sess, _, _ := newManualSessionWithPTYs(t, pty) | 151 | _, sess, _, _ := newManualSessionWithPTYs(t, pty) | ||
| 60 | clk := portsmocks.NewMockClock(t) | 152 | clk := portsmocks.NewMockClock(t) | ||
| 61 | clk.EXPECT().Now().Return(time.Time{}).Maybe() | 153 | clk.EXPECT().Now().Return(time.Time{}).Maybe() | ||
| 62 | d := newTestDaemon(t, nil, clk) | 154 | d := newTestDaemon(t, nil, clk) | ||
| 63 | d.shell = "/usr/bin/fish" | 155 | d.shell = "/usr/bin/fish" | ||
| 64 | d.procComm = func(int) (string, error) { return "", errors.New("unused") } | 156 | d.procComm = func(int) (string, error) { return "", errors.New("unused") } | ||
| 157 | + | p := sess.activeTab().focusedPane() | |||
| 65 | 158 | ||||
| 66 | - | title := d.refreshPaneTitle(sess, "pane-1") | 159 | + | p.mu.Lock() |
| 160 | + | p.screen.Write([]byte("\x1b]2;terminal\a")) | |||
| 161 | + | p.refreshTerminalTitleLocked() | |||
| 162 | + | p.mu.Unlock() | |||
| 163 | + | require.Equal(t, "terminal", d.refreshPaneDisplayTitle(sess, p, true)) | |||
| 164 | + | p.mu.Lock() | |||
| 165 | + | require.Empty(t, p.title.processName, "a lookup failure must not become process state") | |||
| 166 | + | p.mu.Unlock() | |||
| 67 | 167 | ||||
| 68 | - | require.Equal(t, "fish", title) | 168 | + | p.mu.Lock() |
| 69 | - | require.Equal(t, "fish", sess.activeTab().focusedPane().title) | 169 | + | p.screen.Write([]byte("\x1b]2;\a")) |
| 170 | + | p.refreshTerminalTitleLocked() | |||
| 171 | + | p.mu.Unlock() | |||
| 172 | + | require.Equal(t, "sh", d.refreshPaneDisplayTitle(sess, p, true), "pointer refresh must retain its pane-owned fallback") | |||
| 70 | } | 173 | } | ||
M internal/usecase/daemon/picker.go +3 -1
| @@ -360,7 +360,9 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker | |||||
| 360 | return | 360 | return | ||
| 361 | } | 361 | } | ||
| 362 | if targetSess == sess { | 362 | if targetSess == sess { | ||
| 363 | - | sess.switchTab(target.TabIndex) | 363 | + | if sess.switchTab(target.TabIndex) { |
| 364 | + | d.activateTab(sess, sess.activeTab()) | |||
| 365 | + | } | |||
| 364 | d.paint(sess, ac, true) | 366 | d.paint(sess, ac, true) | ||
| 365 | return | 367 | return | ||
| 366 | } | 368 | } | ||
M internal/usecase/daemon/picker_test.go +29 -7
| @@ -90,6 +90,10 @@ func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) { | |||||
| 90 | 90 | ||||
| 91 | func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | 91 | func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | ||
| 92 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | 92 | d, sess, ac, sends, releases := newManualTabSession(t, 2) | ||
| 93 | + | sess.mu.Lock() | |||
| 94 | + | sess.client = ac | |||
| 95 | + | sess.mu.Unlock() | |||
| 96 | + | d.ptys = newBlockingOpenFactory(t, d) | |||
| 93 | defer func() { | 97 | defer func() { | ||
| 94 | for _, release := range releases { | 98 | for _, release := range releases { | ||
| 95 | release() | 99 | release() | ||
| @@ -103,6 +107,7 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | |||||
| 103 | d.handleInput(sess, ac, []byte("\r")) | 107 | d.handleInput(sess, ac, []byte("\r")) | ||
| 104 | 108 | ||||
| 105 | require.Equal(t, 1, activeTabIndex(sess)) | 109 | require.Equal(t, 1, activeTabIndex(sess)) | ||
| 110 | + | requireFloatingInitialized(t, sess.activeTab()) | |||
| 106 | awaitFrame(t, sends, ports.MsgOutput) | 111 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 107 | d.enterPicker(sess, ac) | 112 | d.enterPicker(sess, ac) | ||
| 108 | awaitFrame(t, sends, ports.MsgOutput) | 113 | awaitFrame(t, sends, ports.MsgOutput) | ||
| @@ -315,11 +320,14 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |||||
| 315 | var openedEnv []string | 320 | var openedEnv []string | ||
| 316 | f := portsmocks.NewMockPTYFactory(t) | 321 | f := portsmocks.NewMockPTYFactory(t) | ||
| 317 | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | 322 | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | ||
| 318 | - | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | 323 | + | func(_ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) { |
| 324 | + | if sz != (domain.Size{Cols: 80, Rows: 22}) { | |||
| 325 | + | return newQuietPTY(), nil | |||
| 326 | + | } | |||
| 319 | openedEnv = append([]string(nil), env...) | 327 | openedEnv = append([]string(nil), env...) | ||
| 320 | return p3, nil | 328 | return p3, nil | ||
| 321 | }, | 329 | }, | ||
| 322 | - | ).Once() | 330 | + | ).Maybe() |
| 323 | d := newTestDaemon(t, f, stubClock{}) | 331 | d := newTestDaemon(t, f, stubClock{}) | ||
| 324 | tr1, _ := newCapturingTransport(t) | 332 | tr1, _ := newCapturingTransport(t) | ||
| 325 | tr2, _ := newCapturingTransport(t) | 333 | tr2, _ := newCapturingTransport(t) | ||
| @@ -362,12 +370,12 @@ func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) { | |||||
| 362 | func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) { | 370 | func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) { | ||
| 363 | tb := newTab(nil, domain.Size{Cols: 41, Rows: 5}) | 371 | tb := newTab(nil, domain.Size{Cols: 41, Rows: 5}) | ||
| 364 | left := tb.focusedPane() | 372 | left := tb.focusedPane() | ||
| 365 | - | left.title = "one" | 373 | + | left.title.processName = "one" |
| 366 | left.screen.Write([]byte("L")) | 374 | left.screen.Write([]byte("L")) | ||
| 367 | rightTop := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3}) | 375 | rightTop := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3}) | ||
| 368 | - | rightTop.title = "two" | 376 | + | rightTop.title.processName = "two" |
| 369 | rightBottom := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 2}) | 377 | rightBottom := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 2}) | ||
| 370 | - | rightBottom.title = "three" | 378 | + | rightBottom.title.processName = "three" |
| 371 | rightBottom.screen.Write([]byte("R")) | 379 | rightBottom.screen.Write([]byte("R")) | ||
| 372 | 380 | ||||
| 373 | tb.mu.Lock() | 381 | tb.mu.Lock() | ||
| @@ -532,7 +540,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) { | |||||
| 532 | defer release2() | 540 | defer release2() | ||
| 533 | var opens [][]string | 541 | var opens [][]string | ||
| 534 | f := portsmocks.NewMockPTYFactory(t) | 542 | f := portsmocks.NewMockPTYFactory(t) | ||
| 535 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | 543 | + | normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2} |
| 544 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | |||
| 536 | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | 545 | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | ||
| 537 | opens = append(opens, append([]string(nil), env...)) | 546 | opens = append(opens, append([]string(nil), env...)) | ||
| 538 | if len(opens) == 1 { | 547 | if len(opens) == 1 { | ||
| @@ -541,6 +550,10 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) { | |||||
| 541 | return p2, nil | 550 | return p2, nil | ||
| 542 | }, | 551 | }, | ||
| 543 | ).Twice() | 552 | ).Twice() | ||
| 553 | + | floating := newQuietPTY() | |||
| 554 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool { | |||
| 555 | + | return got != normalSize && got.Valid() | |||
| 556 | + | })).Return(floating, nil).Once() | |||
| 544 | d := newTestDaemon(t, f, stubClock{}) | 557 | d := newTestDaemon(t, f, stubClock{}) | ||
| 545 | d.stopped["old"] = stoppedSession{name: "old", cwd: t.TempDir(), createdAt: 1} | 558 | d.stopped["old"] = stoppedSession{name: "old", cwd: t.TempDir(), createdAt: 1} | ||
| 546 | tr := portsmocks.NewMockTransport(t) | 559 | tr := portsmocks.NewMockTransport(t) | ||
| @@ -552,10 +565,19 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) { | |||||
| 552 | got := ac.sess.Get() | 565 | got := ac.sess.Get() | ||
| 553 | require.NotNil(t, got) | 566 | require.NotNil(t, got) | ||
| 554 | got.mu.Lock() | 567 | got.mu.Lock() | ||
| 555 | - | defer got.mu.Unlock() | |||
| 556 | require.True(t, got.terminal.TrueColor) | 568 | require.True(t, got.terminal.TrueColor) | ||
| 569 | + | got.mu.Unlock() | |||
| 557 | require.Len(t, opens, 2) | 570 | require.Len(t, opens, 2) | ||
| 558 | require.Contains(t, opens[1], "TERM=xterm-direct") | 571 | require.Contains(t, opens[1], "TERM=xterm-direct") | ||
| 559 | require.Contains(t, opens[1], "COLORTERM=truecolor") | 572 | require.Contains(t, opens[1], "COLORTERM=truecolor") | ||
| 560 | require.Contains(t, opens[1], "TERM_PROGRAM=vev") | 573 | require.Contains(t, opens[1], "TERM_PROGRAM=vev") | ||
| 574 | + | _ = d.killSession(got, ports.ReasonSessionKilled, false) | |||
| 575 | + | release1() | |||
| 576 | + | release2() | |||
| 577 | + | d.sessWg.Wait() | |||
| 578 | + | select { | |||
| 579 | + | case <-floating.done: | |||
| 580 | + | default: | |||
| 581 | + | t.Fatal("floating prewarm PTY was not closed") | |||
| 582 | + | } | |||
| 561 | } | 583 | } | ||
M internal/usecase/daemon/render.go +122 -26
| @@ -69,6 +69,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||||
| 69 | p.mu.Lock() | 69 | p.mu.Lock() | ||
| 70 | wasSyncing := p.screen.SyncUpdateActive() | 70 | wasSyncing := p.screen.SyncUpdateActive() | ||
| 71 | p.screen.Write(data) | 71 | p.screen.Write(data) | ||
| 72 | + | p.refreshTerminalTitleLocked() | |||
| 72 | isSyncing := p.screen.SyncUpdateActive() | 73 | isSyncing := p.screen.SyncUpdateActive() | ||
| 73 | p.mu.Unlock() | 74 | p.mu.Unlock() | ||
| 74 | markSnapshotDirty(sess) | 75 | markSnapshotDirty(sess) | ||
| @@ -108,7 +109,11 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||||
| 108 | signal(p.dirty) | 109 | signal(p.dirty) | ||
| 109 | } | 110 | } | ||
| 110 | if err != nil { | 111 | if err != nil { | ||
| 111 | - | d.reapPane(sess, tb, p) | 112 | + | if p.onExit != nil { |
| 113 | + | p.onExit() | |||
| 114 | + | } else { | |||
| 115 | + | d.reapPane(sess, tb, p) | |||
| 116 | + | } | |||
| 112 | return | 117 | return | ||
| 113 | } | 118 | } | ||
| 114 | } | 119 | } | ||
| @@ -214,9 +219,6 @@ func syncUpdateEndIn(data []byte) bool { | |||||
| 214 | // render paints the current client, or (when detached) just clears accumulated | 219 | // render paints the current client, or (when detached) just clears accumulated | ||
| 215 | // damage so it never grows unbounded while headless. | 220 | // damage so it never grows unbounded while headless. | ||
| 216 | func (d *Daemon) render(sess *session, tb *tab, p *pane) { | 221 | func (d *Daemon) render(sess *session, tb *tab, p *pane) { | ||
| 217 | - | tb.mu.Lock() | |||
| 218 | - | previewer := tb.previewClient | |||
| 219 | - | tb.mu.Unlock() | |||
| 220 | if p == nil { | 222 | if p == nil { | ||
| 221 | return | 223 | return | ||
| 222 | } | 224 | } | ||
| @@ -227,6 +229,20 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) { | |||||
| 227 | } | 229 | } | ||
| 228 | p.mu.Unlock() | 230 | p.mu.Unlock() | ||
| 229 | 231 | ||||
| 232 | + | // A retained floating pane continues to drain its PTY while hidden. Its | |||
| 233 | + | // scheduler must not route through picker preview (or the active client), | |||
| 234 | + | // but it must consume damage so a long-hidden command cannot retain it. | |||
| 235 | + | tb.mu.Lock() | |||
| 236 | + | hiddenFloating := tb.floating.pane == p && tb.floating.state != floatingVisible | |||
| 237 | + | previewer := tb.previewClient | |||
| 238 | + | tb.mu.Unlock() | |||
| 239 | + | if hiddenFloating { | |||
| 240 | + | p.mu.Lock() | |||
| 241 | + | p.screen.ClearDamage() | |||
| 242 | + | p.mu.Unlock() | |||
| 243 | + | return | |||
| 244 | + | } | |||
| 245 | + | ||||
| 230 | sess.mu.Lock() | 246 | sess.mu.Lock() | ||
| 231 | ac := sess.client | 247 | ac := sess.client | ||
| 232 | active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb | 248 | active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb | ||
| @@ -259,6 +275,34 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) { | |||||
| 259 | // paint draws the composed client frame (active tab plus status bar) and | 275 | // paint draws the composed client frame (active tab plus status bar) and | ||
| 260 | // sends the resulting bytes. The renderer shadow is reset on explicit invalidations | 276 | // sends the resulting bytes. The renderer shadow is reset on explicit invalidations | ||
| 261 | // such as switch/create/close/rename/resize so the repaint is complete. | 277 | // such as switch/create/close/rename/resize so the repaint is complete. | ||
| 278 | + | // copyTargetRectLocked maps a captured copy source into the already-composed | |||
| 279 | + | // client frame. The caller holds tb.mu, preserving the layout/floating | |||
| 280 | + | // snapshot while the rectangle is chosen. | |||
| 281 | + | func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, cfg domain.FloatingConfig) domain.Rect { | |||
| 282 | + | if p == nil { | |||
| 283 | + | return domain.Rect{} | |||
| 284 | + | } | |||
| 285 | + | if hasFloating && p == floating { | |||
| 286 | + | desired := calculateFloatingGeometry(contentArea, cfg) | |||
| 287 | + | p.mu.Lock() | |||
| 288 | + | geometry := p.committedFloatingGeometryLocked(desired) | |||
| 289 | + | p.mu.Unlock() | |||
| 290 | + | return geometry.Inner | |||
| 291 | + | } | |||
| 292 | + | for _, placement := range layoutSnap.placements { | |||
| 293 | + | if placement.ID == p.id && !placement.Collapsed { | |||
| 294 | + | r := placement.Content | |||
| 295 | + | r.X += contentArea.X | |||
| 296 | + | r.Y += contentArea.Y | |||
| 297 | + | return r | |||
| 298 | + | } | |||
| 299 | + | } | |||
| 300 | + | p.mu.Lock() | |||
| 301 | + | width, height := p.screen.Frame.Width, p.screen.Frame.Height | |||
| 302 | + | p.mu.Unlock() | |||
| 303 | + | return domain.Rect{X: contentArea.X, Y: contentArea.Y, Width: min(width, contentArea.Width), Height: min(height, contentArea.Height)} | |||
| 304 | + | } | |||
| 305 | + | ||||
| 262 | func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID { | 306 | func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID { | ||
| 263 | if !ok { | 307 | if !ok { | ||
| 264 | return nil | 308 | return nil | ||
| @@ -313,10 +357,15 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 313 | tb.mu.Lock() | 357 | tb.mu.Lock() | ||
| 314 | layoutSnap := solveTabLayoutLocked(tb) | 358 | layoutSnap := solveTabLayoutLocked(tb) | ||
| 315 | titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok) | 359 | titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok) | ||
| 360 | + | floating := tb.floating.pane | |||
| 361 | + | hasFloating := tb.floating.state == floatingVisible && floating != nil | |||
| 316 | tb.mu.Unlock() | 362 | tb.mu.Unlock() | ||
| 317 | for _, id := range titleIDs { | 363 | for _, id := range titleIDs { | ||
| 318 | d.refreshPaneTitle(sess, id) | 364 | d.refreshPaneTitle(sess, id) | ||
| 319 | } | 365 | } | ||
| 366 | + | if hasFloating { | |||
| 367 | + | d.refreshPaneDisplayTitle(sess, floating, false) | |||
| 368 | + | } | |||
| 320 | 369 | ||||
| 321 | tb.mu.Lock() | 370 | tb.mu.Lock() | ||
| 322 | if !layoutSnap.matchesLocked(tb) { | 371 | if !layoutSnap.matchesLocked(tb) { | ||
| @@ -328,6 +377,12 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 328 | ac.sendMu.Unlock() | 377 | ac.sendMu.Unlock() | ||
| 329 | return | 378 | return | ||
| 330 | } | 379 | } | ||
| 380 | + | // Recheck the slot after refreshing its title outside the tab lock. One | |||
| 381 | + | // paint uses one immutable floating config snapshot across composition, | |||
| 382 | + | // copy targeting, and cursor placement. | |||
| 383 | + | floating = tb.floating.pane | |||
| 384 | + | hasFloating = tb.floating.state == floatingVisible && floating != nil | |||
| 385 | + | floatingCfg := d.currentFloatingConfig() | |||
| 331 | overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive | 386 | overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive | ||
| 332 | if reset || overlayActive { | 387 | if reset || overlayActive { | ||
| 333 | ac.bars.Reset() | 388 | ac.bars.Reset() | ||
| @@ -343,11 +398,22 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 343 | } else { | 398 | } else { | ||
| 344 | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed) | 399 | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed) | ||
| 345 | } | 400 | } | ||
| 401 | + | contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)} | |||
| 402 | + | if hasFloating { | |||
| 403 | + | frame, damage = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, floatingCfg, layoutSnap, bars.theme, &ac.composed, reset || overlayActive) | |||
| 404 | + | } | |||
| 346 | if overlays.copyActive { | 405 | if overlays.copyActive { | ||
| 347 | - | frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars) | 406 | + | copyPane := overlays.copyPane |
| 407 | + | if copyPane == nil { | |||
| 408 | + | copyPane = p | |||
| 409 | + | } | |||
| 410 | + | copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingCfg) | |||
| 411 | + | frame, damage = composeCopyClientFrame(overlays.copyMode, copyPane, copyTarget, frame, bars) | |||
| 348 | } | 412 | } | ||
| 349 | - | if overlays.paletteActive { | 413 | + | // A palette above normal/copy content dims that composed content. When a |
| 350 | - | contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)} | 414 | + | // floating pane is present its own backdrop already dims normal pane |
| 415 | + | // contents; applying the palette backdrop here would also dim the popup. | |||
| 416 | + | if overlays.paletteActive && !hasFloating { | |||
| 351 | (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme) | 417 | (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme) | ||
| 352 | } | 418 | } | ||
| 353 | if overlays.copySearchModel != nil { | 419 | if overlays.copySearchModel != nil { | ||
| @@ -371,10 +437,19 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 371 | frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles) | 437 | frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles) | ||
| 372 | } | 438 | } | ||
| 373 | overlays.Unlock() | 439 | overlays.Unlock() | ||
| 440 | + | cursorPane := p | |||
| 374 | cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id) | 441 | cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id) | ||
| 375 | - | p.mu.Lock() | 442 | + | if hasFloating { |
| 376 | - | desiredCursor := desiredCursorOut(p.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive) | 443 | + | cursorPane = floating |
| 377 | - | p.mu.Unlock() | 444 | + | } |
| 445 | + | cursorPane.mu.Lock() | |||
| 446 | + | if hasFloating { | |||
| 447 | + | desiredGeometry := calculateFloatingGeometry(contentArea, floatingCfg) | |||
| 448 | + | geometry := floating.committedFloatingGeometryLocked(desiredGeometry) | |||
| 449 | + | cursorContent, cursorVisible = geometry.Inner, geometry.Inner.Width > 0 && geometry.Inner.Height > 0 | |||
| 450 | + | } | |||
| 451 | + | desiredCursor := desiredCursorOut(cursorPane.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive) | |||
| 452 | + | cursorPane.mu.Unlock() | |||
| 378 | ac.sess.mu.Lock() | 453 | ac.sess.mu.Lock() | ||
| 379 | if ac.sess.v != sess { | 454 | if ac.sess.v != sess { | ||
| 380 | ac.sess.mu.Unlock() | 455 | ac.sess.mu.Unlock() | ||
| @@ -511,13 +586,23 @@ func (c *barCache) Reset() { | |||||
| 511 | } | 586 | } | ||
| 512 | 587 | ||||
| 513 | type composedFrameCache struct { | 588 | type composedFrameCache struct { | ||
| 514 | - | frame renderer.Frame | 589 | + | frame renderer.Frame |
| 515 | - | valid bool | 590 | + | valid bool |
| 516 | - | layoutSnap tabLayoutSnapshot | 591 | + | layoutSnap tabLayoutSnapshot |
| 592 | + | titleGenerations map[layout.PaneID]uint64 | |||
| 593 | + | floating *pane | |||
| 594 | + | floatingFrame renderer.Frame | |||
| 595 | + | floatingGeneration uint64 | |||
| 596 | + | floatingGeometry floatingGeometry | |||
| 597 | + | floatingTitleGeneration uint64 | |||
| 517 | } | 598 | } | ||
| 518 | 599 | ||||
| 519 | func (c *composedFrameCache) invalidate() { | 600 | func (c *composedFrameCache) invalidate() { | ||
| 520 | c.valid = false | 601 | c.valid = false | ||
| 602 | + | c.floating = nil | |||
| 603 | + | c.floatingGeneration = 0 | |||
| 604 | + | c.floatingGeometry = floatingGeometry{} | |||
| 605 | + | c.floatingTitleGeneration = 0 | |||
| 521 | } | 606 | } | ||
| 522 | 607 | ||||
| 523 | func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) { | 608 | func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) { | ||
| @@ -580,7 +665,14 @@ func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool | |||||
| 580 | } | 665 | } | ||
| 581 | topBar := frame.Row(0) | 666 | topBar := frame.Row(0) | ||
| 582 | drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles) | 667 | drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles) | ||
| 583 | - | contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage) | 668 | + | var titleGenerations map[layout.PaneID]uint64 |
| 669 | + | if composed != nil { | |||
| 670 | + | if composed.titleGenerations == nil { | |||
| 671 | + | composed.titleGenerations = make(map[layout.PaneID]uint64) | |||
| 672 | + | } | |||
| 673 | + | titleGenerations = composed.titleGenerations | |||
| 674 | + | } | |||
| 675 | + | contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage, titleGenerations) | |||
| 584 | bottomBar := frame.Row(screenRows + 1) | 676 | bottomBar := frame.Row(screenRows + 1) | ||
| 585 | drawStatusBarState(bottomBar, bars, styles) | 677 | drawStatusBarState(bottomBar, bars, styles) | ||
| 586 | if composed != nil { | 678 | if composed != nil { | ||
| @@ -618,10 +710,10 @@ func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, l | |||||
| 618 | } | 710 | } | ||
| 619 | 711 | ||||
| 620 | func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage { | 712 | func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage { | ||
| 621 | - | return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false) | 713 | + | return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false, nil) |
| 622 | } | 714 | } | ||
| 623 | 715 | ||||
| 624 | - | func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool) []renderer.Damage { | 716 | + | func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool, titleGenerations map[layout.PaneID]uint64) []renderer.Damage { |
| 625 | contentArea := domain.Rect{Width: area.Width, Height: area.Height} | 717 | contentArea := domain.Rect{Width: area.Width, Height: area.Height} | ||
| 626 | root := tb.tree.Root | 718 | root := tb.tree.Root | ||
| 627 | placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea | 719 | placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea | ||
| @@ -636,6 +728,11 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do | |||||
| 636 | } | 728 | } | ||
| 637 | placements = []layout.Placement{{ID: fallback.id, Content: contentArea}} | 729 | placements = []layout.Placement{{ID: fallback.id, Content: contentArea}} | ||
| 638 | } | 730 | } | ||
| 731 | + | // A valid cache has the same layout, so its title IDs remain valid. Reset | |||
| 732 | + | // the existing cache only when rebuilding after layout or frame churn. | |||
| 733 | + | if titleGenerations != nil && !cacheValid { | |||
| 734 | + | clear(titleGenerations) | |||
| 735 | + | } | |||
| 639 | if ok && !cacheValid { | 736 | if ok && !cacheValid { | ||
| 640 | drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme)) | 737 | drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme)) | ||
| 641 | } | 738 | } | ||
| @@ -651,13 +748,16 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do | |||||
| 651 | focused := tb.tree.Focus == pl.ID | 748 | focused := tb.tree.Focus == pl.ID | ||
| 652 | pl = offsetPlacement(pl, area.X, area.Y) | 749 | pl = offsetPlacement(pl, area.X, area.Y) | ||
| 653 | if pl.TitleBar.Height > 0 { | 750 | if pl.TitleBar.Height > 0 { | ||
| 654 | - | drawPaneTitleBar(frame, pl, p, focused, theme, "") | 751 | + | generation := drawPaneTitleBar(frame, pl, p, focused, theme) |
| 655 | - | if cacheValid { | 752 | + | if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) { |
| 656 | titleDamage := pl.TitleBar | 753 | titleDamage := pl.TitleBar | ||
| 657 | titleDamage.X -= area.X | 754 | titleDamage.X -= area.X | ||
| 658 | titleDamage.Y -= area.Y | 755 | titleDamage.Y -= area.Y | ||
| 659 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height}) | 756 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height}) | ||
| 660 | } | 757 | } | ||
| 758 | + | if titleGenerations != nil { | |||
| 759 | + | titleGenerations[pl.ID] = generation | |||
| 760 | + | } | |||
| 661 | } | 761 | } | ||
| 662 | if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 { | 762 | if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 { | ||
| 663 | if consumeDamage { | 763 | if consumeDamage { | ||
| @@ -721,7 +821,7 @@ func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bo | |||||
| 721 | } | 821 | } | ||
| 722 | } | 822 | } | ||
| 723 | 823 | ||||
| 724 | - | func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme, fallback string) { | 824 | + | func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 { |
| 725 | styles := newThemeStyles(theme) | 825 | styles := newThemeStyles(theme) | ||
| 726 | style := styles.border | 826 | style := styles.border | ||
| 727 | if focused { | 827 | if focused { | ||
| @@ -733,15 +833,11 @@ func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focuse | |||||
| 733 | frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style}) | 833 | frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style}) | ||
| 734 | } | 834 | } | ||
| 735 | p.mu.Lock() | 835 | p.mu.Lock() | ||
| 736 | - | title := p.title | 836 | + | title := p.displayTitleLocked() |
| 837 | + | generation := p.title.generation | |||
| 737 | p.mu.Unlock() | 838 | p.mu.Unlock() | ||
| 738 | - | if title == "" { | |||
| 739 | - | title = fallback | |||
| 740 | - | } | |||
| 741 | - | if title == "" { | |||
| 742 | - | title = string(p.id) | |||
| 743 | - | } | |||
| 744 | ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style) | 839 | ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style) | ||
| 840 | + | return generation | |||
| 745 | } | 841 | } | ||
| 746 | 842 | ||||
| 747 | func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) { | 843 | func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) { | ||
M internal/usecase/daemon/render_test.go +113 -5
| @@ -631,6 +631,23 @@ func TestComposeClientFrameCacheLayoutChangeClearsStaleDividers(t *testing.T) { | |||||
| 631 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | 631 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | ||
| 632 | } | 632 | } | ||
| 633 | 633 | ||||
| 634 | + | func TestComposeTabFrameCachedTitleBarsDoNotAllocate(t *testing.T) { | |||
| 635 | + | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | |||
| 636 | + | p := win.focusedPane() | |||
| 637 | + | p.title.processName = "shell" | |||
| 638 | + | win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p.id)}, Expanded: p.id} | |||
| 639 | + | layoutSnap := solveTabLayoutLocked(win) | |||
| 640 | + | frame := renderer.NewFrame(20, 5) | |||
| 641 | + | titleGenerations := map[layout.PaneID]uint64{p.id: p.title.generation} | |||
| 642 | + | p.screen.ClearDamage() | |||
| 643 | + | ||||
| 644 | + | composeTabFrameIntoWithLayoutOptions(win, frame, domain.Rect{Width: 20, Height: 5}, themeui.Theme{}, layoutSnap, true, false, titleGenerations) | |||
| 645 | + | allocs := testing.AllocsPerRun(100, func() { | |||
| 646 | + | composeTabFrameIntoWithLayoutOptions(win, frame, domain.Rect{Width: 20, Height: 5}, themeui.Theme{}, layoutSnap, true, false, titleGenerations) | |||
| 647 | + | }) | |||
| 648 | + | require.Zero(t, allocs, "valid cached title-bar renders must not allocate") | |||
| 649 | + | } | |||
| 650 | + | ||||
| 634 | func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) { | 651 | func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) { | ||
| 635 | win := newTab(nil, domain.Size{Cols: 81, Rows: 24}) | 652 | win := newTab(nil, domain.Size{Cols: 81, Rows: 24}) | ||
| 636 | left := win.focusedPane() | 653 | left := win.focusedPane() | ||
| @@ -661,13 +678,24 @@ func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) { | |||||
| 661 | } | 678 | } | ||
| 662 | } | 679 | } | ||
| 663 | 680 | ||||
| 681 | + | func TestComposeTabFrameStackUsesShellFallback(t *testing.T) { | |||
| 682 | + | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | |||
| 683 | + | p := win.focusedPane() | |||
| 684 | + | p.title.displayFallback = "fish" | |||
| 685 | + | win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p.id)}, Expanded: p.id} | |||
| 686 | + | ||||
| 687 | + | frame, _ := composeTabFrame(win, domain.Rect{Width: 20, Height: 5}, themeui.Theme{}) | |||
| 688 | + | ||||
| 689 | + | require.Equal(t, "fish", rowText(frame.Row(0))[:4]) | |||
| 690 | + | } | |||
| 691 | + | ||||
| 664 | func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | 692 | func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | ||
| 665 | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | 693 | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | ||
| 666 | p1 := win.focusedPane() | 694 | p1 := win.focusedPane() | ||
| 667 | - | p1.title = "one" | 695 | + | p1.title.processName = "one" |
| 668 | p1.screen.ClearDamage() | 696 | p1.screen.ClearDamage() | ||
| 669 | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3}) | 697 | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3}) | ||
| 670 | - | p2.title = "two" | 698 | + | p2.title.processName = "two" |
| 671 | p2.screen.Write([]byte("T")) | 699 | p2.screen.Write([]byte("T")) | ||
| 672 | p2.screen.ClearDamage() | 700 | p2.screen.ClearDamage() | ||
| 673 | 701 | ||||
| @@ -688,9 +716,9 @@ func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | |||||
| 688 | func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) { | 716 | func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) { | ||
| 689 | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | 717 | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | ||
| 690 | p1 := win.focusedPane() | 718 | p1 := win.focusedPane() | ||
| 691 | - | p1.title = "one" | 719 | + | p1.title.processName = "one" |
| 692 | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3}) | 720 | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3}) | ||
| 693 | - | p2.title = "two" | 721 | + | p2.title.processName = "two" |
| 694 | p2.screen.Write([]byte("T")) | 722 | p2.screen.Write([]byte("T")) | ||
| 695 | win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id} | 723 | win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id} | ||
| 696 | win.tree.Focus = p2.id | 724 | win.tree.Focus = p2.id | ||
| @@ -702,12 +730,16 @@ func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) { | |||||
| 702 | p1.screen.ClearDamage() | 730 | p1.screen.ClearDamage() | ||
| 703 | p2.screen.ClearDamage() | 731 | p2.screen.ClearDamage() | ||
| 704 | 732 | ||||
| 705 | - | p1.title = "renamed" | 733 | + | p1.title.processName = "renamed" |
| 734 | + | p1.title.generation++ | |||
| 706 | frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | 735 | frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | ||
| 707 | 736 | ||||
| 708 | require.Equal(t, "renamed", rowText(frame.Row(1))[:7]) | 737 | require.Equal(t, "renamed", rowText(frame.Row(1))[:7]) | ||
| 709 | require.NotEqual(t, []renderer.Damage{renderer.FullRedraw()}, damage, "title-only changes should not force a full layout reset") | 738 | require.NotEqual(t, []renderer.Damage{renderer.FullRedraw()}, damage, "title-only changes should not force a full layout reset") | ||
| 710 | require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1}) | 739 | require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1}) | ||
| 740 | + | ||||
| 741 | + | _, damage = composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | |||
| 742 | + | require.NotContains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1}, "unchanged title must not redraw its row") | |||
| 711 | } | 743 | } | ||
| 712 | 744 | ||||
| 713 | func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) { | 745 | func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) { | ||
| @@ -1391,3 +1423,79 @@ func TestHistoryNavBottomBar(t *testing.T) { | |||||
| 1391 | require.True(t, row[docsCol].Style.Equal(styles.accent)) | 1423 | require.True(t, row[docsCol].Style.Equal(styles.accent)) | ||
| 1392 | }) | 1424 | }) | ||
| 1393 | } | 1425 | } | ||
| 1426 | + | ||||
| 1427 | + | func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) { | |||
| 1428 | + | base := renderer.NewFrame(20, 8) | |||
| 1429 | + | for y := range base.Height { | |||
| 1430 | + | for x := range base.Width { | |||
| 1431 | + | base.Set(x, y, renderer.Cell{Rune: '#', Style: renderer.DefaultStyle()}) | |||
| 1432 | + | } | |||
| 1433 | + | } | |||
| 1434 | + | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | |||
| 1435 | + | p.screen.Write([]byte("ab\r\ncd")) | |||
| 1436 | + | mode := scopy.NewMode(scopy.NewSnapshot(p.scrollback, p.screen.Frame)) | |||
| 1437 | + | target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2} | |||
| 1438 | + | ||||
| 1439 | + | frame, damage := composeCopyClientFrame(mode, p, target, base, barState{}) | |||
| 1440 | + | ||||
| 1441 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |||
| 1442 | + | require.Equal(t, "##ab ", rowText(frame.Row(3))) | |||
| 1443 | + | require.Equal(t, "##cd ", rowText(frame.Row(4))) | |||
| 1444 | + | require.Equal(t, strings.Repeat("#", 20), rowText(frame.Row(2))) | |||
| 1445 | + | require.Equal(t, strings.Repeat("#", 20), rowText(frame.Row(5))) | |||
| 1446 | + | require.Equal(t, strings.Repeat("#", 20), rowText(frame.Row(6))) | |||
| 1447 | + | status := rowText(frame.Row(7)) | |||
| 1448 | + | require.Contains(t, status, "[SCROLL]") | |||
| 1449 | + | require.NotContains(t, status, "#") | |||
| 1450 | + | require.Equal(t, renderer.BlankCell(), frame.At(19, 7), "status filler must be blank cells, not zero cells") | |||
| 1451 | + | } | |||
| 1452 | + | ||||
| 1453 | + | func TestCopyTargetRectLocked(t *testing.T) { | |||
| 1454 | + | tb := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | |||
| 1455 | + | cfg := domain.Defaults().Floating | |||
| 1456 | + | contentArea := domain.Rect{Y: 1, Width: 80, Height: 23} | |||
| 1457 | + | fp := newPane("floating", nil, domain.Size{Cols: 10, Rows: 4}) | |||
| 1458 | + | stray := newPane("stray", nil, domain.Size{Cols: 10, Rows: 4}) | |||
| 1459 | + | tb.mu.Lock() | |||
| 1460 | + | defer tb.mu.Unlock() | |||
| 1461 | + | layoutSnap := solveTabLayoutLocked(tb) | |||
| 1462 | + | main := tb.focusedPane() | |||
| 1463 | + | ||||
| 1464 | + | cases := []struct { | |||
| 1465 | + | name string | |||
| 1466 | + | pane *pane | |||
| 1467 | + | floating *pane | |||
| 1468 | + | hasFloating bool | |||
| 1469 | + | want domain.Rect | |||
| 1470 | + | }{ | |||
| 1471 | + | {name: "floating source targets popup inner", pane: fp, floating: fp, hasFloating: true, want: calculateFloatingGeometry(contentArea, cfg).Inner}, | |||
| 1472 | + | {name: "normal source targets solved placement", pane: main, want: domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}}, | |||
| 1473 | + | {name: "unplaced source falls back to pane frame", pane: stray, want: domain.Rect{X: 0, Y: 1, Width: 10, Height: 4}}, | |||
| 1474 | + | } | |||
| 1475 | + | for _, tc := range cases { | |||
| 1476 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 1477 | + | require.Equal(t, tc.want, copyTargetRectLocked(layoutSnap, contentArea, tc.pane, tc.floating, tc.hasFloating, cfg)) | |||
| 1478 | + | }) | |||
| 1479 | + | } | |||
| 1480 | + | } | |||
| 1481 | + | ||||
| 1482 | + | func TestPaintComposesCopyBodyAboveFloating(t *testing.T) { | |||
| 1483 | + | normal, releaseNormal := newBlockingPTY(t) | |||
| 1484 | + | floatingPTY, releaseFloating := newBlockingPTY(t) | |||
| 1485 | + | defer releaseNormal() | |||
| 1486 | + | defer releaseFloating() | |||
| 1487 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, normal) | |||
| 1488 | + | fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3}) | |||
| 1489 | + | fp.scrollback.Append(testRow("flt-old")) | |||
| 1490 | + | fp.screen.Write([]byte("flt-live")) | |||
| 1491 | + | installTestFloating(sess.activeTab(), fp, true) | |||
| 1492 | + | ||||
| 1493 | + | d.enterCopyMode(sess, ac) | |||
| 1494 | + | data := mustOutputData(t, sends) | |||
| 1495 | + | require.Contains(t, string(data), "[SCROLL]") | |||
| 1496 | + | require.Contains(t, string(data), "\x1b[?25l") | |||
| 1497 | + | ||||
| 1498 | + | d.handleInput(sess, ac, []byte("g")) | |||
| 1499 | + | data = mustOutputData(t, sends) | |||
| 1500 | + | require.Contains(t, string(data), "lt-old", "copy viewport of the captured floating pane must compose above the popup") | |||
| 1501 | + | } | |||
M internal/usecase/daemon/session.go +22 -2
| @@ -55,6 +55,7 @@ type session struct { | |||||
| 55 | cancel context.CancelFunc | 55 | cancel context.CancelFunc | ||
| 56 | 56 | ||||
| 57 | mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state | 57 | mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state | ||
| 58 | + | themeMu sync.Mutex | |||
| 58 | tabs []*tab | 59 | tabs []*tab | ||
| 59 | active int | 60 | active int | ||
| 60 | client *attachedClient | 61 | client *attachedClient | ||
| @@ -74,7 +75,7 @@ type session struct { | |||||
| 74 | 75 | ||||
| 75 | // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state. | 76 | // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state. | ||
| 76 | type tab struct { | 77 | type tab struct { | ||
| 77 | - | mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership | 78 | + | mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, previewClient, and pane map membership |
| 78 | 79 | ||||
| 79 | stableID string | 80 | stableID string | ||
| 80 | name string | 81 | name string | ||
| @@ -84,6 +85,8 @@ type tab struct { | |||||
| 84 | size domain.Size | 85 | size domain.Size | ||
| 85 | ctx context.Context | 86 | ctx context.Context | ||
| 86 | cancel context.CancelFunc | 87 | cancel context.CancelFunc | ||
| 88 | + | // floating is independent from the normal layout tree and pane map. | |||
| 89 | + | floating floatingSlot | |||
| 87 | 90 | ||||
| 88 | // previewClient tracks the one client currently previewing this tab in the picker. | 91 | // previewClient tracks the one client currently previewing this tab in the picker. | ||
| 89 | // v1 is last-writer-wins: multiple clients previewing the same tab are not supported. | 92 | // v1 is last-writer-wins: multiple clients previewing the same tab are not supported. | ||
| @@ -330,6 +333,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||||
| 330 | d.log.Info("tab created", "session", name, "tab", tabIndex) | 333 | d.log.Info("tab created", "session", name, "tab", tabIndex) | ||
| 331 | d.startTabGoroutines(sess, tb) | 334 | d.startTabGoroutines(sess, tb) | ||
| 332 | d.mu.Unlock() | 335 | d.mu.Unlock() | ||
| 336 | + | d.activateTab(sess, tb) | |||
| 333 | markSnapshotDirty(sess) | 337 | markSnapshotDirty(sess) | ||
| 334 | return nil | 338 | return nil | ||
| 335 | } | 339 | } | ||
| @@ -371,7 +375,13 @@ func (tb *tab) panesSnapshot() []*pane { | |||||
| 371 | func (tb *tab) closeAllPanes() { | 375 | func (tb *tab) closeAllPanes() { | ||
| 372 | tb.mu.Lock() | 376 | tb.mu.Lock() | ||
| 373 | panes := tb.panesSnapshot() | 377 | panes := tb.panesSnapshot() | ||
| 378 | + | // Invalidate before cancellation so a concurrently unblocked floating | |||
| 379 | + | // reader cannot reap a subsequently reused slot. | |||
| 380 | + | floating := tb.takeFloatingLocked() | |||
| 374 | tb.mu.Unlock() | 381 | tb.mu.Unlock() | ||
| 382 | + | if floating != nil { | |||
| 383 | + | panes = append(panes, floating) | |||
| 384 | + | } | |||
| 375 | for _, p := range panes { | 385 | for _, p := range panes { | ||
| 376 | if p.cancel != nil { | 386 | if p.cancel != nil { | ||
| 377 | p.cancel() | 387 | p.cancel() | ||
| @@ -401,7 +411,10 @@ func (d *Daemon) startTabGoroutines(sess *session, tb *tab) { | |||||
| 401 | 411 | ||||
| 402 | func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) { | 412 | func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) { | ||
| 403 | if p != nil { | 413 | if p != nil { | ||
| 404 | - | d.log.Info("pane created", "session", sess.name, "pane", p.id) | 414 | + | sess.mu.Lock() |
| 415 | + | name := sess.name | |||
| 416 | + | sess.mu.Unlock() | |||
| 417 | + | d.log.Info("pane created", "session", name, "pane", p.id) | |||
| 405 | } | 418 | } | ||
| 406 | d.sessWg.Add(2) | 419 | d.sessWg.Add(2) | ||
| 407 | go d.ptyReader(sess, tb, p) | 420 | go d.ptyReader(sess, tb, p) | ||
| @@ -599,12 +612,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||||
| 599 | return | 612 | return | ||
| 600 | } | 613 | } | ||
| 601 | ringing := tb.attention | 614 | ringing := tb.attention | ||
| 615 | + | wasActive := idx == sess.active | |||
| 602 | sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...) | 616 | sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...) | ||
| 603 | if sess.active >= len(sess.tabs) { | 617 | if sess.active >= len(sess.tabs) { | ||
| 604 | sess.active = len(sess.tabs) - 1 | 618 | sess.active = len(sess.tabs) - 1 | ||
| 605 | } else if idx < sess.active { | 619 | } else if idx < sess.active { | ||
| 606 | sess.active-- | 620 | sess.active-- | ||
| 607 | } | 621 | } | ||
| 622 | + | destination := sess.tabs[sess.active] | |||
| 608 | ac := sess.client | 623 | ac := sess.client | ||
| 609 | name := sess.name | 624 | name := sess.name | ||
| 610 | record := sess.persistRecordLocked(time.Now().UnixNano()) | 625 | record := sess.persistRecordLocked(time.Now().UnixNano()) | ||
| @@ -623,7 +638,11 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||||
| 623 | if tb.cancel != nil { | 638 | if tb.cancel != nil { | ||
| 624 | tb.cancel() | 639 | tb.cancel() | ||
| 625 | } | 640 | } | ||
| 641 | + | d.teardownFloating(tb, ac) | |||
| 626 | tb.closeAllPanes() | 642 | tb.closeAllPanes() | ||
| 643 | + | if wasActive { | |||
| 644 | + | d.activateTab(sess, destination) | |||
| 645 | + | } | |||
| 627 | if repaint && ac != nil { | 646 | if repaint && ac != nil { | ||
| 628 | d.paint(sess, ac, true) | 647 | d.paint(sess, ac, true) | ||
| 629 | } | 648 | } | ||
| @@ -723,6 +742,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 723 | sess.mu.Unlock() | 742 | sess.mu.Unlock() | ||
| 724 | for _, tb := range tabs { | 743 | for _, tb := range tabs { | ||
| 725 | d.clearDestroyedTabPreview(tb) | 744 | d.clearDestroyedTabPreview(tb) | ||
| 745 | + | d.teardownFloating(tb, ac) | |||
| 726 | tb.closeAllPanes() | 746 | tb.closeAllPanes() | ||
| 727 | } | 747 | } | ||
| 728 | for _, path := range clipFiles { | 748 | for _, path := range clipFiles { | ||
M internal/usecase/daemon/session_test.go +74 -7
| @@ -33,7 +33,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) { | |||||
| 33 | defer releaseSecond() | 33 | defer releaseSecond() | ||
| 34 | f := portsmocks.NewMockPTYFactory(t) | 34 | f := portsmocks.NewMockPTYFactory(t) | ||
| 35 | var dirs []string | 35 | var dirs []string | ||
| 36 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | 36 | + | normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2} |
| 37 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | |||
| 37 | func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) { | 38 | func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) { | ||
| 38 | dirs = append(dirs, dir) | 39 | dirs = append(dirs, dir) | ||
| 39 | if len(dirs) == 1 { | 40 | if len(dirs) == 1 { | ||
| @@ -42,6 +43,10 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) { | |||||
| 42 | return second, nil | 43 | return second, nil | ||
| 43 | }, | 44 | }, | ||
| 44 | ).Twice() | 45 | ).Twice() | ||
| 46 | + | floating := newQuietPTY() | |||
| 47 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool { | |||
| 48 | + | return got != normalSize && got.Valid() | |||
| 49 | + | })).Return(floating, nil).Once() | |||
| 45 | 50 | ||||
| 46 | d := newTestDaemon(t, f, stubClock{}) | 51 | d := newTestDaemon(t, f, stubClock{}) | ||
| 47 | tr := portsmocks.NewMockTransport(t) | 52 | tr := portsmocks.NewMockTransport(t) | ||
| @@ -62,6 +67,16 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) { | |||||
| 62 | 67 | ||||
| 63 | require.NoError(t, d.createTab(sess, sz)) | 68 | require.NoError(t, d.createTab(sess, sz)) | ||
| 64 | require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs) | 69 | require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs) | ||
| 70 | + | requireFloatingInitialized(t, sess.activeTab()) | |||
| 71 | + | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | |||
| 72 | + | releaseFirst() | |||
| 73 | + | releaseSecond() | |||
| 74 | + | d.sessWg.Wait() | |||
| 75 | + | select { | |||
| 76 | + | case <-floating.done: | |||
| 77 | + | default: | |||
| 78 | + | t.Fatal("floating prewarm PTY was not closed") | |||
| 79 | + | } | |||
| 65 | } | 80 | } | ||
| 66 | 81 | ||||
| 67 | func TestHandshakeEphemeralHappy(t *testing.T) { | 82 | func TestHandshakeEphemeralHappy(t *testing.T) { | ||
| @@ -197,8 +212,13 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |||||
| 197 | p2.EXPECT().Pid().Return(4242).Maybe() | 212 | p2.EXPECT().Pid().Return(4242).Maybe() | ||
| 198 | 213 | ||||
| 199 | f := portsmocks.NewMockPTYFactory(t) | 214 | f := portsmocks.NewMockPTYFactory(t) | ||
| 200 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p1, nil).Once() | 215 | + | normalSize := domain.Size{Cols: 80, Rows: 22} |
| 201 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | 216 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once() |
| 217 | + | floating := newQuietPTY() | |||
| 218 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool { | |||
| 219 | + | return got != normalSize && got.Valid() | |||
| 220 | + | })).Return(floating, nil).Once() | |||
| 221 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | |||
| 202 | func(string, []string, []string, string, domain.Size) (ports.PTY, error) { | 222 | func(string, []string, []string, string, domain.Size) (ports.PTY, error) { | ||
| 203 | close(opened) | 223 | close(opened) | ||
| 204 | <-releaseOpen | 224 | <-releaseOpen | ||
| @@ -231,6 +251,11 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |||||
| 231 | releaseConn() | 251 | releaseConn() | ||
| 232 | hg.Wait() | 252 | hg.Wait() | ||
| 233 | d.sessWg.Wait() | 253 | d.sessWg.Wait() | ||
| 254 | + | select { | |||
| 255 | + | case <-floating.done: | |||
| 256 | + | default: | |||
| 257 | + | t.Fatal("floating prewarm PTY was not closed") | |||
| 258 | + | } | |||
| 234 | } | 259 | } | ||
| 235 | 260 | ||||
| 236 | // --- ephemeral numbering ---------------------------------------------------- | 261 | // --- ephemeral numbering ---------------------------------------------------- | ||
| @@ -885,6 +910,29 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) { | |||||
| 885 | } | 910 | } | ||
| 886 | } | 911 | } | ||
| 887 | 912 | ||||
| 913 | + | func TestCloseActiveTabActivatesDestinationFloatingPane(t *testing.T) { | |||
| 914 | + | d, sess, ac, _, releases := newManualTabSession(t, 2) | |||
| 915 | + | sess.mu.Lock() | |||
| 916 | + | sess.client = ac | |||
| 917 | + | sess.mu.Unlock() | |||
| 918 | + | d.ptys = newBlockingOpenFactory(t, d) | |||
| 919 | + | defer releases[0]() | |||
| 920 | + | defer releases[1]() | |||
| 921 | + | sess.mu.Lock() | |||
| 922 | + | first, closing := sess.tabs[0], sess.tabs[1] | |||
| 923 | + | sess.active = 1 | |||
| 924 | + | sess.mu.Unlock() | |||
| 925 | + | first.mu.Lock() | |||
| 926 | + | stale := first.takeFloatingLocked() | |||
| 927 | + | first.mu.Unlock() | |||
| 928 | + | closeFloatingPane(stale) | |||
| 929 | + | ||||
| 930 | + | d.closeTab(sess, closing, false) | |||
| 931 | + | ||||
| 932 | + | require.Same(t, first, sess.activeTab()) | |||
| 933 | + | requireFloatingInitialized(t, first) | |||
| 934 | + | } | |||
| 935 | + | ||||
| 888 | func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) { | 936 | func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) { | ||
| 889 | sz := domain.Size{Cols: 80, Rows: 24} | 937 | sz := domain.Size{Cols: 80, Rows: 24} | ||
| 890 | p, release := newBlockingPTY(t) | 938 | p, release := newBlockingPTY(t) | ||
| @@ -1194,7 +1242,8 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||||
| 1194 | 1242 | ||||
| 1195 | var opens [][]string | 1243 | var opens [][]string | ||
| 1196 | f := portsmocks.NewMockPTYFactory(t) | 1244 | f := portsmocks.NewMockPTYFactory(t) | ||
| 1197 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | 1245 | + | normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2} |
| 1246 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | |||
| 1198 | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | 1247 | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | ||
| 1199 | opens = append(opens, append([]string(nil), env...)) | 1248 | opens = append(opens, append([]string(nil), env...)) | ||
| 1200 | if len(opens) == 1 { | 1249 | if len(opens) == 1 { | ||
| @@ -1203,6 +1252,10 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||||
| 1203 | return p2, nil | 1252 | return p2, nil | ||
| 1204 | }, | 1253 | }, | ||
| 1205 | ).Twice() | 1254 | ).Twice() | ||
| 1255 | + | floating := newQuietPTY() | |||
| 1256 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool { | |||
| 1257 | + | return got != normalSize && got.Valid() | |||
| 1258 | + | })).Return(floating, nil).Once() | |||
| 1206 | 1259 | ||||
| 1207 | d := newTestDaemon(t, f, stubClock{}) | 1260 | d := newTestDaemon(t, f, stubClock{}) | ||
| 1208 | d.baseEnv = []string{"KEEP=1", "COLORTERM=old"} | 1261 | d.baseEnv = []string{"KEEP=1", "COLORTERM=old"} | ||
| @@ -1234,7 +1287,8 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||||
| 1234 | 1287 | ||||
| 1235 | var opens [][]string | 1288 | var opens [][]string | ||
| 1236 | f := portsmocks.NewMockPTYFactory(t) | 1289 | f := portsmocks.NewMockPTYFactory(t) | ||
| 1237 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | 1290 | + | normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2} |
| 1291 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | |||
| 1238 | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | 1292 | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | ||
| 1239 | opens = append(opens, append([]string(nil), env...)) | 1293 | opens = append(opens, append([]string(nil), env...)) | ||
| 1240 | if len(opens) == 1 { | 1294 | if len(opens) == 1 { | ||
| @@ -1243,6 +1297,10 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) { | |||||
| 1243 | return p2, nil | 1297 | return p2, nil | ||
| 1244 | }, | 1298 | }, | ||
| 1245 | ).Twice() | 1299 | ).Twice() | ||
| 1300 | + | floating := newQuietPTY() | |||
| 1301 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool { | |||
| 1302 | + | return got != normalSize && got.Valid() | |||
| 1303 | + | })).Return(floating, nil).Once() | |||
| 1246 | 1304 | ||||
| 1247 | d := newTestDaemon(t, f, stubClock{}) | 1305 | d := newTestDaemon(t, f, stubClock{}) | ||
| 1248 | tr1 := portsmocks.NewMockTransport(t) | 1306 | tr1 := portsmocks.NewMockTransport(t) | ||
| @@ -1276,7 +1334,8 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) { | |||||
| 1276 | defer release2() | 1334 | defer release2() | ||
| 1277 | var opens [][]string | 1335 | var opens [][]string | ||
| 1278 | f := portsmocks.NewMockPTYFactory(t) | 1336 | f := portsmocks.NewMockPTYFactory(t) | ||
| 1279 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | 1337 | + | normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2} |
| 1338 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn( | |||
| 1280 | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | 1339 | func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) { | ||
| 1281 | opens = append(opens, append([]string(nil), env...)) | 1340 | opens = append(opens, append([]string(nil), env...)) | ||
| 1282 | if len(opens) == 1 { | 1341 | if len(opens) == 1 { | ||
| @@ -1285,6 +1344,10 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) { | |||||
| 1285 | return p2, nil | 1344 | return p2, nil | ||
| 1286 | }, | 1345 | }, | ||
| 1287 | ).Twice() | 1346 | ).Twice() | ||
| 1347 | + | floating := newQuietPTY() | |||
| 1348 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool { | |||
| 1349 | + | return got != normalSize && got.Valid() | |||
| 1350 | + | })).Return(floating, nil).Once() | |||
| 1288 | d := newTestDaemon(t, f, stubClock{}) | 1351 | d := newTestDaemon(t, f, stubClock{}) | ||
| 1289 | tr := portsmocks.NewMockTransport(t) | 1352 | tr := portsmocks.NewMockTransport(t) | ||
| 1290 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | 1353 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | ||
| @@ -1295,10 +1358,14 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) { | |||||
| 1295 | got := ac.sess.Get() | 1358 | got := ac.sess.Get() | ||
| 1296 | require.NotNil(t, got) | 1359 | require.NotNil(t, got) | ||
| 1297 | got.mu.Lock() | 1360 | got.mu.Lock() | ||
| 1298 | - | defer got.mu.Unlock() | |||
| 1299 | require.True(t, got.terminal.TrueColor) | 1361 | require.True(t, got.terminal.TrueColor) | ||
| 1362 | + | got.mu.Unlock() | |||
| 1300 | require.Len(t, opens, 2) | 1363 | require.Len(t, opens, 2) | ||
| 1301 | require.Contains(t, opens[1], "TERM=xterm-direct") | 1364 | require.Contains(t, opens[1], "TERM=xterm-direct") | ||
| 1302 | require.Contains(t, opens[1], "COLORTERM=truecolor") | 1365 | require.Contains(t, opens[1], "COLORTERM=truecolor") | ||
| 1303 | require.Contains(t, opens[1], "TERM_PROGRAM=vev") | 1366 | require.Contains(t, opens[1], "TERM_PROGRAM=vev") | ||
| 1367 | + | _ = d.killSession(got, ports.ReasonSessionKilled, false) | |||
| 1368 | + | release1() | |||
| 1369 | + | release2() | |||
| 1370 | + | d.sessWg.Wait() | |||
| 1304 | } | 1371 | } | ||
M internal/usecase/daemon/snapshot_restore_test.go +31 -0
| @@ -105,6 +105,37 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||||
| 105 | require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID) | 105 | require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID) | ||
| 106 | } | 106 | } | ||
| 107 | 107 | ||||
| 108 | + | func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) { | |||
| 109 | + | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{ | |||
| 110 | + | Name: "work", Active: 0, Tabs: []snapcodec.Tab{ | |||
| 111 | + | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/one"}}}, | |||
| 112 | + | {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/two"}}}, | |||
| 113 | + | }, | |||
| 114 | + | })}}} | |||
| 115 | + | factory := &restorePTYFactory{} | |||
| 116 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 117 | + | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "btop", Width: 80, Height: 80}}) | |||
| 118 | + | WithSnapshotStore(store)(d) | |||
| 119 | + | d.restoreSnapshots(context.Background()) | |||
| 120 | + | // Only the persisted normal panes may be restored. Floating runtimes are | |||
| 121 | + | // deliberately excluded and must wait for a Phase-5 activation path. | |||
| 122 | + | require.Len(t, factory.opens, 2) | |||
| 123 | + | d.mu.Lock() | |||
| 124 | + | restored := d.findByNameLocked("work") | |||
| 125 | + | d.mu.Unlock() | |||
| 126 | + | require.NotNil(t, restored) | |||
| 127 | + | restored.mu.Lock() | |||
| 128 | + | tabs := append([]*tab(nil), restored.tabs...) | |||
| 129 | + | restored.mu.Unlock() | |||
| 130 | + | require.Len(t, tabs, 2) | |||
| 131 | + | for _, tb := range tabs { | |||
| 132 | + | tb.mu.Lock() | |||
| 133 | + | require.Equal(t, floatingUninitialized, tb.floating.state) | |||
| 134 | + | require.Nil(t, tb.floating.pane) | |||
| 135 | + | tb.mu.Unlock() | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 108 | func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore { | 139 | func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore { | ||
| 109 | t.Helper() | 140 | t.Helper() | ||
| 110 | return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{ | 141 | return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{ | ||
M internal/usecase/daemon/snapshot_test.go +24 -0
| @@ -63,6 +63,30 @@ func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) { | |||||
| 63 | } | 63 | } | ||
| 64 | } | 64 | } | ||
| 65 | 65 | ||||
| 66 | + | func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) { | |||
| 67 | + | store := portsmocks.NewMockSnapshotStore(t) | |||
| 68 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 69 | + | WithSnapshotStore(store)(d) | |||
| 70 | + | sess := newSnapshotTestSession(t, "work", false, "/fallback") | |||
| 71 | + | tb := sess.tabs[0] | |||
| 72 | + | floatingPTY, _ := newBlockingPTY(t) | |||
| 73 | + | floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8}) | |||
| 74 | + | tb.mu.Lock() | |||
| 75 | + | generation := tb.beginFloatingWarmLocked(false) | |||
| 76 | + | require.True(t, tb.installFloatingLocked(floating, generation)) | |||
| 77 | + | tb.mu.Unlock() | |||
| 78 | + | ||||
| 79 | + | store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error { | |||
| 80 | + | snap, err := snapcodec.Unmarshal(data) | |||
| 81 | + | require.NoError(t, err) | |||
| 82 | + | require.Len(t, snap.Tabs, 1) | |||
| 83 | + | require.Len(t, snap.Tabs[0].Panes, 1, "floating runtime must not be persisted") | |||
| 84 | + | require.Equal(t, layout.PaneID("pane-1"), snap.Tabs[0].Panes[0].ID) | |||
| 85 | + | return nil | |||
| 86 | + | }).Once() | |||
| 87 | + | require.True(t, d.captureSession(sess)) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 66 | func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) { | 90 | func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) { | ||
| 67 | store := portsmocks.NewMockSnapshotStore(t) | 91 | store := portsmocks.NewMockSnapshotStore(t) | ||
| 68 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 92 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
M internal/usecase/daemon/status_test.go +46 -11
| @@ -155,6 +155,31 @@ func TestStatusApplyThemeStoresClientAndPropagatesScreens(t *testing.T) { | |||||
| 155 | assertSessionColorScheme(t, sess, true) | 155 | assertSessionColorScheme(t, sess, true) | ||
| 156 | } | 156 | } | ||
| 157 | 157 | ||||
| 158 | + | func TestApplyThemePropagatesToFloatingPane(t *testing.T) { | |||
| 159 | + | p, releasePTY := newBlockingPTY(t) | |||
| 160 | + | defer releasePTY() | |||
| 161 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 162 | + | tb := sess.activeTab() | |||
| 163 | + | floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}) | |||
| 164 | + | installTestFloating(tb, floating, true) | |||
| 165 | + | clientTheme := ports.Theme{ | |||
| 166 | + | HasForeground: true, | |||
| 167 | + | Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 168 | + | HasBackground: true, | |||
| 169 | + | Background: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 170 | + | SchemeKnown: true, | |||
| 171 | + | Light: true, | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | d.applyTheme(sess, ac, clientTheme) | |||
| 175 | + | assertPaneDefaultColors(t, floating, clientTheme.Foreground, clientTheme.Background) | |||
| 176 | + | assertPaneColorScheme(t, floating, true) | |||
| 177 | + | ||||
| 178 | + | d.ApplyConfig(domain.Config{Theme: domain.ThemeDark}) | |||
| 179 | + | assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background) | |||
| 180 | + | assertPaneColorScheme(t, floating, false) | |||
| 181 | + | } | |||
| 182 | + | ||||
| 158 | func TestApplyThemeForcedBuiltinThemePropagatesToChromeAndPanes(t *testing.T) { | 183 | func TestApplyThemeForcedBuiltinThemePropagatesToChromeAndPanes(t *testing.T) { | ||
| 159 | tests := []struct { | 184 | tests := []struct { | ||
| 160 | name string | 185 | name string | ||
| @@ -297,26 +322,36 @@ func TestApplyThemeAutoUnknownDoesNotClobberPaneColorScheme(t *testing.T) { | |||||
| 297 | func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB) { | 322 | func assertSessionDefaultColors(t *testing.T, sess *session, fg, bg renderer.RGB) { | ||
| 298 | t.Helper() | 323 | t.Helper() | ||
| 299 | for _, tb := range sess.tabs { | 324 | for _, tb := range sess.tabs { | ||
| 300 | - | var got []byte | 325 | + | assertPaneDefaultColors(t, tb.focusedPane(), fg, bg) |
| 301 | - | tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) } | |||
| 302 | - | tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |||
| 303 | - | require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got)) | |||
| 304 | - | require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got)) | |||
| 305 | } | 326 | } | ||
| 306 | } | 327 | } | ||
| 307 | 328 | ||||
| 329 | + | func assertPaneDefaultColors(t *testing.T, p *pane, fg, bg renderer.RGB) { | |||
| 330 | + | t.Helper() | |||
| 331 | + | var got []byte | |||
| 332 | + | p.screen.OnResponse = func(b []byte) { got = append(got, b...) } | |||
| 333 | + | p.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |||
| 334 | + | require.True(t, strings.Contains(string(got), formatOSCColor(fg)), string(got)) | |||
| 335 | + | require.True(t, strings.Contains(string(got), formatOSCColor(bg)), string(got)) | |||
| 336 | + | } | |||
| 337 | + | ||||
| 308 | func assertSessionColorScheme(t *testing.T, sess *session, light bool) { | 338 | func assertSessionColorScheme(t *testing.T, sess *session, light bool) { | ||
| 339 | + | t.Helper() | |||
| 340 | + | for _, tb := range sess.tabs { | |||
| 341 | + | assertPaneColorScheme(t, tb.focusedPane(), light) | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | func assertPaneColorScheme(t *testing.T, p *pane, light bool) { | |||
| 309 | t.Helper() | 346 | t.Helper() | ||
| 310 | want := "\x1b[?997;1n" | 347 | want := "\x1b[?997;1n" | ||
| 311 | if light { | 348 | if light { | ||
| 312 | want = "\x1b[?997;2n" | 349 | want = "\x1b[?997;2n" | ||
| 313 | } | 350 | } | ||
| 314 | - | for _, tb := range sess.tabs { | 351 | + | var got []byte |
| 315 | - | var got []byte | 352 | + | p.screen.OnResponse = func(b []byte) { got = append(got, b...) } |
| 316 | - | tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) } | 353 | + | p.screen.Write([]byte("\x1b[?996n")) |
| 317 | - | tb.focusedPane().screen.Write([]byte("\x1b[?996n")) | 354 | + | require.Equal(t, want, string(got)) |
| 318 | - | require.Equal(t, want, string(got)) | |||
| 319 | - | } | |||
| 320 | } | 355 | } | ||
| 321 | 356 | ||||
| 322 | func assertSessionColorSchemeUnknown(t *testing.T, sess *session) { | 357 | func assertSessionColorSchemeUnknown(t *testing.T, sess *session) { | ||
M internal/usecase/daemon/theme_resolve.go +8 -0
| @@ -7,7 +7,12 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme | |||||
| 7 | ac.sendMu.Lock() | 7 | ac.sendMu.Lock() | ||
| 8 | defer ac.sendMu.Unlock() | 8 | defer ac.sendMu.Unlock() | ||
| 9 | } | 9 | } | ||
| 10 | + | sess.themeMu.Lock() | |||
| 11 | + | defer sess.themeMu.Unlock() | |||
| 12 | + | return d.applyHostThemeLocked(sess, ac, t, clearUnknownScheme) | |||
| 13 | + | } | |||
| 10 | 14 | ||||
| 15 | + | func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool { | |||
| 11 | sess.mu.Lock() | 16 | sess.mu.Lock() | ||
| 12 | if ac != nil { | 17 | if ac != nil { | ||
| 13 | if sess.client != ac { | 18 | if sess.client != ac { | ||
| @@ -28,6 +33,9 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme | |||||
| 28 | for _, tb := range tabs { | 33 | for _, tb := range tabs { | ||
| 29 | tb.mu.Lock() | 34 | tb.mu.Lock() | ||
| 30 | panes := tb.panesSnapshot() | 35 | panes := tb.panesSnapshot() | ||
| 36 | + | if floating := tb.floating.pane; floating != nil { | |||
| 37 | + | panes = append(panes, floating) | |||
| 38 | + | } | |||
| 31 | tb.mu.Unlock() | 39 | tb.mu.Unlock() | ||
| 32 | for _, p := range panes { | 40 | for _, p := range panes { | ||
| 33 | p.mu.Lock() | 41 | p.mu.Lock() | ||
A internal/usecase/daemon/title_cache_regression_test.go +37 -0
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestComposeClientFrameCachePrunesTitleGenerationsAfterLayoutChurn(t *testing.T) { | |||
| 14 | + | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | |||
| 15 | + | first := win.focusedPane() | |||
| 16 | + | var bars barCache | |||
| 17 | + | var composed composedFrameCache | |||
| 18 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()} | |||
| 19 | + | ||||
| 20 | + | for i := 0; i < 8; i++ { | |||
| 21 | + | id := layout.PaneID(fmt.Sprintf("pane-%d", i+2)) | |||
| 22 | + | replacement := newPane(id, nil, domain.Size{Cols: 20, Rows: 3}) | |||
| 23 | + | win.panes = map[layout.PaneID]*pane{first.id: first, id: replacement} | |||
| 24 | + | win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{ | |||
| 25 | + | layout.NewLeaf(first.id), | |||
| 26 | + | layout.NewLeaf(id), | |||
| 27 | + | }, Expanded: id} | |||
| 28 | + | win.tree.Focus = id | |||
| 29 | + | ||||
| 30 | + | composeClientFrameWithLayoutCached(state, win, i == 0, solveTabLayoutLocked(win), &bars, &composed) | |||
| 31 | + | ||||
| 32 | + | require.Equal(t, map[layout.PaneID]uint64{ | |||
| 33 | + | first.id: composed.titleGenerations[first.id], | |||
| 34 | + | id: composed.titleGenerations[id], | |||
| 35 | + | }, composed.titleGenerations) | |||
| 36 | + | } | |||
| 37 | + | } | |||
M internal/usecase/keys/bindings.go +1 -0
| @@ -17,6 +17,7 @@ func DefaultBindings() *Bindings { | |||||
| 17 | altRunes := map[rune]Action{ | 17 | altRunes := map[rune]Action{ | ||
| 18 | ' ': ActionOpenPalette, | 18 | ' ': ActionOpenPalette, | ||
| 19 | 'a': ActionJumpAttention, | 19 | 'a': ActionJumpAttention, | ||
| 20 | + | 'f': ActionToggleFloatingPane, | |||
| 20 | 'h': ActionFocusPaneLeft, | 21 | 'h': ActionFocusPaneLeft, | ||
| 21 | 'j': ActionFocusPaneDown, | 22 | 'j': ActionFocusPaneDown, | ||
| 22 | 'k': ActionFocusPaneUp, | 23 | 'k': ActionFocusPaneUp, | ||
M internal/usecase/keys/keyspec.go +16 -15
| @@ -49,21 +49,22 @@ func ParseKeySpec(spec string) (KeySpec, error) { | |||||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | var actionNames = map[Action]string{ | 51 | var actionNames = map[Action]string{ | ||
| 52 | - | ActionOpenPalette: "open-palette", | 52 | + | ActionOpenPalette: "open-palette", |
| 53 | - | ActionJumpAttention: "jump-attention", | 53 | + | ActionToggleFloatingPane: "toggle-floating-pane", |
| 54 | - | ActionFocusPaneLeft: "focus-pane-left", | 54 | + | ActionJumpAttention: "jump-attention", |
| 55 | - | ActionFocusPaneRight: "focus-pane-right", | 55 | + | ActionFocusPaneLeft: "focus-pane-left", |
| 56 | - | ActionFocusPaneUp: "focus-pane-up", | 56 | + | ActionFocusPaneRight: "focus-pane-right", |
| 57 | - | ActionFocusPaneDown: "focus-pane-down", | 57 | + | ActionFocusPaneUp: "focus-pane-up", |
| 58 | - | ActionSwitchTab1: "switch-tab-1", | 58 | + | ActionFocusPaneDown: "focus-pane-down", |
| 59 | - | ActionSwitchTab2: "switch-tab-2", | 59 | + | ActionSwitchTab1: "switch-tab-1", |
| 60 | - | ActionSwitchTab3: "switch-tab-3", | 60 | + | ActionSwitchTab2: "switch-tab-2", |
| 61 | - | ActionSwitchTab4: "switch-tab-4", | 61 | + | ActionSwitchTab3: "switch-tab-3", |
| 62 | - | ActionSwitchTab5: "switch-tab-5", | 62 | + | ActionSwitchTab4: "switch-tab-4", |
| 63 | - | ActionSwitchTab6: "switch-tab-6", | 63 | + | ActionSwitchTab5: "switch-tab-5", |
| 64 | - | ActionSwitchTab7: "switch-tab-7", | 64 | + | ActionSwitchTab6: "switch-tab-6", |
| 65 | - | ActionSwitchTab8: "switch-tab-8", | 65 | + | ActionSwitchTab7: "switch-tab-7", |
| 66 | - | ActionSwitchTab9: "switch-tab-9", | 66 | + | ActionSwitchTab8: "switch-tab-8", |
| 67 | + | ActionSwitchTab9: "switch-tab-9", | |||
| 67 | } | 68 | } | ||
| 68 | 69 | ||||
| 69 | var actionsByName = func() map[string]Action { | 70 | var actionsByName = func() map[string]Action { | ||
M internal/usecase/keys/keyspec_test.go +7 -3
| @@ -64,6 +64,7 @@ func TestActionNamesAreCanonical(t *testing.T) { | |||||
| 64 | name string | 64 | name string | ||
| 65 | }{ | 65 | }{ | ||
| 66 | {ActionOpenPalette, "open-palette"}, | 66 | {ActionOpenPalette, "open-palette"}, | ||
| 67 | + | {ActionToggleFloatingPane, "toggle-floating-pane"}, | |||
| 67 | {ActionJumpAttention, "jump-attention"}, | 68 | {ActionJumpAttention, "jump-attention"}, | ||
| 68 | {ActionFocusPaneLeft, "focus-pane-left"}, | 69 | {ActionFocusPaneLeft, "focus-pane-left"}, | ||
| 69 | {ActionFocusPaneRight, "focus-pane-right"}, | 70 | {ActionFocusPaneRight, "focus-pane-right"}, | ||
| @@ -96,14 +97,17 @@ func TestActionNamesAreCanonical(t *testing.T) { | |||||
| 96 | 97 | ||||
| 97 | func TestBuildBindingsAppliesOverrides(t *testing.T) { | 98 | func TestBuildBindingsAppliesOverrides(t *testing.T) { | ||
| 98 | bindings, warnings := BuildBindings(map[string]string{ | 99 | bindings, warnings := BuildBindings(map[string]string{ | ||
| 99 | - | "open-palette": "alt+o", | 100 | + | "open-palette": "alt+o", |
| 100 | - | "focus-pane-left": "alt+left", | 101 | + | "toggle-floating-pane": "alt+g", |
| 101 | - | "switch-tab-2": "alt+2", | 102 | + | "focus-pane-left": "alt+left", |
| 103 | + | "switch-tab-2": "alt+2", | |||
| 102 | }) | 104 | }) | ||
| 103 | require.Empty(t, warnings) | 105 | require.Empty(t, warnings) | ||
| 104 | 106 | ||||
| 105 | assertAltRuneAction(t, bindings, 'o', ActionOpenPalette) | 107 | assertAltRuneAction(t, bindings, 'o', ActionOpenPalette) | ||
| 106 | assertAltRuneUnbound(t, bindings, ' ') | 108 | assertAltRuneUnbound(t, bindings, ' ') | ||
| 109 | + | assertAltRuneAction(t, bindings, 'g', ActionToggleFloatingPane) | |||
| 110 | + | assertAltRuneUnbound(t, bindings, 'f') | |||
| 107 | assertAltArrowAction(t, bindings, 'D', ActionFocusPaneLeft) | 111 | assertAltArrowAction(t, bindings, 'D', ActionFocusPaneLeft) | ||
| 108 | assertAltRuneUnbound(t, bindings, 'h') | 112 | assertAltRuneUnbound(t, bindings, 'h') | ||
| 109 | assertAltRuneAction(t, bindings, '2', ActionSwitchTab2) | 113 | assertAltRuneAction(t, bindings, '2', ActionSwitchTab2) | ||
M internal/usecase/keys/router.go +1 -0
| @@ -37,6 +37,7 @@ const ( | |||||
| 37 | ActionFocusPaneRight | 37 | ActionFocusPaneRight | ||
| 38 | ActionFocusPaneUp | 38 | ActionFocusPaneUp | ||
| 39 | ActionFocusPaneDown | 39 | ActionFocusPaneDown | ||
| 40 | + | ActionToggleFloatingPane | |||
| 40 | ) | 41 | ) | ||
| 41 | 42 | ||||
| 42 | // Handler receives router outputs. Forward is called only for bytes that should | 43 | // Handler receives router outputs. Forward is called only for bytes that should | ||
M internal/usecase/keys/router_test.go +8 -0
| @@ -53,6 +53,7 @@ func TestDefaultBindingsParityRoutesBuiltInBindings(t *testing.T) { | |||||
| 53 | want Action | 53 | want Action | ||
| 54 | }{ | 54 | }{ | ||
| 55 | {name: "palette", in: []byte{ESC, ' '}, want: ActionOpenPalette}, | 55 | {name: "palette", in: []byte{ESC, ' '}, want: ActionOpenPalette}, | ||
| 56 | + | {name: "floating pane", in: []byte{ESC, 'f'}, want: ActionToggleFloatingPane}, | |||
| 56 | {name: "jump attention", in: []byte{ESC, 'a'}, want: ActionJumpAttention}, | 57 | {name: "jump attention", in: []byte{ESC, 'a'}, want: ActionJumpAttention}, | ||
| 57 | {name: "focus left", in: []byte{ESC, 'h'}, want: ActionFocusPaneLeft}, | 58 | {name: "focus left", in: []byte{ESC, 'h'}, want: ActionFocusPaneLeft}, | ||
| 58 | {name: "focus down", in: []byte{ESC, 'j'}, want: ActionFocusPaneDown}, | 59 | {name: "focus down", in: []byte{ESC, 'j'}, want: ActionFocusPaneDown}, | ||
| @@ -237,6 +238,13 @@ func switchTabActions() []Action { | |||||
| 237 | } | 238 | } | ||
| 238 | } | 239 | } | ||
| 239 | 240 | ||||
| 241 | + | func TestSwitchTabActionsRemainContiguous(t *testing.T) { | |||
| 242 | + | for i, action := range switchTabActions() { | |||
| 243 | + | require.Equal(t, ActionSwitchTab1+Action(i), action) | |||
| 244 | + | } | |||
| 245 | + | require.False(t, ActionToggleFloatingPane >= ActionSwitchTab1 && ActionToggleFloatingPane <= ActionSwitchTab9) | |||
| 246 | + | } | |||
| 247 | + | ||||
| 240 | func TestRouterInterceptsAltAForJumpAttention(t *testing.T) { | 248 | func TestRouterInterceptsAltAForJumpAttention(t *testing.T) { | ||
| 241 | clk := &fakeClock{} | 249 | clk := &fakeClock{} | ||
| 242 | h := &captureHandler{} | 250 | h := &captureHandler{} | ||
M pkg/vt/screen.go +11 -3
| @@ -84,6 +84,7 @@ type Screen struct { | |||||
| 84 | defaultFG renderer.RGB | 84 | defaultFG renderer.RGB | ||
| 85 | defaultBG renderer.RGB | 85 | defaultBG renderer.RGB | ||
| 86 | defaultColorsKnown bool | 86 | defaultColorsKnown bool | ||
| 87 | + | terminalTitle string | |||
| 87 | 88 | ||||
| 88 | damage []renderer.Damage | 89 | damage []renderer.Damage | ||
| 89 | escapeBuf []byte | 90 | escapeBuf []byte | ||
| @@ -218,6 +219,9 @@ func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyle | |||||
| 218 | func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR } | 219 | func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR } | ||
| 219 | func (s *Screen) AltScreenActive() bool { return s.alternate != nil } | 220 | func (s *Screen) AltScreenActive() bool { return s.alternate != nil } | ||
| 220 | 221 | ||||
| 222 | + | // TerminalTitle returns the latest title set by OSC 0 or OSC 2. | |||
| 223 | + | func (s *Screen) TerminalTitle() string { return s.terminalTitle } | |||
| 224 | + | ||||
| 221 | func (s *Screen) Write(data []byte) { | 225 | func (s *Screen) Write(data []byte) { | ||
| 222 | if len(s.escapeBuf) > 0 { | 226 | if len(s.escapeBuf) > 0 { | ||
| 223 | combined := make([]byte, 0, len(s.escapeBuf)+len(data)) | 227 | combined := make([]byte, 0, len(s.escapeBuf)+len(data)) | ||
| @@ -735,15 +739,19 @@ func appendOSCColorComponent(dst []byte, c uint8) []byte { | |||||
| 735 | } | 739 | } | ||
| 736 | 740 | ||||
| 737 | // handleOSC inspects a complete OSC payload (between "ESC ]" and its | 741 | // handleOSC inspects a complete OSC payload (between "ESC ]" and its | ||
| 738 | - | // terminator). Notification sequences (OSC 9, OSC 777 "notify") and clipboard | 742 | + | // terminator). Terminal titles, notification sequences (OSC 9, OSC 777 |
| 739 | - | // set requests (OSC 52) are acted on; titles and every other OSC are still | 743 | + | // "notify"), and clipboard set requests (OSC 52) are acted on; every other OSC |
| 740 | - | // discarded. | 744 | + | // is discarded. |
| 741 | func (s *Screen) handleOSC(payload []byte) { | 745 | func (s *Screen) handleOSC(payload []byte) { | ||
| 742 | if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' { | 746 | if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' { | ||
| 743 | s.handleOSC52(string(payload[len("52;"):])) | 747 | s.handleOSC52(string(payload[len("52;"):])) | ||
| 744 | return | 748 | return | ||
| 745 | } | 749 | } | ||
| 746 | p := string(payload) | 750 | p := string(payload) | ||
| 751 | + | if selector, title, ok := strings.Cut(p, ";"); ok && (selector == "0" || selector == "2") { | |||
| 752 | + | s.terminalTitle = title | |||
| 753 | + | return | |||
| 754 | + | } | |||
| 747 | if p == "9;4" || strings.HasPrefix(p, "9;4;") { | 755 | if p == "9;4" || strings.HasPrefix(p, "9;4;") { | ||
| 748 | s.handleProgress(p[len("9;4"):]) | 756 | s.handleProgress(p[len("9;4"):]) | ||
| 749 | return | 757 | return | ||
M pkg/vt/screen_test.go +64 -0
| @@ -185,6 +185,70 @@ func TestBELWithoutCallbackIsDiscarded(t *testing.T) { | |||||
| 185 | require.Equal(t, "ab", strings.TrimRight(rowString(s.Frame.Row(0)), " ")) | 185 | require.Equal(t, "ab", strings.TrimRight(rowString(s.Frame.Row(0)), " ")) | ||
| 186 | } | 186 | } | ||
| 187 | 187 | ||||
| 188 | + | func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) { | |||
| 189 | + | tests := []struct { | |||
| 190 | + | name string | |||
| 191 | + | seq string | |||
| 192 | + | want string | |||
| 193 | + | }{ | |||
| 194 | + | {name: "OSC 0 BEL terminated", seq: "\x1b]0;zero title\x07", want: "zero title"}, | |||
| 195 | + | {name: "OSC 0 ST terminated", seq: "\x1b]0;zero title\x1b\\", want: "zero title"}, | |||
| 196 | + | {name: "OSC 2 BEL terminated", seq: "\x1b]2;two title\x07", want: "two title"}, | |||
| 197 | + | {name: "OSC 2 ST terminated", seq: "\x1b]2;two title\x1b\\", want: "two title"}, | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | for _, tt := range tests { | |||
| 201 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 202 | + | s := NewScreen(10, 2) | |||
| 203 | + | bells := 0 | |||
| 204 | + | s.OnBell = func() { bells++ } | |||
| 205 | + | s.ClearDamage() | |||
| 206 | + | ||||
| 207 | + | s.Write([]byte(tt.seq)) | |||
| 208 | + | ||||
| 209 | + | require.Equal(t, tt.want, s.TerminalTitle()) | |||
| 210 | + | require.Equal(t, 0, bells) | |||
| 211 | + | require.Empty(t, s.Damage()) | |||
| 212 | + | require.Equal(t, strings.Repeat(" ", s.Frame.Width), rowString(s.Frame.Row(0))) | |||
| 213 | + | }) | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func TestTerminalTitleHandlesSplitWritesReplacementAndClear(t *testing.T) { | |||
| 218 | + | s := NewScreen(10, 2) | |||
| 219 | + | ||||
| 220 | + | s.Write([]byte("\x1b]0;split")) | |||
| 221 | + | s.Write([]byte(" title\x1b")) | |||
| 222 | + | require.Empty(t, s.TerminalTitle()) | |||
| 223 | + | s.Write([]byte("\\")) | |||
| 224 | + | require.Equal(t, "split title", s.TerminalTitle()) | |||
| 225 | + | ||||
| 226 | + | s.Write([]byte("\x1b]2;replacement\x07")) | |||
| 227 | + | require.Equal(t, "replacement", s.TerminalTitle()) | |||
| 228 | + | ||||
| 229 | + | s.Write([]byte("\x1b]0;\x07")) | |||
| 230 | + | require.Empty(t, s.TerminalTitle()) | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func TestTerminalTitleIgnoresUnrelatedAndOversizedOSC(t *testing.T) { | |||
| 234 | + | s := NewScreen(10, 2) | |||
| 235 | + | s.Write([]byte("\x1b]0;retained\x07")) | |||
| 236 | + | ||||
| 237 | + | for _, seq := range []string{ | |||
| 238 | + | "\x1b]1;icon title\x07", | |||
| 239 | + | "\x1b]20;not selector two\x1b\\", | |||
| 240 | + | "\x1b]777;other;x;y\x07", | |||
| 241 | + | } { | |||
| 242 | + | s.Write([]byte(seq)) | |||
| 243 | + | require.Equal(t, "retained", s.TerminalTitle()) | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | payload := strings.Repeat("x", maxEscapeBufferLen+1) | |||
| 247 | + | s.Write([]byte("\x1b]2;" + payload)) | |||
| 248 | + | require.Equal(t, "retained", s.TerminalTitle()) | |||
| 249 | + | require.Empty(t, s.escapeBuf) | |||
| 250 | + | } | |||
| 251 | + | ||||
| 188 | func TestOnNotifyOSC9(t *testing.T) { | 252 | func TestOnNotifyOSC9(t *testing.T) { | ||
| 189 | s := NewScreen(10, 2) | 253 | s := NewScreen(10, 2) | ||
| 190 | var gotTitle, gotBody string | 254 | var gotTitle, gotBody string | ||