detached b60ef98

feat(session): create sessions from inside vev

commit
b60ef98e9b96eb0dbc96aa5a1f26de4780f44f09
author
brice · · 2026-07-03 16:20 UTC
parent
151010a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
5858 | Code | Action |
5959 |---|---|
6060 | CNT | create new tab |
61+| CNS | create and switch to a named session |
6162 | CLT | close current tab |
6263 | NXT | switch to next tab |
6364 | 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 (
2323 "github.com/bnema/vev/internal/adapters/pty"
2424 "github.com/bnema/vev/internal/adapters/sshstdio"
2525 "github.com/bnema/vev/internal/adapters/term"
26+ "github.com/bnema/vev/internal/domain"
2627 "github.com/bnema/vev/internal/ports"
2728 "github.com/bnema/vev/internal/usecase/client"
2829 "github.com/bnema/vev/internal/usecase/confirm"
@@ -235,16 +236,31 @@ func runDaemon() error {
235236 // attach loop. Logging goes to the shared file: the client must never write
236237 // to the console while the terminal is raw.
237238 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-
242239 logFile, err := setupLogging()
243240 if err != nil {
244241 return err
245242 }
246243 defer func() { _ = logFile.Close() }()
247244
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+
248264 if remoteTarget != "" {
249265 transport, err := sshstdio.Dial(remoteTarget, name)
250266 if err != nil {
@@ -256,7 +272,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
256272
257273 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
258274 confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),
259- attach: attachLocalDaemon,
275+ attach: deps.attachLocal,
260276 killDaemon: requestDaemonStop,
261277 settleAfterKill: waitForDaemonStop,
262278 })
@@ -316,6 +332,62 @@ func attachLocalDaemon(ctx context.Context, intent uint8, name string) error {
316332 return client.Attach(ctx, transport, term.New(), intent, name)
317333 }
318334
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+
319391 func waitForDaemonStop(ctx context.Context) error {
320392 timer := time.NewTimer(daemonRestartSettle)
321393 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 write236 // 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 err241 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) {
222222 }
223223
224224 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+ })
227236 if err == nil {
228237 t.Fatal("runAttach with VEV set returned nil error")
229238 }
239+ if called {
240+ t.Fatal("runAttach dialed while nested attach should be rejected")
241+ }
230242 if !strings.Contains(err.Error(), "sessions should be nested with care") {
231243 t.Fatalf("runAttach error = %q, want nested-session warning", err)
232244 }
233245 }
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
33 // Context is the set of application actions available to commands.
44 type Context interface {
55 CreateTab() error
6+ CreateSession() error
67 CloseTab() error
78 NextTab() error
89 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() error5 CreateTab() error
6+ CreateSession() error
6 CloseTab() error7 CloseTab() error
7 NextTab() error8 NextTab() error
8 PrevTab() error9 PrevTab() error
M internal/usecase/command/registry.go +1 -0
@@ -6,6 +6,7 @@ import "strings"
66 func Registry() []Command {
77 commands := []Command{
88 {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() }},
910 {Code: "CLT", Name: "Close tab", Desc: "Close the current tab", Run: func(ctx Context) error { return ctx.CloseTab() }},
1011 {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }},
1112 {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 (
88
99 func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {
1010 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"}
1212
1313 if len(commands) != len(wantCodes) {
1414 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -52,6 +52,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
5252 want string
5353 }{
5454 {code: "CNT", want: "CreateTab"},
55+ {code: "CNS", want: "CreateSession"},
5556 {code: "CLT", want: "CloseTab"},
5657 {code: "NXT", want: "NextTab"},
5758 {code: "PVT", want: "PrevTab"},
@@ -96,6 +97,7 @@ func (s *spyContext) onlyCall() string {
9697 }
9798
9899 func (s *spyContext) CreateTab() error { return s.record("CreateTab") }
100+func (s *spyContext) CreateSession() error { return s.record("CreateSession") }
99101 func (s *spyContext) CloseTab() error { return s.record("CloseTab") }
100102 func (s *spyContext) NextTab() error { return s.record("NextTab") }
101103 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 string52 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 {
101101 return e.d.createTab(e.sess, e.ac.size)
102102 }
103103
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+
104111 func (e paletteExec) CloseTab() error {
105112 if tb := e.sess.activeTab(); tb != nil {
106113 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) {
4040 awaitFrame(t, sends, ports.MsgOutput)
4141 }
4242
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+
4379 func TestPaletteCommandNoopRepaintsAfterClose(t *testing.T) {
4480 d, sess, ac, sends, releases := newManualTabSession(t, 1)
4581 defer releases[0]()
@@ -106,7 +142,7 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) {
106142 awaitFrame(t, sends, ports.MsgOutput)
107143 cmd, ok := ac.palette.Selected()
108144 require.True(t, ok)
109- require.Equal(t, "CLT", cmd.Code)
145+ require.Equal(t, "CNS", cmd.Code)
110146
111147 d.handlePaletteInput(ac, []byte{0x10})
112148 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
108108 return sess, nil
109109 }
110110
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+
111160 func (d *Daemon) createTab(sess *session, sz domain.Size) error {
112161 tbSize := tabSize(sz)
113162 sess.mu.Lock()
@@ -108,6 +108,55 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
108 return sess, nil108 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()

Keyboard shortcuts

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

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

Branches 2

Tags 13

↑↓ moveenter openesc close