feat(session): create sessions from inside vev
- commit
- b60ef98e9b96eb0dbc96aa5a1f26de4780f44f09
- author
- brice · · 2026-07-03 16:20 UTC
- parent
- 151010a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +210 -9
M README.md +1 -0
| @@ -58,6 +58,7 @@ The status bar marks ephemeral sessions with `*`, for example `0*`. Ephemeral se | |||
| 58 | 58 | | Code | Action | | |
| 59 | 59 | |---|---| | |
| 60 | 60 | | CNT | create new tab | | |
| 61 | + | | CNS | create and switch to a named session | | |
| 61 | 62 | | CLT | close current tab | | |
| 62 | 63 | | NXT | switch to next tab | | |
| 63 | 64 | | PVT | switch to previous tab | | |
| @@ -58,6 +58,7 @@ The status bar marks ephemeral sessions with `*`, for example `0*`. Ephemeral se | |||||
| 58 | | Code | Action | | 58 | | Code | Action | | ||
| 59 | |---|---| | 59 | |---|---| | ||
| 60 | | CNT | create new tab | | 60 | | CNT | create new tab | | ||
| 61 | + | | CNS | create and switch to a named session | | |||
| 61 | | CLT | close current tab | | 62 | | CLT | close current tab | | ||
| 62 | | NXT | switch to next tab | | 63 | | NXT | switch to next tab | | ||
| 63 | | PVT | switch to previous tab | | 64 | | PVT | switch to previous tab | | ||
M internal/app/run.go +77 -5
| @@ -23,6 +23,7 @@ import ( | |||
| 23 | 23 | "github.com/bnema/vev/internal/adapters/pty" | |
| 24 | 24 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 25 | 25 | "github.com/bnema/vev/internal/adapters/term" | |
| 26 | + | "github.com/bnema/vev/internal/domain" | |
| 26 | 27 | "github.com/bnema/vev/internal/ports" | |
| 27 | 28 | "github.com/bnema/vev/internal/usecase/client" | |
| 28 | 29 | "github.com/bnema/vev/internal/usecase/confirm" | |
| @@ -235,16 +236,31 @@ func runDaemon() error { | |||
| 235 | 236 | // attach loop. Logging goes to the shared file: the client must never write | |
| 236 | 237 | // to the console while the terminal is raw. | |
| 237 | 238 | func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error { | |
| 238 | - | if os.Getenv("VEV") != "" { | |
| 239 | - | return errors.New("vev: sessions should be nested with care; unset VEV to force") | |
| 240 | - | } | |
| 241 | - | ||
| 242 | 239 | logFile, err := setupLogging() | |
| 243 | 240 | if err != nil { | |
| 244 | 241 | return err | |
| 245 | 242 | } | |
| 246 | 243 | defer func() { _ = logFile.Close() }() | |
| 247 | 244 | ||
| 245 | + | return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), runAttachDeps{ | |
| 246 | + | attachLocal: attachLocalDaemon, | |
| 247 | + | createDetached: createDetachedLocalSession, | |
| 248 | + | }) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | type runAttachDeps struct { | |
| 252 | + | attachLocal func(context.Context, uint8, string) error | |
| 253 | + | createDetached func(context.Context, string) error | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, deps runAttachDeps) error { | |
| 257 | + | if activeSession != "" { | |
| 258 | + | if remoteTarget == "" && intent == ports.IntentNew { | |
| 259 | + | return deps.createDetached(ctx, name) | |
| 260 | + | } | |
| 261 | + | return errors.New("vev: sessions should be nested with care; unset VEV to force") | |
| 262 | + | } | |
| 263 | + | ||
| 248 | 264 | if remoteTarget != "" { | |
| 249 | 265 | transport, err := sshstdio.Dial(remoteTarget, name) | |
| 250 | 266 | if err != nil { | |
| @@ -256,7 +272,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err | |||
| 256 | 272 | ||
| 257 | 273 | return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{ | |
| 258 | 274 | confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr), | |
| 259 | - | attach: attachLocalDaemon, | |
| 275 | + | attach: deps.attachLocal, | |
| 260 | 276 | killDaemon: requestDaemonStop, | |
| 261 | 277 | settleAfterKill: waitForDaemonStop, | |
| 262 | 278 | }) | |
| @@ -316,6 +332,62 @@ func attachLocalDaemon(ctx context.Context, intent uint8, name string) error { | |||
| 316 | 332 | return client.Attach(ctx, transport, term.New(), intent, name) | |
| 317 | 333 | } | |
| 318 | 334 | ||
| 335 | + | func createDetachedLocalSession(ctx context.Context, name string) error { | |
| 336 | + | transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |
| 337 | + | if err != nil { | |
| 338 | + | return err | |
| 339 | + | } | |
| 340 | + | defer func() { _ = transport.Close() }() | |
| 341 | + | ||
| 342 | + | cwd, err := os.Getwd() | |
| 343 | + | if err != nil { | |
| 344 | + | cwd = "" | |
| 345 | + | } | |
| 346 | + | hello := ports.Hello{ | |
| 347 | + | Version: ports.ProtocolVersion, | |
| 348 | + | Intent: ports.IntentNew, | |
| 349 | + | Name: name, | |
| 350 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 351 | + | TermEnv: os.Getenv("TERM"), | |
| 352 | + | Cwd: cwd, | |
| 353 | + | } | |
| 354 | + | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | |
| 355 | + | return fmt.Errorf("vev: creating detached session: %w", err) | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | type detachedReply struct { | |
| 359 | + | frame ports.Frame | |
| 360 | + | err error | |
| 361 | + | } | |
| 362 | + | replyCh := make(chan detachedReply, 1) | |
| 363 | + | go func() { | |
| 364 | + | f, err := transport.Recv() | |
| 365 | + | replyCh <- detachedReply{frame: f, err: err} | |
| 366 | + | }() | |
| 367 | + | ||
| 368 | + | select { | |
| 369 | + | case <-ctx.Done(): | |
| 370 | + | _ = transport.Close() | |
| 371 | + | return ctx.Err() | |
| 372 | + | case reply := <-replyCh: | |
| 373 | + | if reply.err != nil { | |
| 374 | + | return fmt.Errorf("vev: awaiting detached session creation: %w", reply.err) | |
| 375 | + | } | |
| 376 | + | switch reply.frame.Type { | |
| 377 | + | case ports.MsgWelcome: | |
| 378 | + | return nil | |
| 379 | + | case ports.MsgError: | |
| 380 | + | em, derr := ports.UnmarshalErrorMsg(reply.frame.Payload) | |
| 381 | + | if derr != nil { | |
| 382 | + | return fmt.Errorf("vev: decoding error reply: %w", derr) | |
| 383 | + | } | |
| 384 | + | return &client.ProtocolError{Code: em.Code, Text: em.Text} | |
| 385 | + | default: | |
| 386 | + | return fmt.Errorf("vev: unexpected reply type %d to detached session creation", reply.frame.Type) | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | ||
| 319 | 391 | func waitForDaemonStop(ctx context.Context) error { | |
| 320 | 392 | timer := time.NewTimer(daemonRestartSettle) | |
| 321 | 393 | defer timer.Stop() | |
| @@ -23,6 +23,7 @@ import ( | |||||
| 23 | "github.com/bnema/vev/internal/adapters/pty" | 23 | "github.com/bnema/vev/internal/adapters/pty" | ||
| 24 | "github.com/bnema/vev/internal/adapters/sshstdio" | 24 | "github.com/bnema/vev/internal/adapters/sshstdio" | ||
| 25 | "github.com/bnema/vev/internal/adapters/term" | 25 | "github.com/bnema/vev/internal/adapters/term" | ||
| 26 | + | "github.com/bnema/vev/internal/domain" | |||
| 26 | "github.com/bnema/vev/internal/ports" | 27 | "github.com/bnema/vev/internal/ports" | ||
| 27 | "github.com/bnema/vev/internal/usecase/client" | 28 | "github.com/bnema/vev/internal/usecase/client" | ||
| 28 | "github.com/bnema/vev/internal/usecase/confirm" | 29 | "github.com/bnema/vev/internal/usecase/confirm" | ||
| @@ -235,16 +236,31 @@ func runDaemon() error { | |||||
| 235 | // attach loop. Logging goes to the shared file: the client must never write | 236 | // attach loop. Logging goes to the shared file: the client must never write | ||
| 236 | // to the console while the terminal is raw. | 237 | // to the console while the terminal is raw. | ||
| 237 | func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error { | 238 | func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error { | ||
| 238 | - | if os.Getenv("VEV") != "" { | |||
| 239 | - | return errors.New("vev: sessions should be nested with care; unset VEV to force") | |||
| 240 | - | } | |||
| 241 | - | ||||
| 242 | logFile, err := setupLogging() | 239 | logFile, err := setupLogging() | ||
| 243 | if err != nil { | 240 | if err != nil { | ||
| 244 | return err | 241 | return err | ||
| 245 | } | 242 | } | ||
| 246 | defer func() { _ = logFile.Close() }() | 243 | defer func() { _ = logFile.Close() }() | ||
| 247 | 244 | ||||
| 245 | + | return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), runAttachDeps{ | |||
| 246 | + | attachLocal: attachLocalDaemon, | |||
| 247 | + | createDetached: createDetachedLocalSession, | |||
| 248 | + | }) | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | type runAttachDeps struct { | |||
| 252 | + | attachLocal func(context.Context, uint8, string) error | |||
| 253 | + | createDetached func(context.Context, string) error | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, deps runAttachDeps) error { | |||
| 257 | + | if activeSession != "" { | |||
| 258 | + | if remoteTarget == "" && intent == ports.IntentNew { | |||
| 259 | + | return deps.createDetached(ctx, name) | |||
| 260 | + | } | |||
| 261 | + | return errors.New("vev: sessions should be nested with care; unset VEV to force") | |||
| 262 | + | } | |||
| 263 | + | ||||
| 248 | if remoteTarget != "" { | 264 | if remoteTarget != "" { | ||
| 249 | transport, err := sshstdio.Dial(remoteTarget, name) | 265 | transport, err := sshstdio.Dial(remoteTarget, name) | ||
| 250 | if err != nil { | 266 | if err != nil { | ||
| @@ -256,7 +272,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err | |||||
| 256 | 272 | ||||
| 257 | return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{ | 273 | return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{ | ||
| 258 | confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr), | 274 | confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr), | ||
| 259 | - | attach: attachLocalDaemon, | 275 | + | attach: deps.attachLocal, |
| 260 | killDaemon: requestDaemonStop, | 276 | killDaemon: requestDaemonStop, | ||
| 261 | settleAfterKill: waitForDaemonStop, | 277 | settleAfterKill: waitForDaemonStop, | ||
| 262 | }) | 278 | }) | ||
| @@ -316,6 +332,62 @@ func attachLocalDaemon(ctx context.Context, intent uint8, name string) error { | |||||
| 316 | return client.Attach(ctx, transport, term.New(), intent, name) | 332 | return client.Attach(ctx, transport, term.New(), intent, name) | ||
| 317 | } | 333 | } | ||
| 318 | 334 | ||||
| 335 | + | func createDetachedLocalSession(ctx context.Context, name string) error { | |||
| 336 | + | transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |||
| 337 | + | if err != nil { | |||
| 338 | + | return err | |||
| 339 | + | } | |||
| 340 | + | defer func() { _ = transport.Close() }() | |||
| 341 | + | ||||
| 342 | + | cwd, err := os.Getwd() | |||
| 343 | + | if err != nil { | |||
| 344 | + | cwd = "" | |||
| 345 | + | } | |||
| 346 | + | hello := ports.Hello{ | |||
| 347 | + | Version: ports.ProtocolVersion, | |||
| 348 | + | Intent: ports.IntentNew, | |||
| 349 | + | Name: name, | |||
| 350 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |||
| 351 | + | TermEnv: os.Getenv("TERM"), | |||
| 352 | + | Cwd: cwd, | |||
| 353 | + | } | |||
| 354 | + | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | |||
| 355 | + | return fmt.Errorf("vev: creating detached session: %w", err) | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | type detachedReply struct { | |||
| 359 | + | frame ports.Frame | |||
| 360 | + | err error | |||
| 361 | + | } | |||
| 362 | + | replyCh := make(chan detachedReply, 1) | |||
| 363 | + | go func() { | |||
| 364 | + | f, err := transport.Recv() | |||
| 365 | + | replyCh <- detachedReply{frame: f, err: err} | |||
| 366 | + | }() | |||
| 367 | + | ||||
| 368 | + | select { | |||
| 369 | + | case <-ctx.Done(): | |||
| 370 | + | _ = transport.Close() | |||
| 371 | + | return ctx.Err() | |||
| 372 | + | case reply := <-replyCh: | |||
| 373 | + | if reply.err != nil { | |||
| 374 | + | return fmt.Errorf("vev: awaiting detached session creation: %w", reply.err) | |||
| 375 | + | } | |||
| 376 | + | switch reply.frame.Type { | |||
| 377 | + | case ports.MsgWelcome: | |||
| 378 | + | return nil | |||
| 379 | + | case ports.MsgError: | |||
| 380 | + | em, derr := ports.UnmarshalErrorMsg(reply.frame.Payload) | |||
| 381 | + | if derr != nil { | |||
| 382 | + | return fmt.Errorf("vev: decoding error reply: %w", derr) | |||
| 383 | + | } | |||
| 384 | + | return &client.ProtocolError{Code: em.Code, Text: em.Text} | |||
| 385 | + | default: | |||
| 386 | + | return fmt.Errorf("vev: unexpected reply type %d to detached session creation", reply.frame.Type) | |||
| 387 | + | } | |||
| 388 | + | } | |||
| 389 | + | } | |||
| 390 | + | ||||
| 319 | func waitForDaemonStop(ctx context.Context) error { | 391 | func waitForDaemonStop(ctx context.Context) error { | ||
| 320 | timer := time.NewTimer(daemonRestartSettle) | 392 | timer := time.NewTimer(daemonRestartSettle) | ||
| 321 | defer timer.Stop() | 393 | defer timer.Stop() | ||
M internal/app/run_test.go +34 -2
| @@ -222,12 +222,44 @@ func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) { | |||
| 222 | 222 | } | |
| 223 | 223 | ||
| 224 | 224 | func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) { | |
| 225 | - | t.Setenv("VEV", "outer") | |
| 226 | - | err := runAttach(context.Background(), ports.IntentEphemeral, "", "") | |
| 225 | + | called := false | |
| 226 | + | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", runAttachDeps{ | |
| 227 | + | attachLocal: func(context.Context, uint8, string) error { | |
| 228 | + | called = true | |
| 229 | + | return nil | |
| 230 | + | }, | |
| 231 | + | createDetached: func(context.Context, string) error { | |
| 232 | + | called = true | |
| 233 | + | return nil | |
| 234 | + | }, | |
| 235 | + | }) | |
| 227 | 236 | if err == nil { | |
| 228 | 237 | t.Fatal("runAttach with VEV set returned nil error") | |
| 229 | 238 | } | |
| 239 | + | if called { | |
| 240 | + | t.Fatal("runAttach dialed while nested attach should be rejected") | |
| 241 | + | } | |
| 230 | 242 | if !strings.Contains(err.Error(), "sessions should be nested with care") { | |
| 231 | 243 | t.Fatalf("runAttach error = %q, want nested-session warning", err) | |
| 232 | 244 | } | |
| 233 | 245 | } | |
| 246 | + | ||
| 247 | + | func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) { | |
| 248 | + | var gotName string | |
| 249 | + | err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", runAttachDeps{ | |
| 250 | + | attachLocal: func(context.Context, uint8, string) error { | |
| 251 | + | t.Fatal("nested new should not attach to the session") | |
| 252 | + | return nil | |
| 253 | + | }, | |
| 254 | + | createDetached: func(_ context.Context, name string) error { | |
| 255 | + | gotName = name | |
| 256 | + | return nil | |
| 257 | + | }, | |
| 258 | + | }) | |
| 259 | + | if err != nil { | |
| 260 | + | t.Fatalf("runAttachWithDeps returned error: %v", err) | |
| 261 | + | } | |
| 262 | + | if gotName != "scratch" { | |
| 263 | + | t.Fatalf("detached name = %q, want scratch", gotName) | |
| 264 | + | } | |
| 265 | + | } | |
| @@ -222,12 +222,44 @@ func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) { | |||||
| 222 | } | 222 | } | ||
| 223 | 223 | ||||
| 224 | func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) { | 224 | func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) { | ||
| 225 | - | t.Setenv("VEV", "outer") | 225 | + | called := false |
| 226 | - | err := runAttach(context.Background(), ports.IntentEphemeral, "", "") | 226 | + | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", runAttachDeps{ |
| 227 | + | attachLocal: func(context.Context, uint8, string) error { | |||
| 228 | + | called = true | |||
| 229 | + | return nil | |||
| 230 | + | }, | |||
| 231 | + | createDetached: func(context.Context, string) error { | |||
| 232 | + | called = true | |||
| 233 | + | return nil | |||
| 234 | + | }, | |||
| 235 | + | }) | |||
| 227 | if err == nil { | 236 | if err == nil { | ||
| 228 | t.Fatal("runAttach with VEV set returned nil error") | 237 | t.Fatal("runAttach with VEV set returned nil error") | ||
| 229 | } | 238 | } | ||
| 239 | + | if called { | |||
| 240 | + | t.Fatal("runAttach dialed while nested attach should be rejected") | |||
| 241 | + | } | |||
| 230 | if !strings.Contains(err.Error(), "sessions should be nested with care") { | 242 | if !strings.Contains(err.Error(), "sessions should be nested with care") { | ||
| 231 | t.Fatalf("runAttach error = %q, want nested-session warning", err) | 243 | t.Fatalf("runAttach error = %q, want nested-session warning", err) | ||
| 232 | } | 244 | } | ||
| 233 | } | 245 | } | ||
| 246 | + | ||||
| 247 | + | func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) { | |||
| 248 | + | var gotName string | |||
| 249 | + | err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", runAttachDeps{ | |||
| 250 | + | attachLocal: func(context.Context, uint8, string) error { | |||
| 251 | + | t.Fatal("nested new should not attach to the session") | |||
| 252 | + | return nil | |||
| 253 | + | }, | |||
| 254 | + | createDetached: func(_ context.Context, name string) error { | |||
| 255 | + | gotName = name | |||
| 256 | + | return nil | |||
| 257 | + | }, | |||
| 258 | + | }) | |||
| 259 | + | if err != nil { | |||
| 260 | + | t.Fatalf("runAttachWithDeps returned error: %v", err) | |||
| 261 | + | } | |||
| 262 | + | if gotName != "scratch" { | |||
| 263 | + | t.Fatalf("detached name = %q, want scratch", gotName) | |||
| 264 | + | } | |||
| 265 | + | } | |||
M internal/usecase/command/command.go +1 -0
| @@ -3,6 +3,7 @@ package command | |||
| 3 | 3 | // Context is the set of application actions available to commands. | |
| 4 | 4 | type Context interface { | |
| 5 | 5 | CreateTab() error | |
| 6 | + | CreateSession() error | |
| 6 | 7 | CloseTab() error | |
| 7 | 8 | NextTab() error | |
| 8 | 9 | PrevTab() error | |
| @@ -3,6 +3,7 @@ package command | |||||
| 3 | // Context is the set of application actions available to commands. | 3 | // Context is the set of application actions available to commands. | ||
| 4 | type Context interface { | 4 | type Context interface { | ||
| 5 | CreateTab() error | 5 | CreateTab() error | ||
| 6 | + | CreateSession() error | |||
| 6 | CloseTab() error | 7 | CloseTab() error | ||
| 7 | NextTab() error | 8 | NextTab() error | ||
| 8 | PrevTab() error | 9 | PrevTab() error | ||
M internal/usecase/command/registry.go +1 -0
| @@ -6,6 +6,7 @@ import "strings" | |||
| 6 | 6 | func Registry() []Command { | |
| 7 | 7 | commands := []Command{ | |
| 8 | 8 | {Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }}, | |
| 9 | + | {Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }}, | |
| 9 | 10 | {Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }}, | |
| 10 | 11 | {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }}, | |
| 11 | 12 | {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }}, | |
| @@ -6,6 +6,7 @@ import "strings" | |||||
| 6 | func Registry() []Command { | 6 | func Registry() []Command { | ||
| 7 | commands := []Command{ | 7 | commands := []Command{ | ||
| 8 | {Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }}, | 8 | {Code: "CNT", Name: "New tab", Desc: "Create a new tab", Run: func(ctx Context) error { return ctx.CreateTab() }}, | ||
| 9 | + | {Code: "CNS", Name: "New session", Desc: "Create and switch to a named session", Run: func(ctx Context) error { return ctx.CreateSession() }}, | |||
| 9 | {Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }}, | 10 | {Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }}, | ||
| 10 | {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }}, | 11 | {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }}, | ||
| 11 | {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }}, | 12 | {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }}, | ||
M internal/usecase/command/registry_test.go +3 -1
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | |
| 10 | 10 | commands := Registry() | |
| 11 | - | wantCodes := []string{"CNT", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"} | |
| 11 | + | wantCodes := []string{"CNT", "CNS", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"} | |
| 12 | 12 | ||
| 13 | 13 | if len(commands) != len(wantCodes) { | |
| 14 | 14 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | |
| @@ -52,6 +52,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||
| 52 | 52 | want string | |
| 53 | 53 | }{ | |
| 54 | 54 | {code: "CNT", want: "CreateTab"}, | |
| 55 | + | {code: "CNS", want: "CreateSession"}, | |
| 55 | 56 | {code: "CLT", want: "CloseTab"}, | |
| 56 | 57 | {code: "NXT", want: "NextTab"}, | |
| 57 | 58 | {code: "PVT", want: "PrevTab"}, | |
| @@ -96,6 +97,7 @@ func (s *spyContext) onlyCall() string { | |||
| 96 | 97 | } | |
| 97 | 98 | ||
| 98 | 99 | func (s *spyContext) CreateTab() error { return s.record("CreateTab") } | |
| 100 | + | func (s *spyContext) CreateSession() error { return s.record("CreateSession") } | |
| 99 | 101 | func (s *spyContext) CloseTab() error { return s.record("CloseTab") } | |
| 100 | 102 | func (s *spyContext) NextTab() error { return s.record("NextTab") } | |
| 101 | 103 | func (s *spyContext) PrevTab() error { return s.record("PrevTab") } | |
| @@ -8,7 +8,7 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | 9 | func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | ||
| 10 | commands := Registry() | 10 | commands := Registry() | ||
| 11 | - | wantCodes := []string{"CNT", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"} | 11 | + | wantCodes := []string{"CNT", "CNS", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"} |
| 12 | 12 | ||||
| 13 | if len(commands) != len(wantCodes) { | 13 | if len(commands) != len(wantCodes) { | ||
| 14 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | 14 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | ||
| @@ -52,6 +52,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||||
| 52 | want string | 52 | want string | ||
| 53 | }{ | 53 | }{ | ||
| 54 | {code: "CNT", want: "CreateTab"}, | 54 | {code: "CNT", want: "CreateTab"}, | ||
| 55 | + | {code: "CNS", want: "CreateSession"}, | |||
| 55 | {code: "CLT", want: "CloseTab"}, | 56 | {code: "CLT", want: "CloseTab"}, | ||
| 56 | {code: "NXT", want: "NextTab"}, | 57 | {code: "NXT", want: "NextTab"}, | ||
| 57 | {code: "PVT", want: "PrevTab"}, | 58 | {code: "PVT", want: "PrevTab"}, | ||
| @@ -96,6 +97,7 @@ func (s *spyContext) onlyCall() string { | |||||
| 96 | } | 97 | } | ||
| 97 | 98 | ||||
| 98 | func (s *spyContext) CreateTab() error { return s.record("CreateTab") } | 99 | func (s *spyContext) CreateTab() error { return s.record("CreateTab") } | ||
| 100 | + | func (s *spyContext) CreateSession() error { return s.record("CreateSession") } | |||
| 99 | func (s *spyContext) CloseTab() error { return s.record("CloseTab") } | 101 | func (s *spyContext) CloseTab() error { return s.record("CloseTab") } | ||
| 100 | func (s *spyContext) NextTab() error { return s.record("NextTab") } | 102 | func (s *spyContext) NextTab() error { return s.record("NextTab") } | ||
| 101 | func (s *spyContext) PrevTab() error { return s.record("PrevTab") } | 103 | func (s *spyContext) PrevTab() error { return s.record("PrevTab") } | ||
M internal/usecase/daemon/palette.go +7 -0
| @@ -101,6 +101,13 @@ func (e paletteExec) CreateTab() error { | |||
| 101 | 101 | return e.d.createTab(e.sess, e.ac.size) | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | + | func (e paletteExec) CreateSession() error { | |
| 105 | + | e.d.enterPrompt(e.sess, e.ac, " Create session ", "", func(name string) error { | |
| 106 | + | return e.d.createSessionAndSwitch(e.sess, e.ac, name) | |
| 107 | + | }) | |
| 108 | + | return nil | |
| 109 | + | } | |
| 110 | + | ||
| 104 | 111 | func (e paletteExec) CloseTab() error { | |
| 105 | 112 | if tb := e.sess.activeTab(); tb != nil { | |
| 106 | 113 | e.d.closeTab(e.sess, tb, true) | |
| @@ -101,6 +101,13 @@ func (e paletteExec) CreateTab() error { | |||||
| 101 | return e.d.createTab(e.sess, e.ac.size) | 101 | return e.d.createTab(e.sess, e.ac.size) | ||
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | + | func (e paletteExec) CreateSession() error { | |||
| 105 | + | e.d.enterPrompt(e.sess, e.ac, " Create session ", "", func(name string) error { | |||
| 106 | + | return e.d.createSessionAndSwitch(e.sess, e.ac, name) | |||
| 107 | + | }) | |||
| 108 | + | return nil | |||
| 109 | + | } | |||
| 110 | + | ||||
| 104 | func (e paletteExec) CloseTab() error { | 111 | func (e paletteExec) CloseTab() error { | ||
| 105 | if tb := e.sess.activeTab(); tb != nil { | 112 | if tb := e.sess.activeTab(); tb != nil { | ||
| 106 | e.d.closeTab(e.sess, tb, true) | 113 | e.d.closeTab(e.sess, tb, true) | ||
M internal/usecase/daemon/palette_test.go +37 -1
| @@ -40,6 +40,42 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |||
| 40 | 40 | awaitFrame(t, sends, ports.MsgOutput) | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | + | func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) { | |
| 44 | + | p1, release1 := newBlockingPTY(t) | |
| 45 | + | p2, release2 := newBlockingPTY(t) | |
| 46 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p1) | |
| 47 | + | defer release1() | |
| 48 | + | defer release2() | |
| 49 | + | ptys := portsmocks.NewMockPTYFactory(t) | |
| 50 | + | ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p2, nil).Once() | |
| 51 | + | d.ptys = ptys | |
| 52 | + | ||
| 53 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 54 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 55 | + | d.handleInput(sess, ac, []byte("CNS\r")) | |
| 56 | + | promptFrame := awaitFrame(t, sends, ports.MsgOutput) | |
| 57 | + | promptOutput, err := ports.UnmarshalOutput(promptFrame.Payload) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | require.False(t, ac.paletteActive()) | |
| 60 | + | require.True(t, ac.promptActive()) | |
| 61 | + | require.Contains(t, string(promptOutput.Data), "Create session") | |
| 62 | + | ||
| 63 | + | d.handleInput(sess, ac, []byte("scratch\r")) | |
| 64 | + | repaint := awaitFrame(t, sends, ports.MsgOutput) | |
| 65 | + | repaintOutput, err := ports.UnmarshalOutput(repaint.Payload) | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | require.False(t, ac.promptActive()) | |
| 68 | + | require.Equal(t, 2, sessionCount(d)) | |
| 69 | + | require.Nil(t, sess.client) | |
| 70 | + | newSess := ac.currentSession() | |
| 71 | + | require.NotNil(t, newSess) | |
| 72 | + | require.NotSame(t, sess, newSess) | |
| 73 | + | require.Equal(t, "scratch", newSess.name) | |
| 74 | + | require.False(t, newSess.ephemeral) | |
| 75 | + | require.Same(t, ac, newSess.client) | |
| 76 | + | require.Contains(t, string(repaintOutput.Data), "scratch") | |
| 77 | + | } | |
| 78 | + | ||
| 43 | 79 | func TestPaletteCommandNoopRepaintsAfterClose(t *testing.T) { | |
| 44 | 80 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 45 | 81 | defer releases[0]() | |
| @@ -106,7 +142,7 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) { | |||
| 106 | 142 | awaitFrame(t, sends, ports.MsgOutput) | |
| 107 | 143 | cmd, ok := ac.palette.Selected() | |
| 108 | 144 | require.True(t, ok) | |
| 109 | - | require.Equal(t, "CLT", cmd.Code) | |
| 145 | + | require.Equal(t, "CNS", cmd.Code) | |
| 110 | 146 | ||
| 111 | 147 | d.handlePaletteInput(ac, []byte{0x10}) | |
| 112 | 148 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -40,6 +40,42 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) { | |||||
| 40 | awaitFrame(t, sends, ports.MsgOutput) | 40 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 41 | } | 41 | } | ||
| 42 | 42 | ||||
| 43 | + | func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) { | |||
| 44 | + | p1, release1 := newBlockingPTY(t) | |||
| 45 | + | p2, release2 := newBlockingPTY(t) | |||
| 46 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p1) | |||
| 47 | + | defer release1() | |||
| 48 | + | defer release2() | |||
| 49 | + | ptys := portsmocks.NewMockPTYFactory(t) | |||
| 50 | + | ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p2, nil).Once() | |||
| 51 | + | d.ptys = ptys | |||
| 52 | + | ||||
| 53 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 54 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 55 | + | d.handleInput(sess, ac, []byte("CNS\r")) | |||
| 56 | + | promptFrame := awaitFrame(t, sends, ports.MsgOutput) | |||
| 57 | + | promptOutput, err := ports.UnmarshalOutput(promptFrame.Payload) | |||
| 58 | + | require.NoError(t, err) | |||
| 59 | + | require.False(t, ac.paletteActive()) | |||
| 60 | + | require.True(t, ac.promptActive()) | |||
| 61 | + | require.Contains(t, string(promptOutput.Data), "Create session") | |||
| 62 | + | ||||
| 63 | + | d.handleInput(sess, ac, []byte("scratch\r")) | |||
| 64 | + | repaint := awaitFrame(t, sends, ports.MsgOutput) | |||
| 65 | + | repaintOutput, err := ports.UnmarshalOutput(repaint.Payload) | |||
| 66 | + | require.NoError(t, err) | |||
| 67 | + | require.False(t, ac.promptActive()) | |||
| 68 | + | require.Equal(t, 2, sessionCount(d)) | |||
| 69 | + | require.Nil(t, sess.client) | |||
| 70 | + | newSess := ac.currentSession() | |||
| 71 | + | require.NotNil(t, newSess) | |||
| 72 | + | require.NotSame(t, sess, newSess) | |||
| 73 | + | require.Equal(t, "scratch", newSess.name) | |||
| 74 | + | require.False(t, newSess.ephemeral) | |||
| 75 | + | require.Same(t, ac, newSess.client) | |||
| 76 | + | require.Contains(t, string(repaintOutput.Data), "scratch") | |||
| 77 | + | } | |||
| 78 | + | ||||
| 43 | func TestPaletteCommandNoopRepaintsAfterClose(t *testing.T) { | 79 | func TestPaletteCommandNoopRepaintsAfterClose(t *testing.T) { | ||
| 44 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | 80 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | ||
| 45 | defer releases[0]() | 81 | defer releases[0]() | ||
| @@ -106,7 +142,7 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) { | |||||
| 106 | awaitFrame(t, sends, ports.MsgOutput) | 142 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 107 | cmd, ok := ac.palette.Selected() | 143 | cmd, ok := ac.palette.Selected() | ||
| 108 | require.True(t, ok) | 144 | require.True(t, ok) | ||
| 109 | - | require.Equal(t, "CLT", cmd.Code) | 145 | + | require.Equal(t, "CNS", cmd.Code) |
| 110 | 146 | ||||
| 111 | d.handlePaletteInput(ac, []byte{0x10}) | 147 | d.handlePaletteInput(ac, []byte{0x10}) | ||
| 112 | awaitFrame(t, sends, ports.MsgOutput) | 148 | awaitFrame(t, sends, ports.MsgOutput) | ||
M internal/usecase/daemon/session.go +49 -0
| @@ -108,6 +108,55 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 108 | 108 | return sess, nil | |
| 109 | 109 | } | |
| 110 | 110 | ||
| 111 | + | func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name string) error { | |
| 112 | + | if name == "" { | |
| 113 | + | return errors.New("name required") | |
| 114 | + | } | |
| 115 | + | sz := ac.size | |
| 116 | + | d.mu.Lock() | |
| 117 | + | if d.closing { | |
| 118 | + | d.mu.Unlock() | |
| 119 | + | return errors.New("daemon is shutting down") | |
| 120 | + | } | |
| 121 | + | if d.nameTakenLocked(name) { | |
| 122 | + | d.mu.Unlock() | |
| 123 | + | return errors.New("name already in use") | |
| 124 | + | } | |
| 125 | + | from.mu.Lock() | |
| 126 | + | cwd := from.cwd | |
| 127 | + | if from.client != ac { | |
| 128 | + | from.mu.Unlock() | |
| 129 | + | d.mu.Unlock() | |
| 130 | + | return errors.New("client detached") | |
| 131 | + | } | |
| 132 | + | from.mu.Unlock() | |
| 133 | + | ||
| 134 | + | newSess, err := d.createSessionLocked(name, false, cwd, sz) | |
| 135 | + | if err != nil { | |
| 136 | + | d.mu.Unlock() | |
| 137 | + | return err | |
| 138 | + | } | |
| 139 | + | from.mu.Lock() | |
| 140 | + | if from.client != ac { | |
| 141 | + | from.mu.Unlock() | |
| 142 | + | d.mu.Unlock() | |
| 143 | + | d.killSession(newSess, ports.ReasonSessionKilled) | |
| 144 | + | return errors.New("client detached") | |
| 145 | + | } | |
| 146 | + | from.client = nil | |
| 147 | + | ac.setSession(nil) | |
| 148 | + | from.mu.Unlock() | |
| 149 | + | ||
| 150 | + | newSess.mu.Lock() | |
| 151 | + | newSess.client = ac | |
| 152 | + | newSess.mu.Unlock() | |
| 153 | + | ac.setSession(newSess) | |
| 154 | + | d.mu.Unlock() | |
| 155 | + | ||
| 156 | + | d.firstPaint(newSess, ac, sz) | |
| 157 | + | return nil | |
| 158 | + | } | |
| 159 | + | ||
| 111 | 160 | func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |
| 112 | 161 | tbSize := tabSize(sz) | |
| 113 | 162 | sess.mu.Lock() | |
| @@ -108,6 +108,55 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||||
| 108 | return sess, nil | 108 | return sess, nil | ||
| 109 | } | 109 | } | ||
| 110 | 110 | ||||
| 111 | + | func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name string) error { | |||
| 112 | + | if name == "" { | |||
| 113 | + | return errors.New("name required") | |||
| 114 | + | } | |||
| 115 | + | sz := ac.size | |||
| 116 | + | d.mu.Lock() | |||
| 117 | + | if d.closing { | |||
| 118 | + | d.mu.Unlock() | |||
| 119 | + | return errors.New("daemon is shutting down") | |||
| 120 | + | } | |||
| 121 | + | if d.nameTakenLocked(name) { | |||
| 122 | + | d.mu.Unlock() | |||
| 123 | + | return errors.New("name already in use") | |||
| 124 | + | } | |||
| 125 | + | from.mu.Lock() | |||
| 126 | + | cwd := from.cwd | |||
| 127 | + | if from.client != ac { | |||
| 128 | + | from.mu.Unlock() | |||
| 129 | + | d.mu.Unlock() | |||
| 130 | + | return errors.New("client detached") | |||
| 131 | + | } | |||
| 132 | + | from.mu.Unlock() | |||
| 133 | + | ||||
| 134 | + | newSess, err := d.createSessionLocked(name, false, cwd, sz) | |||
| 135 | + | if err != nil { | |||
| 136 | + | d.mu.Unlock() | |||
| 137 | + | return err | |||
| 138 | + | } | |||
| 139 | + | from.mu.Lock() | |||
| 140 | + | if from.client != ac { | |||
| 141 | + | from.mu.Unlock() | |||
| 142 | + | d.mu.Unlock() | |||
| 143 | + | d.killSession(newSess, ports.ReasonSessionKilled) | |||
| 144 | + | return errors.New("client detached") | |||
| 145 | + | } | |||
| 146 | + | from.client = nil | |||
| 147 | + | ac.setSession(nil) | |||
| 148 | + | from.mu.Unlock() | |||
| 149 | + | ||||
| 150 | + | newSess.mu.Lock() | |||
| 151 | + | newSess.client = ac | |||
| 152 | + | newSess.mu.Unlock() | |||
| 153 | + | ac.setSession(newSess) | |||
| 154 | + | d.mu.Unlock() | |||
| 155 | + | ||||
| 156 | + | d.firstPaint(newSess, ac, sz) | |||
| 157 | + | return nil | |||
| 158 | + | } | |||
| 159 | + | ||||
| 111 | func (d *Daemon) createTab(sess *session, sz domain.Size) error { | 160 | func (d *Daemon) createTab(sess *session, sz domain.Size) error { | ||
| 112 | tbSize := tabSize(sz) | 161 | tbSize := tabSize(sz) | ||
| 113 | sess.mu.Lock() | 162 | sess.mu.Lock() | ||