diff --git a/.gitignore b/.gitignore index 209b0ce..28d187c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ flued /site/src/routeTree.gen.ts # Wrangler's local cache from site deploys. /site/.wrangler/ +# tsc --noEmit with incremental drops this beside web/tsconfig.json. +web/tsconfig.tsbuildinfo diff --git a/internal/daemon/conn.go b/internal/daemon/conn.go index 7b28219..c0c0536 100644 --- a/internal/daemon/conn.go +++ b/internal/daemon/conn.go @@ -64,6 +64,13 @@ const ( peekMaxBytes = 128 << 10 ) +// capMultiplex is the welcome capability that says this daemon understands +// session groups and ephemeral sessions — the optional spawn and update +// fields added for the in-session multiplexer. Feature detection, not +// negotiation: the fields are additive either way, the cap only tells a +// client whether the affordances are worth drawing. +const capMultiplex = "multiplex" + var ( errConnClosed = errors.New("daemon: connection closed") errConnBacklogged = errors.New("daemon: client is not draining its socket") @@ -445,6 +452,12 @@ func (c *conn) serve() { DaemonID: "local", Host: c.srv.hostname, Ver: c.srv.version, + // What this daemon can do beyond the base protocol, for a client to + // feature-detect on. "multiplex" says spawn accepts group and + // ephemeral, and update accepts ephemeral: a client talking to a + // daemon that does not say it hides the split and scratch affordances + // rather than spawning sessions whose extra fields silently dropped. + Caps: []string{capMultiplex}, // Read here, once, at the moment this connection opens. The status is // not a stream — nothing pushes an update when the relay reconnects — // so what a client holds is what was true when it arrived, which is @@ -591,7 +604,7 @@ func (c *conn) handleControl(msg any) { // spend that distinction on the way past. wire.Update and // session.MetaPatch have the same shape for exactly this reason. if _, err := c.srv.reg.UpdateMeta(m.ID, session.MetaPatch{ - Name: m.Name, Tags: m.Tags, Pinned: m.Pinned, + Name: m.Name, Tags: m.Tags, Pinned: m.Pinned, Ephemeral: m.Ephemeral, }); err != nil { // The only thing UpdateMeta refuses is an id it does not hold, and a // client editing a session that has just exited and been reaped is @@ -682,6 +695,7 @@ func (c *conn) handleControl(msg any) { case wire.Spawn: s, err := c.srv.reg.Spawn(session.SpawnOpts{ Cwd: m.Cwd, Cmd: m.Cmd, Cols: m.Cols, Rows: m.Rows, + Group: m.Group, Ephemeral: m.Ephemeral, }) if err != nil { c.sendErrorFor(m.ReqID, "spawn_failed", err.Error()) diff --git a/internal/daemon/multiplex_test.go b/internal/daemon/multiplex_test.go new file mode 100644 index 0000000..e762704 --- /dev/null +++ b/internal/daemon/multiplex_test.go @@ -0,0 +1,128 @@ +package daemon + +import ( + "slices" + "testing" + + "github.com/karnstack/flue/internal/session" + "github.com/karnstack/flue/internal/wire" +) + +// TestWelcomeAnnouncesMultiplex pins the capability the web client +// feature-detects the split and scratch affordances on. Dropping it from the +// welcome silently hides both from every client, which is why it is a test +// and not just a literal in conn.go. +func TestWelcomeAnnouncesMultiplex(t *testing.T) { + ts, _ := newTestServer(t) + c := dial(t, ts) + + readUntil(t, c, func(msg any, _ []byte) bool { + w, ok := msg.(wire.Welcome) + if !ok { + return false + } + if !slices.Contains(w.Caps, "multiplex") { + t.Fatalf("welcome caps = %v, want to contain %q", w.Caps, "multiplex") + } + return true + }) +} + +// TestSpawnCarriesGroupAndEphemeralOverTheWire: the optional spawn fields +// reach the registry, and the session list hands them back — which is how a +// group view learns its members and a list knows what to fold away. +func TestSpawnCarriesGroupAndEphemeralOverTheWire(t *testing.T) { + ts, reg := newTestServer(t) + anchor, err := reg.Spawn(session.SpawnOpts{Cmd: []string{"sleep", "2"}, Cols: 80, Rows: 24}) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + defer anchor.Close() + + c := dial(t, ts) + writeControl(t, c, wire.Hello{Ver: "test"}) + writeControl(t, c, wire.Spawn{ + Cmd: []string{"sleep", "2"}, Cols: 80, Rows: 24, + Group: anchor.ID(), Ephemeral: true, ReqID: 5, + }) + + var spawned string + readUntil(t, c, func(msg any, _ []byte) bool { + a, ok := msg.(wire.Attached) + if !ok || a.ReqID != 5 { + return false + } + spawned = a.ID + return true + }) + + s, ok := reg.Get(spawned) + if !ok { + t.Fatalf("spawned session %q not in the registry", spawned) + } + defer s.Close() + info := s.Info() + if info.Group != anchor.ID() { + t.Errorf("Group = %q, want %q", info.Group, anchor.ID()) + } + if !info.Ephemeral { + t.Error("Ephemeral = false, want true") + } + + // And the list reports what the spawn declared. + writeControl(t, c, wire.List{}) + readUntil(t, c, func(msg any, _ []byte) bool { + l, ok := msg.(wire.Sessions) + if !ok { + return false + } + for _, row := range l.Sessions { + if row.ID == spawned { + if row.Group != anchor.ID() || !row.Ephemeral { + t.Errorf("listed row group=%q ephemeral=%v, want %q true", + row.Group, row.Ephemeral, anchor.ID()) + } + return true + } + } + return false + }) +} + +// TestUpdateClearsEphemeralOverTheWire is the wire half of the keep +// affordance: an update carrying ephemeral=false promotes the scratch, and +// one carrying no ephemeral field leaves the flag alone. +func TestUpdateClearsEphemeralOverTheWire(t *testing.T) { + ts, reg := newTestServer(t) + anchor, err := reg.Spawn(session.SpawnOpts{Cmd: []string{"sleep", "2"}, Cols: 80, Rows: 24}) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + defer anchor.Close() + scratch, err := reg.Spawn(session.SpawnOpts{ + Cmd: []string{"sleep", "2"}, Cols: 80, Rows: 24, + Group: anchor.ID(), Ephemeral: true, + }) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + defer scratch.Close() + + c := dial(t, ts) + writeControl(t, c, wire.Hello{Ver: "test"}) + + // An edit about something else must not touch the flag. + name := "kept name" + writeControl(t, c, wire.Update{ID: scratch.ID(), Name: &name}) + readUntil(t, c, func(msg any, _ []byte) bool { _, ok := msg.(wire.Sessions); return ok }) + if info := scratch.Info(); !info.Ephemeral || info.Name != name { + t.Fatalf("after a name edit: ephemeral=%v name=%q, want true %q", info.Ephemeral, info.Name, name) + } + + kept := false + writeControl(t, c, wire.Update{ID: scratch.ID(), Ephemeral: &kept}) + readUntil(t, c, func(msg any, _ []byte) bool { _, ok := msg.(wire.Sessions); return ok }) + if info := scratch.Info(); info.Ephemeral { + t.Fatal("Ephemeral still true after the clearing update") + } +} diff --git a/internal/session/multiplex_test.go b/internal/session/multiplex_test.go new file mode 100644 index 0000000..b6e2350 --- /dev/null +++ b/internal/session/multiplex_test.go @@ -0,0 +1,210 @@ +package session + +import ( + "testing" + "time" +) + +// TestSpawnCarriesGroupAndEphemeral pins the two new SpawnOpts fields onto +// Info, and — the half that guards every session that exists today — that a +// spawn naming neither reports neither. +func TestSpawnCarriesGroupAndEphemeral(t *testing.T) { + r := NewRegistry(nil) + anchor := spawnRunning(t, r) + + member, err := r.Spawn(SpawnOpts{ + Cmd: []string{"sleep", "5"}, Cols: 80, Rows: 24, + Group: anchor.ID(), Ephemeral: true, + }) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + t.Cleanup(func() { _ = member.Close() }) + + info := member.Info() + if info.Group != anchor.ID() { + t.Errorf("Group = %q, want %q", info.Group, anchor.ID()) + } + if !info.Ephemeral { + t.Error("Ephemeral = false, want true") + } + + plain := anchor.Info() + if plain.Group != "" || plain.Ephemeral { + t.Errorf("plain session carries group=%q ephemeral=%v, want neither", plain.Group, plain.Ephemeral) + } +} + +// TestEphemeralExitedReapedFast pins the retention split: an exited scratch +// terminal leaves the registry after EphemeralRetention, while an ordinary +// exited session beside it waits out the full ExitedRetention. +func TestEphemeralExitedReapedFast(t *testing.T) { + now := time.Now() + r := NewRegistry(func() time.Time { return now }) + + plain, err := r.Spawn(SpawnOpts{Cmd: []string{"sh", "-c", "exit 0"}, Cols: 80, Rows: 24}) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + scratch, err := r.Spawn(SpawnOpts{ + Cmd: []string{"sh", "-c", "exit 0"}, Cols: 80, Rows: 24, Ephemeral: true, + }) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + waitExited(t, plain, 5*time.Second) + waitExited(t, scratch, 5*time.Second) + + now = now.Add(EphemeralRetention + time.Second) + r.Reap() + if _, ok := r.Get(scratch.ID()); ok { + t.Error("ephemeral session still present past EphemeralRetention") + } + if _, ok := r.Get(plain.ID()); !ok { + t.Error("ordinary session reaped on the ephemeral schedule") + } +} + +// TestRunningEphemeralFollowsItsParent is the scratch terminal's lifecycle +// promise: dismissed or not, it runs while its parent runs — the first Reap +// with both alive touches nothing — and it is closed by the sweep once the +// parent has exited, without waiting for the parent to be reaped. +func TestRunningEphemeralFollowsItsParent(t *testing.T) { + now := time.Now() + r := NewRegistry(func() time.Time { return now }) + + parent, err := r.Spawn(SpawnOpts{Cmd: []string{"sleep", "0.2"}, Cols: 80, Rows: 24}) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + t.Cleanup(func() { _ = parent.Close() }) + scratch, err := r.Spawn(SpawnOpts{ + Cmd: []string{"sleep", "60"}, Cols: 80, Rows: 24, + Group: parent.ID(), Ephemeral: true, + }) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + t.Cleanup(func() { _ = scratch.Close() }) + + // Both alive: the sweep must leave the scratch running. + r.Reap() + if scratch.Info().State != "running" { + t.Fatal("scratch closed while its parent was still running") + } + + waitExited(t, parent, 5*time.Second) + // The parent is exited but not yet reaped — still listable in its + // retention window — and that alone ends the scratch. + r.Reap() + waitExited(t, scratch, 5*time.Second) +} + +// TestRunningEphemeralWithoutAParentIsLeftAlone: no group means no lifecycle +// to follow, and the sweep must not guess one. +func TestRunningEphemeralWithoutAParentIsLeftAlone(t *testing.T) { + r := NewRegistry(nil) + scratch, err := r.Spawn(SpawnOpts{ + Cmd: []string{"sleep", "60"}, Cols: 80, Rows: 24, Ephemeral: true, + }) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + t.Cleanup(func() { _ = scratch.Close() }) + + r.Reap() + if scratch.Info().State != "running" { + t.Fatal("ungrouped ephemeral session closed by the sweep") + } +} + +// TestApplyMetaClearsEphemeral is the "keep" affordance: clearing the flag +// promotes a scratch to an ordinary member, after which the parent's exit no +// longer takes it down. +func TestApplyMetaClearsEphemeral(t *testing.T) { + now := time.Now() + r := NewRegistry(func() time.Time { return now }) + + parent, err := r.Spawn(SpawnOpts{Cmd: []string{"sh", "-c", "exit 0"}, Cols: 80, Rows: 24}) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + t.Cleanup(func() { _ = parent.Close() }) + scratch, err := r.Spawn(SpawnOpts{ + Cmd: []string{"sleep", "60"}, Cols: 80, Rows: 24, + Group: parent.ID(), Ephemeral: true, + }) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + t.Cleanup(func() { _ = scratch.Close() }) + + kept := false + info, err := r.UpdateMeta(scratch.ID(), MetaPatch{Ephemeral: &kept}) + if err != nil { + t.Fatalf("UpdateMeta: %v", err) + } + if info.Ephemeral { + t.Fatal("Ephemeral still true after the clearing patch") + } + if info.Group != parent.ID() { + t.Errorf("Group = %q after the patch, want %q untouched", info.Group, parent.ID()) + } + + waitExited(t, parent, 5*time.Second) + r.Reap() + if scratch.Info().State != "running" { + t.Fatal("a kept session was closed with its parent; promotion did not stick") + } +} + +// TestSnapshotSkipsEphemeralAndCarriesGroup pins the restart story: a split +// member revives as a member because its snapshot names the group, and a +// scratch terminal is not snapshotted at all — its life is bound to a process +// the restart does not preserve. +func TestSnapshotSkipsEphemeralAndCarriesGroup(t *testing.T) { + r := NewRegistry(nil) + anchor := spawnRunning(t, r) + + member, err := r.Spawn(SpawnOpts{ + Cmd: []string{"sleep", "5"}, Cols: 80, Rows: 24, Group: anchor.ID(), + }) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + t.Cleanup(func() { _ = member.Close() }) + scratch, err := r.Spawn(SpawnOpts{ + Cmd: []string{"sleep", "5"}, Cols: 80, Rows: 24, + Group: anchor.ID(), Ephemeral: true, + }) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + t.Cleanup(func() { _ = scratch.Close() }) + + if _, ok := scratch.Snapshot(); ok { + t.Error("an ephemeral session produced a snapshot") + } + snap, ok := member.Snapshot() + if !ok { + t.Fatal("a grouped member produced no snapshot") + } + if snap.Group != anchor.ID() { + t.Fatalf("snapshot Group = %q, want %q", snap.Group, anchor.ID()) + } + + // And the revival hands the link back. + _ = member.Close() + r2 := NewRegistry(nil) + revived, err := r2.Revive(snap) + if err != nil { + t.Fatalf("Revive: %v", err) + } + t.Cleanup(func() { _ = revived.Close() }) + if got := revived.Info().Group; got != anchor.ID() { + t.Errorf("revived Group = %q, want %q", got, anchor.ID()) + } + if revived.Info().Ephemeral { + t.Error("revived session reports Ephemeral = true") + } +} diff --git a/internal/session/registry.go b/internal/session/registry.go index d4a92c2..118ba97 100644 --- a/internal/session/registry.go +++ b/internal/session/registry.go @@ -252,8 +252,19 @@ func (r *Registry) start(opts SpawnOpts, id string, preload []byte, restore Info Tags: normalizeTags(restore.Tags), CreatedAt: created, LastActive: born, + // From the options, then from the restore record: a spawn names its + // group up front, and a revival hands back the one the dead session + // carried. Both empty is every session from before the field. + Group: opts.Group, + // Deliberately not restored: an ephemeral session is never + // snapshotted (see Session.Snapshot), so a restore record cannot + // carry the flag. + Ephemeral: opts.Ephemeral, }, } + if s.info.Group == "" { + s.info.Group = restore.Group + } s.info.ID = s.id s.info.Title = restore.Title // Before pump starts, so everything restored precedes everything live. @@ -381,25 +392,61 @@ func (r *Registry) List() []*Session { return out } -// Reap removes sessions that exited more than ExitedRetention ago. +// Reap removes sessions that exited more than their retention ago — +// ExitedRetention ordinarily, EphemeralRetention for a scratch terminal — +// and closes the running ephemeral children of parents that have ended. +// +// The second half is the whole of an ephemeral session's lifecycle: a scratch +// terminal is dismissed by detaching, never by closing, so the shell inside it +// runs on — a dev server started there keeps serving — until the session it +// was opened from exits or is reaped. This sweep is where that promise is +// kept. A parent that is merely exited (still listable in its retention +// window) already ends its scratch: the terminal the scratch belongs beside is +// over, and nothing can reopen it from there. // // Victims are collected under r.mu and closed only after it has been // released. Close signals a process group, waits for the session's supervisor // to answer and closes a file descriptor; doing any of that while holding r.mu // would turn a stall in one session into a stall of Get, List, Spawn and every -// other session too. The one session call made under r.mu, exitStatus, reads -// two fields under s.mu and returns. +// other session too. The session calls made under r.mu, exitStatus and +// groupID, read fields under s.mu and return. func (r *Registry) Reap() { now := r.clock() var victims []*Session + var orphans []*Session r.mu.Lock() for id, s := range r.sessions { - exited, at := s.exitStatus() - if exited && now.Sub(at) >= ExitedRetention { + exited, at, ephemeral := s.exitStatus() + retention := ExitedRetention + if ephemeral { + // A scratch terminal's final output has one reader, who has already + // dismissed it. Keeping it listable for ten minutes would pile + // hidden exited rows behind every list that folds them away. + retention = EphemeralRetention + } + if exited && now.Sub(at) >= retention { victims = append(victims, s) delete(r.sessions, id) + continue } + if !ephemeral || exited { + continue + } + // A running scratch terminal lives exactly as long as its parent. An + // ephemeral session with no group has no parent to follow and is left + // alone — its client owns its lifecycle. + group := s.groupID() + if group == "" { + continue + } + parent, held := r.sessions[group] + if held { + if parentExited, _, _ := parent.exitStatus(); !parentExited { + continue + } + } + orphans = append(orphans, s) } r.mu.Unlock() @@ -412,4 +459,19 @@ func (r *Registry) Reap() { // a name is exactly what makes it findable in that window. DeleteMeta(dir, s.ID()) } + for _, s := range orphans { + // Between collection under r.mu and this Close, a Keep may have + // landed: UpdateMeta clears Ephemeral without r.mu, and a session + // promoted in that window is an ordinary member now — killing it + // would be the sweep spending a decision the user just reversed. + // Re-read the flag at the last moment; the promotion path never sets + // it back, so a stale read here can only spare, never kill. + if _, _, ephemeral := s.exitStatus(); !ephemeral { + continue + } + // Close, not delete: the kill lands now, the exit is recorded by the + // session's own supervisor, and the next sweep reaps the row through + // the ordinary path above. + _ = s.Close() + } } diff --git a/internal/session/session.go b/internal/session/session.go index ac6577a..5b0a390 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -20,6 +20,13 @@ import ( // output remains readable before the registry reaps it. const ExitedRetention = 10 * time.Minute +// EphemeralRetention is ExitedRetention for a session marked ephemeral: long +// enough for the client that owns it to hear the exit, and no longer. It is +// not an expiry on a running scratch terminal — a dismissed scratch keeps +// running until its parent session ends (see Registry.Reap) — it only keeps +// exited ones from waiting out ten minutes hidden from every list. +const EphemeralRetention = 10 * time.Second + // DefaultRingSize is the default scrollback capacity per session. const DefaultRingSize = 2 << 20 // 2 MiB @@ -51,6 +58,21 @@ type SpawnOpts struct { Cols uint16 Rows uint16 RingSize int // zero means DefaultRingSize + + // Group is the id of the session this one is grouped under — the anchor a + // client renders it beside as a split or a tab. It is metadata and nothing + // more: the daemon never resolves it, never requires the anchor to exist, + // and never treats members differently. Empty is every session spawned + // before the field existed, and every session that stands alone. + Group string + // Ephemeral marks a session a client considers disposable — a scratch + // terminal, spawned with Group naming the session it was opened from. Its + // life is tied to that parent: dismissing the scratch UI merely detaches, + // and the shell runs on until the parent session ends, at which point the + // registry closes it (see Reap). Server-side it is otherwise only the + // shorter exited retention; whether to hide it from a list is a client + // decision. + Ephemeral bool } // Info is a snapshot of session state safe to serialise. @@ -79,6 +101,10 @@ type Info struct { Rows uint16 `json:"rows"` CreatedAt time.Time `json:"createdAt"` LastActive time.Time `json:"lastActive"` + // Group and Ephemeral mirror SpawnOpts; see there. Both omitempty, so a + // session that carries neither serialises exactly as it always has. + Group string `json:"group,omitempty"` + Ephemeral bool `json:"ephemeral,omitempty"` } // MetaPatch is a partial update to a session's human-owned metadata: a nil @@ -93,6 +119,10 @@ type MetaPatch struct { Name *string Tags *[]string Pinned *bool + // Ephemeral is here for exactly one edit: a scratch terminal being kept. + // Clearing the flag promotes it to an ordinary session — listable by the + // client's rules and back on the ordinary exited retention. + Ephemeral *bool } // Sub is one subscriber's view of a session's output stream. Backlog plus @@ -212,6 +242,15 @@ type Session struct { func (s *Session) ID() string { return s.id } +// groupID reads the session's group link under s.mu. It is set at spawn and +// never rewritten, but the lock keeps the read on the right side of the rule +// rather than leaning on that. +func (s *Session) groupID() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.info.Group +} + // Info returns a snapshot of the session's state, and is also where the // child's cwd is refreshed — the kernel is the only party that knows where a // `cd` left the shell, so every snapshot asks it. @@ -259,6 +298,9 @@ func (s *Session) ApplyMeta(p MetaPatch) Info { if p.Pinned != nil { s.info.Pinned = *p.Pinned } + if p.Ephemeral != nil { + s.info.Ephemeral = *p.Ephemeral + } return s.info } @@ -871,13 +913,14 @@ func masterReadable(f *os.File) bool { return readable } -// exitStatus reports whether the child has exited and, if so, when — the two -// fields Registry.Reap needs. It is a plain read of two fields under s.mu, -// which is never held across anything that can block. -func (s *Session) exitStatus() (bool, time.Time) { +// exitStatus reports whether the child has exited, when, and whether the +// session is ephemeral — the fields Registry.Reap needs to pick a retention. +// It is a plain read of fields under s.mu, which is never held across +// anything that can block. +func (s *Session) exitStatus() (exited bool, at time.Time, ephemeral bool) { s.mu.Lock() defer s.mu.Unlock() - return s.info.State == "exited", s.exitedAt + return s.info.State == "exited", s.exitedAt, s.info.Ephemeral } // signalGroup delivers sig to the process group led by the child. A group that diff --git a/internal/session/snapshot.go b/internal/session/snapshot.go index 5062046..5ff30c3 100644 --- a/internal/session/snapshot.go +++ b/internal/session/snapshot.go @@ -36,7 +36,13 @@ type Snapshot struct { Name string `json:"name"` Tags []string `json:"tags"` Pinned bool `json:"pinned"` - Cwd string `json:"cwd"` + // Group travels so a split survives a restart as a split: members are just + // sessions, and this is the one fact that makes them members. omitempty + // keeps the ungrouped snapshot byte-compatible with what earlier daemons + // wrote and read. There is no Ephemeral beside it, because an ephemeral + // session is never snapshotted at all — see Session.Snapshot. + Group string `json:"group,omitempty"` + Cwd string `json:"cwd"` Cols uint16 `json:"cols"` Rows uint16 `json:"rows"` // The ring's retained bytes. encoding/json carries []byte as base64. @@ -133,11 +139,14 @@ func reviveNote(claudeSession string) []byte { } // Snapshot captures what a revival needs. ok is false for an exited or -// closed session: those end with the daemon rather than coming back. +// closed session — those end with the daemon rather than coming back — and +// for an ephemeral one: a scratch terminal's life is bound to its parent's +// process, and the parent's revival is a fresh shell the old scratch has no +// standing beside. func (s *Session) Snapshot() (Snapshot, bool) { s.mu.Lock() defer s.mu.Unlock() - if s.closed || s.info.State != "running" { + if s.closed || s.info.State != "running" || s.info.Ephemeral { return Snapshot{}, false } ring, _ := s.ring.Since(s.ring.BaseSeq()) // a fresh copy, per Since @@ -148,6 +157,7 @@ func (s *Session) Snapshot() (Snapshot, bool) { Name: s.info.Name, Tags: s.info.Tags, Pinned: s.info.Pinned, + Group: s.info.Group, Cwd: s.info.Cwd, Cols: s.info.Cols, Rows: s.info.Rows, @@ -210,6 +220,7 @@ func (r *Registry) Revive(snap Snapshot) (*Session, error) { Name: snap.Name, Tags: snap.Tags, Pinned: snap.Pinned, + Group: snap.Group, CreatedAt: snap.CreatedAt, }, ) diff --git a/internal/transport/relay/channel.go b/internal/transport/relay/channel.go index 1df2e49..8e8f1e0 100644 --- a/internal/transport/relay/channel.go +++ b/internal/transport/relay/channel.go @@ -309,7 +309,11 @@ func (t *Transport) openChannel(s *socket, m *relaywire.Open) { // everything, and there is nobody left to answer. return } - go t.serveChannel(s, ch, m.Origin) + t.serving.Add(1) + go func() { + defer t.serving.Done() + t.serveChannel(s, ch, m.Origin) + }() } // canServeChannels reports whether this daemon has what a channel needs: a @@ -644,7 +648,9 @@ func (t *Transport) pair(s *socket, m *relaywire.Pair) { // path parses; decoding the control message already copied it out of the // read buffer. body := m.Body + t.serving.Add(1) go func() { + defer t.serving.Done() defer func() { <-t.pairings }() t.answerPair(s, m.ID, t.srv.PairDevice(body, relayPeer)) }() diff --git a/internal/transport/relay/channel_test.go b/internal/transport/relay/channel_test.go index 327a939..fdba832 100644 --- a/internal/transport/relay/channel_test.go +++ b/internal/transport/relay/channel_test.go @@ -158,7 +158,14 @@ func (s *readingServer) ServeConn(ctx context.Context, mc daemon.MessageConn, me }() if s.block != nil { - <-s.block + // As the real ServeConn would: blocked or not, the context ending is + // the end of the connection. Run joins these goroutines on its way + // out now, so a block that ignored ctx would deadlock every teardown + // whose cleanup closes the block after stopping the transport. + select { + case <-s.block: + case <-ctx.Done(): + } return } for { @@ -891,6 +898,92 @@ func TestRelayChannelBackpressureClosesOneChannelNotTheSocket(t *testing.T) { attach(t, c, 2, id.deviceKey, id.key.Public) } +// laggingServer is a Server whose ServeConn keeps working past its context — +// the shape of a connection's tail writes (a last-seen stamp, a back-filled +// certificate) landing after Run was told to stop. It deliberately ignores +// ctx: the real daemon honours it, but honouring it is exactly what would +// hide a Run that returns without waiting. +type laggingServer struct { + release chan struct{} + + mu sync.Mutex + served int +} + +func (s *laggingServer) ServeConn(_ context.Context, mc daemon.MessageConn, _ daemon.ConnMeta) { + s.mu.Lock() + s.served++ + s.mu.Unlock() + <-s.release + _ = mc.Close() +} + +func (s *laggingServer) PairDevice([]byte, string) daemon.PairOutcome { + return daemon.PairRefusal() +} + +func (s *laggingServer) SetRelayStatus(string, string) {} + +func (s *laggingServer) waitServed(t *testing.T) { + t.Helper() + deadline := time.Now().Add(waitFor) + for { + s.mu.Lock() + n := s.served + s.mu.Unlock() + if n >= 1 { + return + } + if time.Now().After(deadline) { + t.Fatal("no connection was handed to ServeConn") + } + time.Sleep(time.Millisecond) + } +} + +// TestRunWaitsForServedConnections: Run's return is the transport's promise +// that nothing of it is still running. The goroutines serving channels write +// into the device registry (a last-seen stamp on every attach), so a Run that +// returns while one is still going leaves those writes racing whatever the +// caller does next — in the daemon that is shutdown, in these tests it is the +// harness deleting the registry's directory out from under the write. +func TestRunWaitsForServedConnections(t *testing.T) { + t.Parallel() + r := newFakeRelay(t, "s") + id := newIdentity(t) + srv := &laggingServer{release: make(chan struct{})} + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(srv.release) }) } + t.Cleanup(release) + + tr := newChannelTransport(t, r, srv, id, nil) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + done := make(chan error, 1) + go func() { done <- tr.Run(ctx) }() + + c := r.accept(t) + attach(t, c, 1, id.deviceKey, id.key.Public) + srv.waitServed(t) + + cancel() + select { + case <-done: + t.Fatal("Run returned while a connection was still being served") + case <-time.After(100 * time.Millisecond): + } + + release() + select { + case err := <-done: + if err != nil { + t.Errorf("Run returned %v after its context was cancelled, want nil", err) + } + case <-time.After(waitFor): + t.Fatal("Run did not return after the last served connection ended") + } +} + // panickingServer stands in for any unhandled failure on the serve path. // daemon.ServeConn propagates a panic to its caller by design; on loopback that // caller is net/http, which recovers per connection. @@ -1146,9 +1239,12 @@ func TestRelayPairingRunsOffTheReadLoop(t *testing.T) { <-release return daemon.PairRefusal() }} - t.Cleanup(func() { close(release) }) tr := newChannelTransport(t, r, srv, id, nil) runTransport(t, tr) + // After runTransport, deliberately: cleanups run last-first, and Run now + // waits for the parked ceremony on its way out, so the park must be + // released before the transport is stopped. + t.Cleanup(func() { close(release) }) c := r.accept(t) c.sendControl(t, relaywire.Pair{ID: 1, Origin: testOrigin, Body: pairingBody(t, "t", unpairedKey(t).Public)}) diff --git a/internal/transport/relay/relay.go b/internal/transport/relay/relay.go index f15332d..ef3503a 100644 --- a/internal/transport/relay/relay.go +++ b/internal/transport/relay/relay.go @@ -255,6 +255,15 @@ type Transport struct { // count, taken by each and returned when it finishes. See maxPairings. pairings chan struct{} + // serving counts the goroutines this transport has spun off to serve a + // channel or answer a pairing — the ones that write into the device + // registry (a last-seen stamp, a pairing's new entry). Run waits for it + // on the way out, and only there: within a reconnect a lame connection's + // unwinding must not delay the dial that brings every other browser back, + // but Run's return is the promise that nothing of the transport is still + // running — least of all a registry write racing the caller's teardown. + serving sync.WaitGroup + log *slog.Logger // keepalive is the interval between flue-ping frames, a field rather than a @@ -352,6 +361,10 @@ func (t *Transport) Run(ctx context.Context) error { // context is done, and a status left reading "connecting" would have every // welcome after it announce a relay nothing is trying to reach. defer t.srv.SetRelayStatus(daemon.RelayOff, "") + // LIFO with the line above: the serve goroutines are joined first, then + // the status flips. See the field's comment for why the join is here and + // not in each reconnect iteration. + defer t.serving.Wait() attempt := 0 for { diff --git a/internal/wire/control.go b/internal/wire/control.go index f7ffbf9..dc64cb0 100644 --- a/internal/wire/control.go +++ b/internal/wire/control.go @@ -24,6 +24,14 @@ type Spawn struct { // ReqID correlates this request with the attached or error answering it. // Client-chosen; zero means the client asked for no correlation. ReqID uint64 `json:"reqId,omitempty"` + // Group links the new session under an anchor session — a split pane, or a + // tab in the anchor's group. Optional and additive: a daemon from before + // the field ignores it, and the session simply spawns ungrouped. + Group string `json:"group,omitempty"` + // Ephemeral marks a scratch terminal: hidden by clients, reaped fast once + // exited, and closed by the daemon when the Group parent ends. Optional + // and additive like Group. + Ephemeral bool `json:"ephemeral,omitempty"` } type Attach struct { @@ -97,6 +105,10 @@ type Update struct { Name *string `json:"name,omitempty"` Tags *[]string `json:"tags,omitempty"` Pinned *bool `json:"pinned,omitempty"` + // Ephemeral exists for one edit: clearing it keeps a scratch terminal, + // promoting it to an ordinary member of its group. A pointer for the same + // reason the others are — absent means this edit is not about it. + Ephemeral *bool `json:"ephemeral,omitempty"` } // Peek asks for the tail of a session's scrollback without attaching to it. diff --git a/spec/protocol.md b/spec/protocol.md index 236a985..a4de277 100644 --- a/spec/protocol.md +++ b/spec/protocol.md @@ -29,13 +29,13 @@ Every control message is a JSON object with a `type` discriminator. |---|---|---| | `hello` | `ver`, `caps[]` | open the conversation | | `list` | — | list the daemon's sessions | -| `spawn` | `cwd`, `cmd[]`, `cols`, `rows`, `reqId?` | start a session and attach to it | +| `spawn` | `cwd`, `cmd[]`, `cols`, `rows`, `reqId?`, `group?`, `ephemeral?` | start a session and attach to it | | `attach` | `id`, `lastSeq`, `reqId?` | attach to an existing session | | `detach` | `ref` | release an attachment | | `resize` | `ref`, `cols`, `rows`, `primary` | report this view's dimensions | | `signal` | `ref`, `sig` | send a signal to the session's process | | `close` | `ref` *or* `id` | end the session | -| `update` | `id`, `name?`, `tags[]?`, `pinned?` | edit a session's human-owned metadata | +| `update` | `id`, `name?`, `tags[]?`, `pinned?`, `ephemeral?` | edit a session's human-owned metadata | | `peek` | `id`, `bytes?`, `reqId?` | read the tail of a session's scrollback without attaching | | `stat` | `id`, `paths[]`, `reqId?` | ask whether paths exist, relative to a session | | `read` | `id`, `path`, `reqId?` | start reading one | @@ -92,6 +92,47 @@ Each record of `deviceList.devices[]` carries `id`, `label`, `pairedAt` and `lastSeen`. Both timestamps are unix **seconds**, not the RFC 3339 strings `sessions[]` uses. +### Groups and ephemeral sessions + +Both fields are **optional and additive**, and a plain session — no `group`, +no `ephemeral` — behaves exactly as it always has. An old daemon ignores the +unknown spawn fields and the session simply starts ungrouped; an old client +ignores the unknown `sessions[]` fields and grouped or ephemeral sessions +show up as ordinary rows. `welcome.caps` containing `"multiplex"` is how a +client learns the daemon understands them; without it a client should hide +its split and scratch affordances rather than spawn sessions whose fields +silently dropped. + +`spawn.group` links the new session under an anchor session — the daemon +records the id on the session's `sessions[]` row and does nothing else with +it. It never resolves the anchor, never requires it to exist, and never +treats members differently: grouping is a rendering instruction to clients +(splits on a desktop, tabs on a phone), not a server concept. Groups are +flat — a member's `group` names its anchor, and nothing nests. + +`spawn.ephemeral` marks a scratch terminal, and it is the one place the +daemon does act: + +- A **running** ephemeral session whose `group` parent has exited (or been + reaped) is closed by the daemon's periodic sweep. That is the whole + lifecycle: dismissing a scratch terminal in a client is a detach, never a + close, so the shell inside it — a dev server, a watch loop — keeps running + until the session it was opened from ends. +- An **exited** ephemeral session is reaped after seconds rather than the + usual ten minutes, so hidden scratch rows do not pile up behind lists that + fold them away. +- A running ephemeral session with **no** `group` is left entirely alone. + +`update.ephemeral` exists for one edit: `false` clears the flag and keeps the +scratch — an ordinary member of its group from then on, on ordinary +retention, no longer bound to its parent. Ephemeral sessions are never +snapshotted for revival; grouped non-ephemeral sessions revive with their +`group` intact. + +Whether to hide ephemeral sessions or fold a group into one row is a client +decision. The read-only `GET /api/sessions` and the CLI keep reporting every +session, fields included. + `welcome.relay`, when present, is `{status, origin?}` — the state of the daemon's relay leg at the moment this connection was accepted. `status` is `connecting` while the daemon is dialling, and `connected` once the socket is diff --git a/testdata/wire/control.json b/testdata/wire/control.json index dc0c2af..e232a82 100644 --- a/testdata/wire/control.json +++ b/testdata/wire/control.json @@ -29,6 +29,29 @@ "reqId": 6 } }, + { + "name": "spawnScratch", + "json": { + "type": "spawn", + "cwd": "/home/karn/code", + "cols": 120, + "rows": 40, + "reqId": 21, + "group": "a1b2c3d4e5f60718", + "ephemeral": true + } + }, + { + "name": "spawnSplit", + "json": { + "type": "spawn", + "cwd": "/home/karn/code", + "cols": 120, + "rows": 40, + "reqId": 22, + "group": "a1b2c3d4e5f60718" + } + }, { "name": "attach", "json": { @@ -97,6 +120,14 @@ "pinned": true } }, + { + "name": "updateKeepScratch", + "json": { + "type": "update", + "id": "a1b2c3d4e5f60708", + "ephemeral": false + } + }, { "name": "updateClearTags", "json": { @@ -185,6 +216,18 @@ "ver": "0.1.0" } }, + { + "name": "welcomeCaps", + "json": { + "type": "welcome", + "daemonId": "local", + "host": "macbook", + "ver": "0.1.0", + "caps": [ + "multiplex" + ] + } + }, { "name": "welcomeRelay", "json": { @@ -255,6 +298,26 @@ "rows": 24, "createdAt": "2026-07-28T07:15:00Z", "lastActive": "2026-07-28T09:00:00Z" + }, + { + "id": "s3", + "title": "htop", + "name": "", + "tags": [], + "pinned": false, + "cwd": "/home/karn/code", + "cmd": [ + "zsh", + "-l" + ], + "state": "running", + "exitCode": 0, + "cols": 120, + "rows": 40, + "createdAt": "2026-07-28T10:00:00Z", + "lastActive": "2026-07-28T10:31:00Z", + "group": "s1", + "ephemeral": true } ] } diff --git a/web/src/client/client.test.ts b/web/src/client/client.test.ts index 480a4a3..056d3ef 100644 --- a/web/src/client/client.test.ts +++ b/web/src/client/client.test.ts @@ -221,6 +221,8 @@ describe('control message golden file', () => { 'hello', 'list', 'spawn', + 'spawnScratch', + 'spawnSplit', 'attach', 'detach', 'resize', @@ -229,6 +231,7 @@ describe('control message golden file', () => { 'closeById', 'update', 'updateTagsAndPinned', + 'updateKeepScratch', 'updateClearTags', 'updateClearName', 'peek', @@ -240,6 +243,7 @@ describe('control message golden file', () => { 'pairStart', 'pairCancel', 'welcome', + 'welcomeCaps', 'welcomeRelay', 'welcomeRelayConnecting', 'sessions', @@ -287,6 +291,31 @@ describe('control message golden file', () => { expect(fixture('spawn')).toStrictEqual(want) }) + it('decodes spawn with the scratch fields — group and ephemeral', () => { + const want: SpawnMsg = { + type: 'spawn', + cwd: '/home/karn/code', + cols: 120, + rows: 40, + reqId: 21, + group: 'a1b2c3d4e5f60718', + ephemeral: true, + } + expect(fixture('spawnScratch')).toStrictEqual(want) + }) + + it('decodes spawn with a group alone — a split pane', () => { + const want: SpawnMsg = { + type: 'spawn', + cwd: '/home/karn/code', + cols: 120, + rows: 40, + reqId: 22, + group: 'a1b2c3d4e5f60718', + } + expect(fixture('spawnSplit')).toStrictEqual(want) + }) + it('decodes attach', () => { const want: AttachMsg = { type: 'attach', @@ -392,6 +421,15 @@ describe('control message golden file', () => { expect(got.pinned).toBe(false) }) + it('decodes an update keeping a scratch terminal', () => { + // `ephemeral: false` is the promotion — the same explicit-falsy hazard as + // clearing a name, and the only edit the field exists for. + const want: UpdateMsg = { type: 'update', id: 'a1b2c3d4e5f60708', ephemeral: false } + const got = fixture('updateKeepScratch') as UpdateMsg + expect(got).toStrictEqual(want) + expect(got.ephemeral).toBe(false) + }) + it('decodes devices', () => { const want: DevicesMsg = { type: 'devices' } expect(fixture('devices')).toStrictEqual(want) @@ -419,6 +457,17 @@ describe('control message golden file', () => { expect(fixture('welcome')).toStrictEqual(want) }) + it('decodes welcome with capabilities — how multiplex is feature-detected', () => { + const want: Welcome = { + type: 'welcome', + daemonId: 'local', + host: 'macbook', + ver: '0.1.0', + caps: ['multiplex'], + } + expect(fixture('welcomeCaps')).toStrictEqual(want) + }) + it('decodes welcome with a live relay', () => { const want: Welcome = { type: 'welcome', @@ -488,6 +537,26 @@ describe('control message golden file', () => { createdAt: '2026-07-28T07:15:00Z', lastActive: '2026-07-28T09:00:00Z', }, + { + // A scratch terminal: grouped under s1 and ephemeral. Both fields + // are omitempty on the Go side, which is why the two rows above + // must keep decoding without them. + id: 's3', + title: 'htop', + name: '', + tags: [], + pinned: false, + cwd: '/home/karn/code', + cmd: ['zsh', '-l'], + state: 'running', + exitCode: 0, + cols: 120, + rows: 40, + createdAt: '2026-07-28T10:00:00Z', + lastActive: '2026-07-28T10:31:00Z', + group: 's1', + ephemeral: true, + }, ], } expect(fixture('sessions')).toStrictEqual(want) @@ -1649,6 +1718,46 @@ describe('FlueClient sending', () => { expect(c.spawn({ cols: 80, rows: 24 })).toBe(1) }) + it('carries group and ephemeral on a spawn that names them, and neither otherwise', () => { + const { c, sockets } = harness() + c.connect() + sockets[0]!.open() + c.spawn({ cols: 80, rows: 24, group: 'anchor1', ephemeral: true }) + c.spawn({ cols: 80, rows: 24 }) + + const spawns = sockets[0]!.sentControl().filter((m) => m.type === 'spawn') + expect(spawns).toStrictEqual([ + { type: 'spawn', cols: 80, rows: 24, group: 'anchor1', ephemeral: true, reqId: 1 }, + { type: 'spawn', cols: 80, rows: 24, reqId: 2 }, + ]) + }) + + it('carries an explicit ephemeral: false on update — the keep edit', () => { + const { c, sockets } = harness() + c.connect() + sockets[0]!.open() + c.update({ id: 's1', ephemeral: false }) + expect(sockets[0]!.sentControl().filter((m) => m.type === 'update')).toStrictEqual([ + { type: 'update', id: 's1', ephemeral: false }, + ]) + }) + + it('answers hasCap from the welcome, and not before one', () => { + const { c, sockets } = harness() + c.connect() + sockets[0]!.open() + expect(c.hasCap('multiplex')).toBe(false) + sockets[0]!.emitControl({ + type: 'welcome', + daemonId: 'local', + host: 'h', + ver: '1', + caps: ['multiplex'], + }) + expect(c.hasCap('multiplex')).toBe(true) + expect(c.hasCap('something-else')).toBe(false) + }) + it('drops rather than holds a spawn issued while the socket is down', async () => { // A held spawn would surface behind a ten-second backoff as a shell // nobody asked for at a screen nobody is looking at. diff --git a/web/src/client/client.ts b/web/src/client/client.ts index 7bfea3f..2a3ea97 100644 --- a/web/src/client/client.ts +++ b/web/src/client/client.ts @@ -405,6 +405,19 @@ export class FlueClient { return this.lastWelcome?.ver ?? null } + /** + * Whether the daemon's welcome declared a capability — how a consumer + * feature-detects before drawing an affordance. `multiplex` is the one that + * exists today: it gates the split and scratch-terminal controls, which + * must not spawn sessions whose `group`/`ephemeral` fields an older daemon + * would silently drop. False before the first welcome, which errs on the + * side of hiding a control for one round trip rather than showing one that + * cannot work. + */ + hasCap(cap: string): boolean { + return this.lastWelcome?.caps?.includes(cap) ?? false + } + /** * Why the daemon revoked this device, or null while it has not — * `onRevoked`'s counterpart for a consumer that mounts after the event, as @@ -505,7 +518,13 @@ export class FlueClient { * to protect. Losing it costs a retry and little else, since the sessions * screen re-lists on reconnect and the row visibly snaps back. */ - update(patch: { id: string; name?: string; tags?: string[]; pinned?: boolean }) { + update(patch: { + id: string + name?: string + tags?: string[] + pinned?: boolean + ephemeral?: boolean + }) { this.send({ type: 'update', ...patch }) } @@ -624,7 +643,14 @@ export class FlueClient { * that appears minutes later at a screen nobody is looking at is worse * than none. */ - spawn(opts: { cwd?: string; cmd?: string[]; cols: number; rows: number }): number | null { + spawn(opts: { + cwd?: string + cmd?: string[] + cols: number + rows: number + group?: string + ephemeral?: boolean + }): number | null { if (!this.ready || !this.sock) return null const { cols, rows, ...rest } = opts const reqId = this.nextReqId++ diff --git a/web/src/client/protocol.ts b/web/src/client/protocol.ts index a011718..7cac2c7 100644 --- a/web/src/client/protocol.ts +++ b/web/src/client/protocol.ts @@ -94,6 +94,19 @@ export interface SessionInfo { createdAt: string /** RFC 3339, as Go marshals a time.Time. */ lastActive: string + /** + * The id of the anchor session this one is grouped under — a split pane on + * a desktop, a tab on a phone. Absent for every session that stands alone, + * which is every session from before the field. Metadata only: the daemon + * records it and nothing else, so folding a group is this side's decision. + */ + group?: string + /** + * A scratch terminal: hidden by this client's lists, reaped fast once + * exited, and closed by the daemon when its `group` parent ends. Dismissing + * its UI detaches — the shell keeps running until then. Absent means false. + */ + ephemeral?: boolean } /** @@ -151,6 +164,14 @@ export interface SpawnMsg { * asked for. Mirrors `reqId,omitempty` on the Go side. */ reqId?: number + /** + * Group the new session under an anchor session — a split, or a tab in the + * anchor's group. Only sent to a daemon whose welcome caps include + * `multiplex`; an older daemon would ignore it and spawn an orphan. + */ + group?: string + /** Mark a scratch terminal. Same caps gate as `group`. */ + ephemeral?: boolean } export interface AttachMsg { @@ -234,6 +255,12 @@ export interface UpdateMsg { name?: string tags?: string[] pinned?: boolean + /** + * One edit only: `false` keeps a scratch terminal, promoting it to an + * ordinary member of its group. Absent leaves the flag alone, like every + * other field here. + */ + ephemeral?: boolean } /** diff --git a/web/src/components/exit-overlay.tsx b/web/src/components/exit-overlay.tsx deleted file mode 100644 index 1255208..0000000 --- a/web/src/components/exit-overlay.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Button } from '@/components/ui/button' -import { cn } from '@/lib/utils' - -/** - * The card shown over a session whose shell has exited. - * - * The wrapper is pointer-transparent on purpose: the dimmed scrollback under - * it stays scrollable and selectable, and only the card itself takes events. - * Dressed in the --chip-* variables the pane above it carries, so the card - * wears the terminal's own theme like every other floating control. - */ -export function ExitOverlay({ - code, - onRestart, - onClose, -}: { - code: number - onRestart: () => void - onClose: () => void -}) { - return ( -
-
-

- shell exited{' '} - ({code}) -

-
- - -
-
-
- ) -} diff --git a/web/src/components/session-group.test.tsx b/web/src/components/session-group.test.tsx new file mode 100644 index 0000000..c3f5f46 --- /dev/null +++ b/web/src/components/session-group.test.tsx @@ -0,0 +1,196 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { PaneTree } from '@/sessions/pane-tree' +import { SessionGroup } from './session-group' + +type Props = Parameters[0] + +/** + * useIsMobile reads window.innerWidth once at mount and then listens to a + * media query; jsdom's default width is desktop-shaped, so mobile is opted + * into per test by shrinking the window before render. + */ +function setWidth(px: number) { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: px }) +} + +const AB_ROW: PaneTree = { split: 'row', ratio: 0.5, a: { leaf: 'a' }, b: { leaf: 'b' } } + +function renderGroup(over: Partial = {}) { + const props: Props = { + tabs: [AB_ROW], + panes: [ + { id: 'a', label: 'api server' }, + { id: 'b', label: 'logs' }, + ], + onRatio: vi.fn(), + active: 'a', + onActivate: vi.fn(), + renderPane: (id, inset, fit) => ( + + pane:{id} + + ), + ...over, + } + const view = render() + return { ...view, props } +} + +afterEach(() => { + setWidth(1024) + localStorage.clear() +}) + +describe('SessionGroup', () => { + it('renders a group of one as nothing but the pane — the degenerate case', () => { + renderGroup({ panes: [{ id: 'solo', label: '' }], tabs: [{ leaf: 'solo' }] }) + expect(screen.getByText('pane:solo')).toBeTruthy() + expect(screen.queryByRole('tablist')).toBeNull() + expect(screen.queryByRole('separator')).toBeNull() + expect(screen.getByText('pane:solo').dataset.inset).toBe('0') + }) + + it('renders one tab of splits with a divider and no strip on a desktop', () => { + setWidth(1280) + renderGroup() + expect(screen.getByText('pane:a')).toBeTruthy() + expect(screen.getByText('pane:b')).toBeTruthy() + expect(screen.getByRole('separator').getAttribute('aria-orientation')).toBe('vertical') + expect(screen.queryByRole('tablist')).toBeNull() + // Split panes must not pin themselves to the visual viewport — the + // pinning slots are single-occupancy, and siblings would steal them + // from each other. See renderPane's `fit` on the props. + expect(screen.getByText('pane:a').dataset.fit).toBe('false') + expect(screen.getByText('pane:b').dataset.fit).toBe('false') + }) + + it('renders a stacked split with a horizontal divider', () => { + setWidth(1280) + renderGroup({ tabs: [{ split: 'column', ratio: 0.5, a: { leaf: 'a' }, b: { leaf: 'b' } }] }) + expect(screen.getByRole('separator').getAttribute('aria-orientation')).toBe('horizontal') + }) + + it('renders a nested tree — a column stacked inside one side of a row', () => { + setWidth(1280) + renderGroup({ + panes: [ + { id: 'a', label: '' }, + { id: 'b', label: '' }, + { id: 'c', label: '' }, + ], + tabs: [ + { + split: 'row', + ratio: 0.5, + a: { leaf: 'a' }, + b: { split: 'column', ratio: 0.5, a: { leaf: 'b' }, b: { leaf: 'c' } }, + }, + ], + }) + expect(screen.getByText('pane:a')).toBeTruthy() + expect(screen.getByText('pane:b')).toBeTruthy() + expect(screen.getByText('pane:c')).toBeTruthy() + const separators = screen.getAllByRole('separator') + expect(separators.map((s) => s.getAttribute('aria-orientation')).sort()).toEqual([ + 'horizontal', + 'vertical', + ]) + }) + + it('renders a strip when there is more than one tab, splits inside the active one', () => { + setWidth(1280) + renderGroup({ + panes: [ + { id: 'a', label: 'api server' }, + { id: 'b', label: 'logs' }, + { id: 'c', label: 'scratchpad' }, + ], + tabs: [AB_ROW, { leaf: 'c' }], + active: 'a', + }) + expect(screen.getByRole('tablist')).toBeTruthy() + // The active tab holds the split pair; the other tab's pane stays + // unmounted. The multi-pane tab's label carries its pane count. + expect(screen.getByText('pane:a')).toBeTruthy() + expect(screen.getByText('pane:b')).toBeTruthy() + expect(screen.queryByText('pane:c')).toBeNull() + expect(screen.getByRole('tab', { name: 'api server · 2' })).toBeTruthy() + expect(screen.getByRole('tab', { name: 'scratchpad' })).toBeTruthy() + }) + + it('shows the tab holding the active pane, and a lone tab pane pins under the strip', () => { + setWidth(1280) + renderGroup({ + panes: [ + { id: 'a', label: '' }, + { id: 'b', label: '' }, + { id: 'c', label: '' }, + ], + tabs: [AB_ROW, { leaf: 'c' }], + active: 'c', + }) + expect(screen.getByText('pane:c')).toBeTruthy() + expect(screen.queryByText('pane:a')).toBeNull() + // Alone under the strip, the pane is the page again: it pins, inset by + // the strip's height. + expect(screen.getByText('pane:c').dataset.fit).toBe('true') + expect(screen.getByText('pane:c').dataset.inset).not.toBe('0') + }) + + it('offers the strip + when a new-tab handler is given', async () => { + setWidth(1280) + const onNewTab = vi.fn() + renderGroup({ tabs: [AB_ROW, { leaf: 'c' }], panes: [ + { id: 'a', label: '' }, + { id: 'b', label: '' }, + { id: 'c', label: '' }, + ], onNewTab }) + await userEvent.click(screen.getByRole('button', { name: 'New tab in this group' })) + expect(onNewTab).toHaveBeenCalled() + }) + + it('renders a flat tab strip and one pane on show on a phone, whatever the trees say', () => { + setWidth(390) + renderGroup() + expect(screen.getByRole('tablist')).toBeTruthy() + expect(screen.getByText('pane:a')).toBeTruthy() + expect(screen.queryByText('pane:b')).toBeNull() + expect(screen.queryByRole('separator')).toBeNull() + // The pane is told about the strip above it, for the viewport pinning. + expect(screen.getByText('pane:a').dataset.inset).not.toBe('0') + }) + + it('reports a tab pick and marks the shown tab selected', async () => { + setWidth(390) + const { props } = renderGroup() + expect(screen.getByRole('tab', { name: 'api server' }).getAttribute('aria-selected')).toBe( + 'true', + ) + await userEvent.click(screen.getByRole('tab', { name: 'logs' })) + expect(props.onActivate).toHaveBeenCalledWith('b') + }) + + it('falls back to the first pane when the active id has gone', () => { + setWidth(390) + renderGroup({ active: 'gone' }) + expect(screen.getByText('pane:a')).toBeTruthy() + expect(screen.getByRole('tab', { name: 'api server' }).getAttribute('aria-selected')).toBe( + 'true', + ) + }) + + it('names an unnamed pane by its position', () => { + setWidth(390) + renderGroup({ + panes: [ + { id: 'a', label: '' }, + { id: 'b', label: '' }, + ], + }) + expect(screen.getByRole('tab', { name: 'Terminal 1' })).toBeTruthy() + expect(screen.getByRole('tab', { name: 'Terminal 2' })).toBeTruthy() + }) +}) diff --git a/web/src/components/session-group.tsx b/web/src/components/session-group.tsx new file mode 100644 index 0000000..3a77d87 --- /dev/null +++ b/web/src/components/session-group.tsx @@ -0,0 +1,335 @@ +import { useCallback, useEffect, useRef, type ReactNode } from 'react' +import { PlusIcon } from 'lucide-react' + +import { useIsMobile } from '@/hooks/use-mobile' +import { leafIds, tabOf, type PaneTree, type TreePath } from '@/sessions/pane-tree' +import { cn } from '@/lib/utils' + +/** + * The tab strips' heights in px (h-11 and h-9 below). Handed to panes as + * their viewportInset, so the strip and the pinning formula cannot disagree + * about how tall the strip is. + */ +const MOBILE_STRIP_PX = 44 +const DESKTOP_STRIP_PX = 36 + +/** One pane of a group: the session, and what a tab calls it. */ +export interface GroupPane { + id: string + label: string +} + +export interface SessionGroupProps { + /** + * The desktop arrangement: one split tree per tab (sessions/pane-tree.ts), + * owned and persisted by the route, which is where splits are made and + * members reconciled. Tabs and splits compose — a tab holds a whole split + * arrangement, and "new tab" adds a tab without disturbing it. Out of step + * is survivable: the route reconciles against the members on every change. + */ + tabs: PaneTree[] + /** Every pane in order — anchor first — for labels and the phone's flat tabs. */ + panes: GroupPane[] + /** A divider settled: the split at `path` inside tab `tab` wants `ratio`. */ + onRatio: (tab: number, path: TreePath, ratio: number) => void + /** The pane in front: the phone's tab, or the desktop tab that holds it. */ + active: string + onActivate: (id: string) => void + /** The strip's `+`: a new tab, in the directory of the active pane. */ + onNewTab?: () => void + /** + * Draw one pane's terminal. `viewportInset` is the in-flow chrome above + * the pane — a tab strip's height, zero elsewhere — for the terminal's + * visual-viewport pinning; key the terminal on it. `fit` says whether this + * pane may pin itself to the visual viewport at all: true when it is the + * page, false for every pane of a multi-pane split — the pinning slots are + * single-occupancy (lib/viewport.ts), and N sibling panes each grabbing + * them would leave all but the last frozen at mount-time height. + */ + renderPane: (id: string, viewportInset: number, fit: boolean) => ReactNode +} + +/** + * One session group, rendered for the screen it is on: tabs of split trees + * on a desktop, a flat tab strip on a phone — a phone has room for exactly + * one pane, so its tabs are the members and the trees are ignored. + * + * A group of one deliberately does not read as a group at all — no strip, no + * divider, exactly the full-bleed terminal every session has always been. + * That is the backward-compatibility floor the issue draws: a plain session + * is the degenerate case, not a special one. + */ +export function SessionGroup({ + tabs, + panes, + onRatio, + active, + onActivate, + onNewTab, + renderPane, +}: SessionGroupProps) { + const isMobile = useIsMobile() + const labels = new Map(panes.map((p, i) => [p.id, p.label === '' ? `Terminal ${i + 1}` : p.label])) + + if (panes.length <= 1) { + const only = panes[0]?.id ?? active + return
{renderPane(only, 0, true)}
+ } + + if (isMobile) { + const shown = panes.some((p) => p.id === active) ? active : panes[0]!.id + return ( +
+ ({ key: p.id, label: labels.get(p.id)!, selected: p.id === shown }))} + onPick={(key) => onActivate(key)} + onNewTab={onNewTab} + /> +
+ {renderPane(shown, MOBILE_STRIP_PX, true)} +
+
+ ) + } + + // Desktop: the tab that holds the active pane, first tab when none does. + const at = Math.max(0, tabOf(tabs, active)) + const tree = tabs[at] + if (tree === undefined) { + // The tree has not caught up with the members yet (first list still in + // flight). One pane, plainly, until it does. + return
{renderPane(active, 0, true)}
+ } + const strip = tabs.length > 1 + const soloLeaf = 'leaf' in tree + + const body = soloLeaf ? ( + renderPane(tree.leaf, strip ? DESKTOP_STRIP_PX : 0, true) + ) : ( + onRatio(at, path, ratio)} renderPane={renderPane} /> + ) + + if (!strip) return
{body}
+ return ( +
+ { + const ids = leafIds(t) + const label = labels.get(ids[0]!) ?? `Terminal ${i + 1}` + return { + key: ids[0]!, + label: ids.length > 1 ? `${label} · ${ids.length}` : label, + selected: i === at, + } + })} + onPick={(key) => onActivate(key)} + onNewTab={onNewTab} + /> +
{body}
+
+ ) +} + +/** + * The tab strip, in flow above the panes rather than floating over them — a + * strip overlaid on a pane would sit on the first row of every full-screen + * program. Compact on a desktop, finger-sized on a phone; quiet by the nav + * rules: colour and a soft tint mark the selected tab, never weight. + */ +function Strip({ + height, + tabs, + onPick, + onNewTab, +}: { + height: 'mobile' | 'desktop' + tabs: Array<{ key: string; label: string; selected: boolean }> + onPick: (key: string) => void + onNewTab?: () => void +}) { + // The selected tab keeps itself in view: the cycle chord can land on a tab + // the strip has scrolled past, and a selection nobody can see reads as the + // chord doing nothing. By hand rather than scrollIntoView, which walks + // every scrollable ancestor and would be free to nudge the page. + const selectedEl = useRef(null) + const selectedKey = tabs.find((t) => t.selected)?.key + useEffect(() => { + const el = selectedEl.current + const strip = el?.parentElement + if (el == null || strip == null) return + const left = el.offsetLeft + const right = left + el.offsetWidth + if (left < strip.scrollLeft) strip.scrollLeft = left - 8 + else if (right > strip.scrollLeft + strip.clientWidth) { + strip.scrollLeft = right - strip.clientWidth + 8 + } + }, [selectedKey]) + + return ( +
+ {tabs.map((t) => ( + + ))} + {onNewTab !== undefined && ( + + )} +
+ ) +} + +/** + * One tab's split tree, drawn recursively: a split is a flex box of its two + * subtrees with a draggable divider between them, a leaf is a pane. + */ +function SplitTree({ + tree, + onRatio, + renderPane, +}: { + tree: PaneTree + onRatio: (path: TreePath, ratio: number) => void + renderPane: SessionGroupProps['renderPane'] +}) { + // The two boxes either side of each divider, keyed by the divider's path, + // for the drag to write sizes into directly. A drag through state would + // re-render every mounted terminal once per pointermove — sixty times a + // second — for a style change on exactly two wrapper divs. + const boxes = useRef(new Map()) + const boxFor = (key: string) => { + let entry = boxes.current.get(key) + if (entry === undefined) { + entry = { a: null, b: null } + boxes.current.set(key, entry) + } + return entry + } + + const beginDrag = useCallback( + (path: TreePath, dir: 'row' | 'column') => (e: React.PointerEvent) => { + const holder = e.currentTarget.parentElement + if (holder === null) return + const entry = boxes.current.get(path.join('')) + if (entry === undefined || entry.a === null || entry.b === null) return + const { a, b } = entry + e.preventDefault() + e.currentTarget.setPointerCapture(e.pointerId) + const box = holder.getBoundingClientRect() + const span = dir === 'row' ? box.width : box.height + const startAt = dir === 'row' ? e.clientX : e.clientY + const startRatio = Number(a.style.flexGrow) + let settled = startRatio + + const move = (ev: PointerEvent) => { + const at = dir === 'row' ? ev.clientX : ev.clientY + settled = Math.min(0.85, Math.max(0.15, startRatio + (at - startAt) / span)) + a.style.flexGrow = String(settled) + b.style.flexGrow = String(1 - settled) + } + const up = () => { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + window.removeEventListener('pointercancel', up) + // One state commit per drag, where it also persists — React's sizes + // and the DOM's agree again from the next render on. + onRatio(path, settled) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + window.addEventListener('pointercancel', up) + }, + [onRatio], + ) + + const renderNode = (node: PaneTree, path: TreePath): ReactNode => { + if ('leaf' in node) { + return ( +
{renderPane(node.leaf, 0, false)}
+ ) + } + const key = path.join('') + const entry = boxFor(key) + return ( +
+
{ + entry.a = el + }} + className="relative min-h-0 min-w-0 basis-0" + style={{ flexGrow: node.ratio }} + > + {renderNode(node.a, [...path, 'a'])} +
+
+ {/* The grab area: a 1px line is a divider, not a handle. */} + +
{ + entry.b = el + }} + className="relative min-h-0 min-w-0 basis-0" + style={{ flexGrow: 1 - node.ratio }} + > + {renderNode(node.b, [...path, 'b'])} +
+
+ ) + } + + return
{renderNode(tree, [])}
+} diff --git a/web/src/components/session-table.tsx b/web/src/components/session-table.tsx index 2ee872a..c1f8438 100644 --- a/web/src/components/session-table.tsx +++ b/web/src/components/session-table.tsx @@ -164,6 +164,7 @@ const TAG_CAP = 3 */ function SessionRow({ s, + paneCount, shown, selected, onToggleSelect, @@ -171,6 +172,8 @@ function SessionRow({ peek, }: { s: FleetSession + /** Panes folded into this row, when its group has more than one. */ + paneCount?: number shown: ColumnKey[] selected: ReadonlySet onToggleSelect: (key: string) => void @@ -200,6 +203,15 @@ function SessionRow({ {name} + {paneCount !== undefined && paneCount > 1 && ( + // The whole group folded to this one row (sessions/groups.ts), and + // the fold has to say so — a group that reads as one plain session + // is N-1 terminals nobody can account for. "Terminals", not panes + // or tabs: those are per-device renderings of the same group. + + {paneCount} terminals + + )} {s.cmd.join(' ')} @@ -387,6 +399,7 @@ function PinnedRule() { */ export function SessionTable({ groups, + panes, columns, selected, onToggleSelect, @@ -398,6 +411,12 @@ export function SessionTable({ peek, }: { groups: Group[] + /** + * keyOf(row) -> how many panes that row's session group folded into it, + * from foldGroups. Rows it does not name are ordinary sessions; the badge + * only renders past one. + */ + panes?: ReadonlyMap columns: ColumnKey[] selected: ReadonlySet onToggleSelect(key: string): void @@ -533,6 +552,7 @@ export function SessionTable({ {at === boundary && at > 0 && } ): KeyboardEvent { + return { + key: '', + code: '', + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false, + ...over, + } as KeyboardEvent +} + +describe('matchHelpChord', () => { + it('reads ⌘/ on a Mac and Ctrl+Shift+/ elsewhere', () => { + expect(matchHelpChord(key({ metaKey: true, key: '/', code: 'Slash' }), true)).toBe(true) + expect( + matchHelpChord(key({ ctrlKey: true, shiftKey: true, key: '?', code: 'Slash' }), false), + ).toBe(true) + }) + + it('never claims plain Ctrl+/ — that is readline’s undo', () => { + expect(matchHelpChord(key({ ctrlKey: true, key: '/', code: 'Slash' }), false)).toBe(false) + expect(matchHelpChord(key({ ctrlKey: true, key: '/', code: 'Slash' }), true)).toBe(false) + }) + + it('refuses extra modifiers and other keys', () => { + expect( + matchHelpChord(key({ metaKey: true, shiftKey: true, key: '/', code: 'Slash' }), true), + ).toBe(false) + expect(matchHelpChord(key({ metaKey: true, key: 'k', code: 'KeyK' }), true)).toBe(false) + }) + + it('prints the platform spelling', () => { + expect(helpChordLabel(true)).toBe('⌘/') + expect(helpChordLabel(false)).toBe('Ctrl+Shift+/') + }) +}) + +describe('ShortcutsHelp', () => { + it('opens from the chip and lists every section', async () => { + render() + expect(screen.queryByRole('dialog')).toBeNull() + + await userEvent.click(screen.getByRole('button', { name: 'Keyboard shortcuts' })) + expect(screen.getByRole('dialog')).toBeTruthy() + for (const label of ['Sessions', 'This session', 'Terminal']) { + expect(screen.getByRole('heading', { name: label })).toBeTruthy() + } + expect(screen.getByText('Split right')).toBeTruthy() + expect(screen.getByText('Scratch terminal')).toBeTruthy() + }) + + it('hides the chip for a finger, keeping the chord for a hardware keyboard', () => { + render() + expect(screen.queryByRole('button', { name: 'Keyboard shortcuts' })).toBeNull() + act(() => { + window.dispatchEvent( + new KeyboardEvent('keydown', { key: '?', code: 'Slash', ctrlKey: true, shiftKey: true }), + ) + }) + expect(screen.getByRole('dialog')).toBeTruthy() + }) + + it('toggles on its own chord', () => { + render() + // jsdom's navigator is not a Mac, so the chord is Ctrl+Shift+/. + const chord = () => + act(() => { + window.dispatchEvent( + new KeyboardEvent('keydown', { key: '?', code: 'Slash', ctrlKey: true, shiftKey: true }), + ) + }) + chord() + expect(screen.getByRole('dialog')).toBeTruthy() + chord() + expect(screen.queryByRole('dialog')).toBeNull() + }) +}) diff --git a/web/src/components/shortcuts-help.tsx b/web/src/components/shortcuts-help.tsx new file mode 100644 index 0000000..9a88f8e --- /dev/null +++ b/web/src/components/shortcuts-help.tsx @@ -0,0 +1,187 @@ +import { useEffect, useMemo, useState, type CSSProperties } from 'react' +import { Dialog } from 'radix-ui' +import { KeyboardIcon, XIcon } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { + newTabChordLabel, + splitChordLabel, + tabCycleChordLabel, +} from '@/lib/split-keys' +import { cn } from '@/lib/utils' +import { isApplePlatform, openChordLabel } from '@/switcher/keys' + +/** + * Whether a keystroke asked for this card. ⌘/ on a Mac — the palette-help + * spelling every app with a palette uses, and a Cmd chord never reaches the + * shell. Elsewhere it is Ctrl+Shift+/, in the Ctrl+Shift namespace the + * terminal reserves for its own chrome (#64): plain Ctrl+/ is readline's + * undo and may not be taken. Matched on `code` first, as every chord here + * is — Shift turns / into ? on most layouts. + */ +export function matchHelpChord(e: KeyboardEvent, apple: boolean): boolean { + const slash = e.code === 'Slash' || e.key === '/' || e.key === '?' + if (!slash) return false + if (apple) return e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey + return e.ctrlKey && e.shiftKey && !e.metaKey && !e.altKey +} + +/** How the help chord is printed. */ +export function helpChordLabel(apple: boolean): string { + return apple ? '⌘/' : 'Ctrl+Shift+/' +} + +interface Row { + what: string + keys: string +} + +interface Section { + label: string + rows: Row[] +} + +/** + * Everything the keyboard can do here, spelled for this keyboard. A static + * card rather than something detected: it is documentation, and a row for a + * verb this daemon cannot serve still teaches what flue is. + */ +function sections(apple: boolean): Section[] { + return [ + { + label: 'Sessions', + rows: [ + { what: 'Switch session', keys: openChordLabel(apple) }, + { what: 'Next / previous session', keys: apple ? '⌃⇧] ⌃⇧[' : 'Ctrl+Shift+] [' }, + { what: 'Pinned session 1–9', keys: apple ? '⌃⇧1–9' : 'Ctrl+Shift+1–9' }, + ], + }, + { + label: 'This session', + rows: [ + { what: 'Split right', keys: splitChordLabel(apple, 'row') }, + { what: 'Split down', keys: splitChordLabel(apple, 'column') }, + { what: 'New tab', keys: newTabChordLabel(apple) }, + { what: 'Next / previous tab', keys: tabCycleChordLabel(apple) }, + { what: 'Scratch terminal', keys: apple ? '⌃ ⌃ (double-tap)' : 'Ctrl Ctrl (double-tap)' }, + ], + }, + { + label: 'Terminal', + rows: [ + // The literal matches TERMINAL_SHORTCUT_HINT in terminal.tsx; spelled + // out here because importing it would close a cycle. + { what: 'Focus mode — every key to the shell', keys: 'Ctrl+Shift+Enter' }, + { what: 'Copy the selection', keys: apple ? '⌘C' : 'Ctrl+C (with a selection)' }, + { what: 'This card', keys: helpChordLabel(apple) }, + ], + }, + ] +} + +/** + * The keyboard shortcuts card and the chip that opens it. + * + * A chip in the terminal's control strip, because the chords it teaches are + * the terminal's — and because the pills that used to teach them are gone in + * a hundred milliseconds on a local daemon. One instance per surface: the + * route mounts the strip on one pane, so the chord listener here is single + * too. + */ +export function ShortcutsHelp({ + chipStyle, + chip = true, +}: { + chipStyle: CSSProperties + /** + * Whether to draw the chip at all. False on a coarse pointer: a card of + * keyboard chords is furniture on a phone. The chord listener stays either + * way — an iPad grows a hardware keyboard without changing its pointer, + * and ⌘/ should answer it. + */ + chip?: boolean +}) { + const apple = useMemo(() => isApplePlatform(), []) + const [open, setOpen] = useState(false) + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!matchHelpChord(e, apple)) return + // Capture, and stopped, for the reason every chord here is: left to + // bubble, xterm turns the keystroke into bytes first. + e.preventDefault() + e.stopPropagation() + setOpen((v) => !v) + } + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) + }, [apple]) + + return ( + + {chip && ( + + + + )} + + + +
+
+
+ {sections(apple).map((section) => ( +
+

+ {section.label} +

+
+ {section.rows.map((row) => ( +
+
+ {row.what} +
+
+ {row.keys} +
+
+ ))} +
+
+ ))} +
+
+
+
+ ) +} diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index 2dfa8b5..328d63e 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -1790,19 +1790,30 @@ function session(over: Partial = {}): SessionInfo { } } -describe('the exit overlay', () => { - it('appears when the shell exits, naming the code', () => { - const { sock } = mountTerminal((em) => ) +describe('an exited shell', () => { + it('hands the exit straight to onClosed — no overlay, no question', () => { + const onClosed = vi.fn() + const { sock } = mountTerminal((em) => ( + + )) act(() => sock.emitControl(attached({ ref: 1, id: 's1' }))) - act(() => sock.emitControl({ type: 'exit', ref: 1, code: 130 })) + expect(onClosed).not.toHaveBeenCalled() - const card = screen.getByRole('alertdialog') - expect(card.getAttribute('aria-label')).toBe('shell exited (130)') - expect(card.textContent).toContain('(130)') + act(() => sock.emitControl({ type: 'exit', ref: 1, code: 130 })) + // What closing means — fold a pane, dismiss the scratch modal, leave the + // route, or keep a corpse on screen to read — is the caller's decision, + // which is why the exit is reported rather than acted on. + expect(onClosed).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('alertdialog')).toBeNull() + // Nothing is sent for the dead session: the exit already retired its ref + // on both ends, and the daemon reaps it on its own schedule. + expect(sock.ofType('close')).toHaveLength(0) }) - it('dims the terminal but leaves it in the tree, scrollback intact', () => { + it('dims the scrollback for a view that stays', () => { + // A caller that keeps the view — reading a session that was already over + // — gets the dimmed pane and the pill, with the scrollback intact. const { sock } = mountTerminal((em) => ) act(() => sock.emitControl(attached({ ref: 1, id: 's1' }))) @@ -1810,52 +1821,6 @@ describe('the exit overlay', () => { act(() => sock.emitControl({ type: 'exit', ref: 1, code: 0 })) expect(inset().className).toContain('opacity-60') - // The wrapper must not eat events meant for the scrollback under it. - expect(screen.getByRole('alertdialog').parentElement!.className).toContain('pointer-events-none') - }) - - it('Restart spawns in the dead session’s directory, closes it, and hands over', () => { - const onRestarted = vi.fn() - const { sock } = mountTerminal((em) => ( - - )) - - act(() => sock.emitControl(attached({ ref: 1, id: 's1' }))) - act(() => sock.emitControl({ type: 'sessions', sessions: [session()] })) - act(() => sock.emitControl({ type: 'exit', ref: 1, code: 0 })) - - fireEvent.click(screen.getByRole('button', { name: 'Restart' })) - const spawns = sock.ofType('spawn') - expect(spawns).toHaveLength(1) - expect(spawns[0]).toMatchObject({ cwd: '/home/karn/code', cols: 80, rows: 24 }) - - // A second click while the first is unanswered must not start a second - // shell. - fireEvent.click(screen.getByRole('button', { name: 'Restart' })) - expect(sock.ofType('spawn')).toHaveLength(1) - - const reqId = spawns[0]!.reqId as number - act(() => sock.emitControl(attached({ ref: 9, id: 's2', reqId }))) - - // The new ref goes straight back — the next route attaches for itself. - // Nothing is sent for the dead session: the exit already retired its ref - // on both ends, and the daemon reaps it after ExitedRetention. - expect(sock.ofType('detach')).toContainEqual({ type: 'detach', ref: 9 }) - expect(onRestarted).toHaveBeenCalledWith('s2') - }) - - it('Close just leaves — the daemon reaps an exited session on its own', () => { - const onClosed = vi.fn() - const { sock } = mountTerminal((em) => ( - - )) - - act(() => sock.emitControl(attached({ ref: 1, id: 's1' }))) - act(() => sock.emitControl({ type: 'exit', ref: 1, code: 1 })) - - fireEvent.click(screen.getByRole('button', { name: 'Close' })) - expect(sock.ofType('close')).toHaveLength(0) - expect(onClosed).toHaveBeenCalled() }) }) diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index f60be83..128d1fa 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -1,17 +1,26 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' -import { ArrowLeftRightIcon, LayoutGridIcon, PlusIcon } from 'lucide-react' +import { DropdownMenu } from 'radix-ui' +import { + ArrowLeftRightIcon, + Columns2Icon, + LayoutGridIcon, + PanelTopIcon, + PlusIcon, + Rows2Icon, + SquareTerminalIcon, +} from 'lucide-react' import { useFlueClient } from '@/client/provider' -import { ExitOverlay } from '@/components/exit-overlay' import { KeyBar } from '@/components/key-bar' import { PasteBox } from '@/components/paste-box' import { SelectionMenu, type MenuEnd } from '@/components/selection-menu' +import { ShortcutsHelp } from '@/components/shortcuts-help' import { ThemeMenu } from '@/components/theme-menu' import { DARK_SCHEME_QUERY, prefersDark } from '@/emulator/palette' import { controlColors, resolveTheme, THEME_SYSTEM } from '@/emulator/themes' import type { Emulator } from '@/emulator/types' import { createXtermEmulator, type XtermOptions } from '@/emulator/xterm' -import { loadThemePref, saveThemePref, THEME_PREF_KEY } from '@/lib/theme-pref' +import { loadThemePref, onThemePref, saveThemePref, THEME_PREF_KEY } from '@/lib/theme-pref' import { cellAt, cellBox, @@ -23,9 +32,11 @@ import { } from '@/lib/geometry' import { startGlide } from '@/lib/glide' import { createKeyboardModes, type KeyboardMode } from '@/lib/keyboard' +import { newTabChordLabel, splitChordLabel, type GroupLayout } from '@/lib/split-keys' import { barKeyBytes, ctrlTransform, type BarKey } from '@/lib/keys' import { cn } from '@/lib/utils' import { trackVisualViewport, zoomedIn } from '@/lib/viewport' +import { useScratch } from '@/scratch/context' import { isApplePlatform, openChordLabel } from '@/switcher/keys' import { useSwitcher } from '@/switcher/provider' @@ -48,11 +59,13 @@ export interface TerminalProps { */ createEmulator?: (opts: XtermOptions) => Emulator /** - * Called with the new session's id once a Restart's spawn has attached. - * The route supplies navigation; the component never touches the router. + * Called the moment the shell exits, and by nothing else. What "closed" + * means belongs to the view above: a split pane folds away, the scratch + * modal dismisses, a lone session leaves for the dashboard — and a session + * that was *already* exited when it was opened is the caller's to keep on + * screen, which is why the daemon's whole exited-retention window exists. + * The component never touches the router. */ - onRestarted?: (sessionId: string) => void - /** Called after Close has closed the dead session; navigate away here. */ onClosed?: () => void /** * Called by the `+` in the control strip, with this session's directory when @@ -64,6 +77,46 @@ export interface TerminalProps { * would be a terminal that could not be mounted without one. */ onNewSession?: (cwd: string | null) => void + /** + * Called by the split and new-tab rows in the `+` menu, with this + * session's directory and how the group should render from now on. The + * rows only exist when this is provided, which is how the route + * feature-detects: no handler on a daemon without the `multiplex` cap, no + * rows. What a split *is* — a member session, a pane, a tab — lives above + * this component with the group layout; the terminal only offers the + * verbs. + */ + onSplit?: (cwd: string | null, layout: GroupLayout) => void + /** + * How much floating chrome to draw. `minimal` is for the scratch modal, + * which has its own frame and its own dismiss: the status pill stays — a + * scratch that cannot say "Reconnecting…" is a black box — and every + * navigation chip goes, because navigating away from inside a modal is not + * a place anyone means to go. + */ + chrome?: 'full' | 'minimal' + /** + * Whether the pane pins itself to the visual viewport (lib/viewport.ts). + * True everywhere the terminal is the page — which is what the pinning + * formula assumes. The desktop scratch modal turns it off: a centered + * dialog is not at the top of the page, and a pane forced to viewport + * height would burst it. + */ + fitViewport?: boolean + /** + * Height in px of in-flow chrome above the pane — the mobile tab strip. + * Read once at mount (it feeds the viewport tracker); the group view keys + * this component on it, so a strip appearing remounts rather than drifts. + */ + viewportInset?: number + /** + * Whether this view writes the browser tab's title. True everywhere the + * terminal is the tab's subject; the route turns it off for every split + * pane that is not the URL session, and the scratch modal always — with + * several mounted at once, whoever registered last would otherwise name + * the tab. + */ + ownsTitle?: boolean } /** Named so the test and the markup cannot drift apart. */ @@ -151,12 +204,17 @@ const PRESS_SLOP = 10 export function Terminal({ sessionId, createEmulator = createXtermEmulator, - onRestarted, onClosed, onNewSession, + onSplit, + chrome = 'full', + fitViewport = true, + viewportInset = 0, + ownsTitle = true, }: TerminalProps) { const client = useFlueClient() const switcher = useSwitcher() + const scratch = useScratch() // Which modifier this keyboard actually has, for the chip's tooltip. Once per // mount: nobody swaps a Mac for a ThinkPad mid-session. const chordLabel = useMemo(() => openChordLabel(isApplePlatform()), []) @@ -187,7 +245,6 @@ export function Terminal({ const [ctrlArmed, setCtrlArmed] = useState(false) const ctrlArmedRef = useRef(ctrlArmed) ctrlArmedRef.current = ctrlArmed - const [exitCode, setExitCode] = useState(null) // The touch menu: which end of the terminal it is at, and whether the // press it came from found anything to copy. Null for not showing. // @@ -222,7 +279,6 @@ export function Terminal({ // ends, and an exited session reaps itself after ExitedRetention — Close // just leaves. const actionsRef = useRef<{ - restart: (dir: string | null) => void applyTheme: (id: string) => void sendKey: (key: BarKey) => void copy: () => void @@ -230,10 +286,14 @@ export function Terminal({ pasteText: (text: string) => void dismiss: () => void } | null>(null) - // The latest onRestarted, readable from inside the effect without putting - // a prop identity in its dependency array. - const restartedRef = useRef(onRestarted) - restartedRef.current = onRestarted + // The latest onClosed, readable from inside the effect without putting a + // prop identity in its dependency array. + const closedRef = useRef(onClosed) + closedRef.current = onClosed + // Same treatment for tab-title ownership, which flips without a remount + // when the URL moves between two panes of one group. + const ownsTitleRef = useRef(ownsTitle) + ownsTitleRef.current = ownsTitle useEffect(() => { const pane = paneRef.current @@ -337,9 +397,6 @@ export function Terminal({ // reconnect must not walk the pill back to "Reconnecting…" and imply that // waiting will help. let over = false - // The reqId of a Restart's spawn, unanswered. Doubles as the click guard: - // one restart in flight at a time, per mount. - let restartReq: number | null = null let frame = 0 // The pty-resize debounce. A browser sidebar sliding open resizes the // pane on every animation frame, and each new pty size is a SIGWINCH the @@ -365,6 +422,7 @@ export function Terminal({ let tabOsc = '' let tabCwd = '' const retitle = () => { + if (!ownsTitleRef.current) return const at = tabOsc || tabCwd const text = tabName && at ? `${tabName} — ${at}` : tabName || at if (text) document.title = text @@ -738,13 +796,17 @@ export function Terminal({ // The pane hugs the visual viewport: a phone keyboard shrinks it and the // ResizeObserver below refits the terminal above the keyboard. While - // pinch-zoomed it instead releases the surface so one finger pans. - const untrackViewport = trackVisualViewport({ - pane, - surface, - gestureArea: inner, - viewport: window.visualViewport, - }) + // pinch-zoomed it instead releases the surface so one finger pans. The + // desktop scratch modal opts out — see fitViewport on the props. + const untrackViewport = fitViewport + ? trackVisualViewport({ + pane, + surface, + gestureArea: inner, + viewport: window.visualViewport, + topInset: viewportInset, + }) + : () => {} // Every registration returns an unsubscribe, and all of them are released // on cleanup: the client outlives this view by design. @@ -783,16 +845,6 @@ export function Terminal({ offs.push( client.onAttached((a) => { - if (restartReq !== null && a.reqId === restartReq) { - // The Restart's own spawn. Hand the new ref straight back — the - // route this navigates to attaches for itself — and go. The dead - // session needs nothing: the exit already retired its ref on both - // ends, and the daemon reaps it after ExitedRetention. - restartReq = null - client.detach(a.ref) - restartedRef.current?.(a.id) - return - } // One client serves the whole tab, so every listener sees every reply. if (a.id !== sessionId) return ref = a.ref @@ -858,8 +910,14 @@ export function Terminal({ if (r !== ref) return over = true emulator.write(EXIT_NOTICE(code)) - setExitCode(code) setPhase('exited') + // The view above decides what an exit closes — a pane, a modal, the + // whole route — or keeps: a session opened after it had already + // exited is being read, and reading is the point of the daemon's + // exited-retention window. There is no overlay in between; a shell + // that ends is over, and asking Restart-or-Close on every `exit 0` + // answered a question nobody was asking. + closedRef.current?.() }), ) @@ -905,6 +963,18 @@ export function Terminal({ const onKey = (e: KeyboardEvent) => { if (e.key !== 'Enter' || !e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return + // With splits and the scratch modal, several Terminals listen on this + // window at once, and stopPropagation cannot silence siblings on the + // same target and phase — unguarded, every pane would race its own + // requestFullscreen. The pane holding the keyboard answers the chord; + // when no pane holds it, only a lone terminal may (a chord over an + // ambiguous split does nothing rather than something arbitrary). + const active = document.activeElement + if (!pane.contains(active)) { + const owner = active instanceof Element ? active.closest('[data-flue-inset]') : null + if (owner !== null) return + if (document.querySelectorAll('[data-flue-inset]').length > 1) return + } // Capture, and stopped here. Left to bubble, xterm's own handler on the // helper textarea would already have turned it into a carriage return // and sent it to the shell before this ran. @@ -937,14 +1007,6 @@ export function Terminal({ media?.addEventListener?.('change', onScheme) actionsRef.current = { - // Click-driven only, for the same StrictMode reason session creation - // lives on the sessions screen: a spawn fired from a mount effect runs - // twice and can only ever detach one of its shells. - restart: (dir) => { - if (restartReq !== null) return - const reqId = client.spawn({ cwd: dir ?? undefined, cols: dims.cols, rows: dims.rows }) - if (reqId !== null) restartReq = reqId - }, applyTheme: (id) => { const next = resolveTheme(id, prefersDark()) emulator.setTheme(next) @@ -1008,9 +1070,8 @@ export function Terminal({ // Another tab choosing a theme lands here: the preference is global, and // a change should sweep every open terminal, not just the one clicked. - // The storage event only ever fires in *other* tabs; the clicking tab - // goes through handleTheme. A null key is storage.clear() — back to - // system either way. + // The storage event only ever fires in *other* tabs. A null key is + // storage.clear() — back to system either way. const onStorage = (e: StorageEvent) => { if (e.key !== null && e.key !== THEME_PREF_KEY) return const id = e.newValue ?? THEME_SYSTEM @@ -1018,6 +1079,15 @@ export function Terminal({ actionsRef.current?.applyTheme(id) } window.addEventListener('storage', onStorage) + // And a choice made in *this* document lands here — the half the storage + // event cannot deliver. With splits and the scratch modal, several + // terminals share one document, and the menu that took the click sits on + // exactly one of them; without this, that pane repainted and its + // siblings kept the old clothes until remount. + const offThemePref = onThemePref((id) => { + setThemeId(id) + actionsRef.current?.applyTheme(id) + }) client.attach(sessionId, 0) // For the cwd: `attached` does not carry it, the session list does. @@ -1036,6 +1106,7 @@ export function Terminal({ glide?.() untrackViewport() window.removeEventListener('storage', onStorage) + offThemePref() window.removeEventListener('keydown', onKey, true) observer.disconnect() media?.removeEventListener?.('change', onScheme) @@ -1048,7 +1119,9 @@ export function Terminal({ // what React's double-invoked mount effect does on every mount. if (ref !== null) client.detach(ref) else client.forget(sessionId) - document.title = priorTitle + // Only the title's owner restores it: a split pane unmounting must not + // blank the name the URL pane is still entitled to. + if (ownsTitleRef.current) document.title = priorTitle if (canvas.style.backgroundColor === paintedCanvas) { canvas.style.backgroundColor = priorCanvas } @@ -1057,14 +1130,16 @@ export function Terminal({ } emulator.dispose() } - }, [client, sessionId, createEmulator]) + // fitViewport and viewportInset rebuild the emulator on change by design: + // both describe the box the terminal lives in, and the group view keys + // this component on them anyway. + }, [client, sessionId, createEmulator, fitViewport, viewportInset]) - const handleRestart = () => actionsRef.current?.restart(cwd) - const handleClose = () => onClosed?.() const handleTheme = (id: string) => { setThemeId(id) + // The save announces to every terminal in this document — this pane + // included — so the apply is not repeated here. saveThemePref(id) - actionsRef.current?.applyTheme(id) } // What the floating controls wear: the resolved theme's own surfaces, @@ -1083,6 +1158,10 @@ export function Terminal({
- + {chrome === 'full' && } + {chrome === 'full' && ( + <> {/* The way to another session without leaving this one. @@ -1194,29 +1275,45 @@ export function Terminal({ Open the flue dashboard {/* - A button, and it used to be a link to `/?cwd=` — which is the - dashboard, so a session started from here came up behind the whole - list for as long as the daemon took to answer. It asks first now: - the dialog above this component collects a name and tags while there - is still a form to collect them on, and the page it opens starts the - session on its own ground. - - The same box as the theme trigger — an icon in px-2.5 py-1.5 — so the - cluster reads as one control strip, not two heights. + One `+` for everything that creates a terminal, so the strip stays + four chips however many verbs a daemon offers. On a daemon without + the `multiplex` cap (no onSplit, no scratch) it is the plain + new-session button it always was; otherwise it opens a menu of the + four verbs with their chords beside them — which is also where the + chords are taught, the way the switcher chip's tooltip teaches ⌘K. */} - + {onSplit === undefined && !scratch.enabled ? ( + + ) : ( + onNewSession?.(cwd)} + onSplit={onSplit === undefined ? undefined : (layout) => onSplit(cwd, layout)} + onScratch={scratch.enabled ? scratch.toggle : undefined} + /> + )} + {/* + Every chord in one card, because the pills that used to teach them + are gone in a hundred milliseconds on a local daemon and the + tooltips teach one chord each. No chip for a finger — the card is + keyboard education — though the chord still answers a hardware + keyboard on a coarse-pointer device. + */} + + + )} {phase !== 'live' && ( // Dark in both themes, like the pane it floats over usually is; the // translucent ground and backdrop-blur keep it legible over whatever @@ -1270,9 +1367,6 @@ export function Terminal({
)}
- {phase === 'exited' && exitCode !== null && ( - - )} ) } @@ -1284,3 +1378,93 @@ const NOTICE: Record, string> = { gone: 'This session is gone', revoked: 'This device was revoked', } + +/** + * The `+` menu: every verb that creates a terminal from here, in one chip. + * Dressed in the terminal's control colours like the theme menu beside it, + * and for the same reason spelled the same way — the content portals outside + * the pane that carries the --chip-* variables, so they are set again on the + * content explicitly. + */ +function PlusMenu({ + chipStyle, + onNewSession, + onSplit, + onScratch, +}: { + chipStyle: CSSProperties + onNewSession: () => void + onSplit?: (layout: GroupLayout) => void + onScratch?: () => void +}) { + // Once per mount, like the switcher chip's tooltip: nobody swaps keyboards + // mid-session. + const apple = useMemo(() => isApplePlatform(), []) + const itemClass = cn( + 'flex cursor-default items-center gap-x-2.5 rounded-md px-2.5 py-1.5', + 'text-base/6 text-(--chip-fg) outline-none select-none sm:text-sm/6', + 'data-[highlighted]:bg-(--chip-wash)', + ) + const chordClass = 'ml-6 font-mono text-xs text-(--chip-dim)' + + return ( + + + + + + + + + {onSplit !== undefined && ( + <> + onSplit('row')}> + + onSplit('column')}> + + onSplit('tabs')}> + + + )} + {onScratch !== undefined && ( + + + )} + + + + ) +} diff --git a/web/src/fleet/fleet.ts b/web/src/fleet/fleet.ts index 9c8a491..bdf39bb 100644 --- a/web/src/fleet/fleet.ts +++ b/web/src/fleet/fleet.ts @@ -986,6 +986,12 @@ export class FleetClient { for (const slot of this.slots) { if (slot.rows === null) continue for (const row of slot.rows) { + // Ephemeral sessions are hidden here, at the one seam every fleet + // consumer reads through — the sessions list, the switcher, the + // recents. A scratch terminal is reachable from exactly one place, + // the session it was opened over, and that surface talks to the + // FlueClient directly rather than to these rows. + if (row.ephemeral) continue out.push({ ...row, machineId: slot.id, machineName: slot.name }) } } diff --git a/web/src/lib/split-keys.test.ts b/web/src/lib/split-keys.test.ts new file mode 100644 index 0000000..7920565 --- /dev/null +++ b/web/src/lib/split-keys.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' + +import { + matchNewTabChord, + matchSplitChord, + matchTabCycleChord, + newTabChordLabel, + splitChordLabel, + tabCycleChordLabel, +} from './split-keys' + +function key(over: Partial): KeyboardEvent { + return { + key: '', + code: '', + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false, + ...over, + } as KeyboardEvent +} + +describe('matchSplitChord', () => { + it('reads ⌘D as a side-by-side split and ⇧⌘D as a stacked one on a Mac', () => { + expect(matchSplitChord(key({ metaKey: true, key: 'd', code: 'KeyD' }), true)).toBe('row') + expect( + matchSplitChord(key({ metaKey: true, shiftKey: true, key: 'D', code: 'KeyD' }), true), + ).toBe('column') + }) + + it('reads Ctrl+Shift+D and Ctrl+Alt+Shift+D elsewhere', () => { + expect( + matchSplitChord(key({ ctrlKey: true, shiftKey: true, key: 'D', code: 'KeyD' }), false), + ).toBe('row') + expect( + matchSplitChord( + key({ ctrlKey: true, shiftKey: true, altKey: true, key: 'D', code: 'KeyD' }), + false, + ), + ).toBe('column') + }) + + it('never claims plain Ctrl+D — that is the shell EOF, not ours', () => { + expect(matchSplitChord(key({ ctrlKey: true, key: 'd', code: 'KeyD' }), false)).toBeNull() + expect(matchSplitChord(key({ ctrlKey: true, key: 'd', code: 'KeyD' }), true)).toBeNull() + }) + + it('refuses extra modifiers riding along', () => { + expect( + matchSplitChord(key({ metaKey: true, ctrlKey: true, key: 'd', code: 'KeyD' }), true), + ).toBeNull() + expect( + matchSplitChord( + key({ ctrlKey: true, shiftKey: true, metaKey: true, key: 'D', code: 'KeyD' }), + false, + ), + ).toBeNull() + }) + + it('matches on the physical key when the layout produced another character', () => { + // Shift over a non-US layout can spell D as something else entirely; + // `code` names the key itself, as the switcher's brackets do. + expect(matchSplitChord(key({ metaKey: true, key: 'Δ', code: 'KeyD' }), true)).toBe('row') + }) + + it('ignores other keys entirely', () => { + expect(matchSplitChord(key({ metaKey: true, key: 'e', code: 'KeyE' }), true)).toBeNull() + }) +}) + +describe('matchNewTabChord', () => { + it('reads ⌥⌘T on a Mac — through the † the layout makes of Alt+T', () => { + expect(matchNewTabChord(key({ metaKey: true, altKey: true, key: '†', code: 'KeyT' }), true)).toBe( + true, + ) + }) + + it('reads Ctrl+Alt+T elsewhere and never the browser-owned spellings', () => { + expect( + matchNewTabChord(key({ ctrlKey: true, altKey: true, key: 't', code: 'KeyT' }), false), + ).toBe(true) + // ⌘T and Ctrl+T open a browser tab; ⇧ variants reopen one. All refused. + expect(matchNewTabChord(key({ metaKey: true, key: 't', code: 'KeyT' }), true)).toBe(false) + expect(matchNewTabChord(key({ ctrlKey: true, key: 't', code: 'KeyT' }), false)).toBe(false) + expect( + matchNewTabChord( + key({ ctrlKey: true, altKey: true, shiftKey: true, key: 'T', code: 'KeyT' }), + false, + ), + ).toBe(false) + }) +}) + +describe('matchTabCycleChord', () => { + it('steps right and left on the same modifier family as new-tab', () => { + const mods = { metaKey: true, altKey: true } + expect(matchTabCycleChord(key({ ...mods, key: 'ArrowRight', code: 'ArrowRight' }), true)).toBe(1) + expect(matchTabCycleChord(key({ ...mods, key: 'ArrowLeft', code: 'ArrowLeft' }), true)).toBe(-1) + const other = { ctrlKey: true, altKey: true } + expect(matchTabCycleChord(key({ ...other, key: 'ArrowRight', code: 'ArrowRight' }), false)).toBe( + 1, + ) + }) + + it('leaves plain and shifted arrows to the shell and the browser', () => { + expect(matchTabCycleChord(key({ key: 'ArrowRight', code: 'ArrowRight' }), true)).toBeNull() + expect( + matchTabCycleChord( + key({ metaKey: true, altKey: true, shiftKey: true, key: 'ArrowLeft', code: 'ArrowLeft' }), + true, + ), + ).toBeNull() + }) +}) + +describe('chord labels', () => { + it('print the platform spelling', () => { + expect(splitChordLabel(true, 'row')).toBe('⌘D') + expect(splitChordLabel(true, 'column')).toBe('⇧⌘D') + expect(splitChordLabel(false, 'row')).toBe('Ctrl+Shift+D') + expect(splitChordLabel(false, 'column')).toBe('Ctrl+Alt+Shift+D') + expect(newTabChordLabel(true)).toBe('⌥⌘T') + expect(newTabChordLabel(false)).toBe('Ctrl+Alt+T') + expect(tabCycleChordLabel(true)).toBe('⌥⌘← →') + expect(tabCycleChordLabel(false)).toBe('Ctrl+Alt+← →') + }) +}) diff --git a/web/src/lib/split-keys.ts b/web/src/lib/split-keys.ts new file mode 100644 index 0000000..2c3af2f --- /dev/null +++ b/web/src/lib/split-keys.ts @@ -0,0 +1,92 @@ +/* + * The split chords, next of kin to the switcher's (switcher/keys.ts) and + * bound by the same three constraints: browser-reserved combinations, a + * terminal underneath that owns most keys, and layouts that move characters + * around. The survivors: + * + * - Mac: ⌘D splits side by side, ⇧⌘D splits stacked — the pair Supacode + * and VS Code taught. A Cmd chord never reaches the shell, and Chrome + * lets a page preventDefault its bookmark shortcut. + * - Elsewhere: Ctrl+Shift+D and Ctrl+Alt+Shift+D. Plain Ctrl+D is EOF — + * the keystroke that closes shells — and may never be taken; Ctrl+Shift + * is the namespace a terminal emulator conventionally reserves for its + * own chrome (#64), and Alt is the only modifier left to say "the other + * axis". + */ + +/** + * Which way one split lies: `row` is side by side (a split to the right), + * `column` is stacked (a split downward). Axes mix freely — every split in + * the tree (sessions/pane-tree.ts) carries its own. + */ +export type SplitDirection = 'row' | 'column' + +/** + * The three verbs that add a pane: split the asking pane along an axis, or + * open a new tab beside its tab. Tabs and splits compose — a tab holds a + * whole split tree — so this names the placement of one new pane, never a + * mode the group switches into. + */ +export type GroupLayout = SplitDirection | 'tabs' + +/** What a keystroke asked the split to do, or null for "not ours". */ +export function matchSplitChord(e: KeyboardEvent, apple: boolean): SplitDirection | null { + // `code` first for the reason the switcher's brackets use it — `key` is + // what the layout produced under Shift — with the character accepted + // behind it for layouts that report one. + const d = e.code === 'KeyD' || (e.key.length === 1 && e.key.toLowerCase() === 'd') + if (!d) return null + + if (apple) { + if (!e.metaKey || e.ctrlKey || e.altKey) return null + return e.shiftKey ? 'column' : 'row' + } + + if (!e.ctrlKey || !e.shiftKey || e.metaKey) return null + return e.altKey ? 'column' : 'row' +} + +/** How a split chord is printed, for menu rows and tooltips. */ +export function splitChordLabel(apple: boolean, dir: SplitDirection): string { + if (apple) return dir === 'row' ? '⌘D' : '⇧⌘D' + return dir === 'row' ? 'Ctrl+Shift+D' : 'Ctrl+Alt+Shift+D' +} + +/** + * Whether a keystroke asked for a new tab. ⌥⌘T on a Mac and Ctrl+Alt+T + * elsewhere, because the two natural spellings are both browser property: + * ⌘T/Ctrl+T opens a browser tab and ⇧⌘T/Ctrl+Shift+T reopens one, and + * neither can be taken back (#64). Matched on `code`: with Alt held a Mac + * layout produces † for the T key, which is exactly the trap the split + * chords dodge the same way. + */ +export function matchNewTabChord(e: KeyboardEvent, apple: boolean): boolean { + const t = e.code === 'KeyT' || (e.key.length === 1 && e.key.toLowerCase() === 't') + if (!t) return false + if (apple) return e.metaKey && e.altKey && !e.ctrlKey && !e.shiftKey + return e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey +} + +/** How the new-tab chord is printed. */ +export function newTabChordLabel(apple: boolean): string { + return apple ? '⌥⌘T' : 'Ctrl+Alt+T' +} + +/** + * Which neighbouring tab a keystroke asked for, or null. ⌥⌘←/→ on a Mac and + * Ctrl+Alt+←/→ elsewhere — the same family as the new-tab chord, because + * they are the same subject, and the browser owns every conventional + * spelling (Ctrl+Tab, ⌘⇧[ and ]). The switcher's ⌃⇧[ ] keeps meaning + * *sessions*; this one walks the tabs inside the group on screen. + */ +export function matchTabCycleChord(e: KeyboardEvent, apple: boolean): -1 | 1 | null { + const step = e.code === 'ArrowRight' ? 1 : e.code === 'ArrowLeft' ? -1 : null + if (step === null) return null + if (apple) return e.metaKey && e.altKey && !e.ctrlKey && !e.shiftKey ? step : null + return e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey ? step : null +} + +/** How the tab-cycle pair is printed. */ +export function tabCycleChordLabel(apple: boolean): string { + return apple ? '⌥⌘← →' : 'Ctrl+Alt+← →' +} diff --git a/web/src/lib/theme-pref.test.ts b/web/src/lib/theme-pref.test.ts index 5c1e99a..ffee317 100644 --- a/web/src/lib/theme-pref.test.ts +++ b/web/src/lib/theme-pref.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { THEME_SYSTEM } from '@/emulator/themes' -import { loadThemePref, saveThemePref, THEME_PREF_KEY } from './theme-pref' +import { loadThemePref, onThemePref, saveThemePref, THEME_PREF_KEY } from './theme-pref' afterEach(() => localStorage.clear()) @@ -23,4 +23,28 @@ describe('the theme preference', () => { expect(localStorage.getItem(THEME_PREF_KEY)).toBeNull() expect(loadThemePref()).toBe(THEME_SYSTEM) }) + + it('announces every save to this document — how a split repaints all its panes', () => { + // The storage event only reaches other tabs, so with several terminals + // in one document (a split, the scratch modal) this listener is the only + // way a sibling pane hears the choice. + const heard: string[] = [] + const offA = onThemePref((id) => heard.push(`a:${id}`)) + const offB = onThemePref((id) => heard.push(`b:${id}`)) + saveThemePref('dracula') + expect(heard).toEqual(['a:dracula', 'b:dracula']) + + // System announces too — back-to-default must sweep the panes as well. + saveThemePref(THEME_SYSTEM) + expect(heard.slice(2)).toEqual([`a:${THEME_SYSTEM}`, `b:${THEME_SYSTEM}`]) + + // An unsubscribed listener stays quiet, and only its own registration + // retires. + offA() + saveThemePref('nord') + expect(heard.slice(4)).toEqual(['b:nord']) + offB() + saveThemePref('dracula') + expect(heard).toHaveLength(5) + }) }) diff --git a/web/src/lib/theme-pref.ts b/web/src/lib/theme-pref.ts index da7f50c..7573831 100644 --- a/web/src/lib/theme-pref.ts +++ b/web/src/lib/theme-pref.ts @@ -6,8 +6,16 @@ import { THEME_SYSTEM } from '@/emulator/themes' * Global on purpose — choosing a theme in one session applies to all of * them, and a session spawned out of another wears the same clothes with no * inheritance machinery at all. Per-session themes can come back later by - * suffixing the key; the storage-event listeners in the terminal views are - * what make a change land in every open tab the moment it is made. + * suffixing the key. + * + * A change travels two ways, because the browser splits the audience in two. + * The storage event reaches every *other* tab — and only them; the document + * that wrote never hears it. The listeners below are the other half: every + * terminal mounted in *this* document — the panes of a split, the scratch + * modal — hears a save the moment it is made. Before splits there was one + * terminal per document and the first half sufficed; a theme picked from a + * split's menu then repainted exactly one pane and left its siblings in the + * old clothes until remount. * * Client-side because the daemon holds what a terminal *is*; what it looks * like in this browser is this browser's business. Every access is guarded: @@ -34,4 +42,23 @@ export function saveThemePref(themeId: string): void { } catch { // Nothing to do: the theme still applies for this view's lifetime. } + // Announced whether or not the write stuck: the panes on screen should + // follow the choice even where storage refuses to remember it. + for (const cb of [...listeners]) cb(themeId) +} + +type ThemePrefListener = (themeId: string) => void + +const listeners = new Set() + +/** + * Hear theme choices made in this document — the half the storage event + * cannot deliver. Every mounted terminal subscribes; the returned + * unsubscribe retires exactly this registration. + */ +export function onThemePref(cb: ThemePrefListener): () => void { + listeners.add(cb) + return () => { + listeners.delete(cb) + } } diff --git a/web/src/lib/viewport.test.ts b/web/src/lib/viewport.test.ts index fdd4c02..f1d2260 100644 --- a/web/src/lib/viewport.test.ts +++ b/web/src/lib/viewport.test.ts @@ -127,6 +127,58 @@ describe('trackVisualViewport', () => { expect(pane.style.height).toBe('') }) + it('leaves the slots empty when every tracker is gone, in any dispose order', () => { + // The failure this pins down: A then B install, A disposes first (its + // slot check fails, so it restores nothing), then B disposes and puts + // back the handler it captured — A's, whose tracker is already dead. The + // resurrected closure would keep restyling A's detached pane on every + // keyboard move, and nothing live would own the slot. + const vv = fakeViewport({ height: 700 }) + const paneB = document.createElement('div') + const surfaceB = document.createElement('div') + const disposeA = trackVisualViewport({ pane, surface, viewport: vv }) + const disposeB = trackVisualViewport({ pane: paneB, surface: surfaceB, viewport: vv }) + + disposeA() + disposeB() + expect(vv.onresize).toBeNull() + expect(vv.onscroll).toBeNull() + + vv.height = 400 + vv.fire() + expect(pane.style.height).toBe('') + expect(paneB.style.height).toBe('') + }) + + it('hands the slot to the survivor when the top and a middle tracker dispose', () => { + // Three panes overlap, the middle then the top go away: the slot must + // fall to the one still alive — not to the middle one's dead handler, + // which is what a captured-previous chain restores. + const vv = fakeViewport({ height: 700 }) + const paneB = document.createElement('div') + const paneC = document.createElement('div') + trackVisualViewport({ pane, surface, viewport: vv }) + const disposeB = trackVisualViewport({ + pane: paneB, + surface: document.createElement('div'), + viewport: vv, + }) + const disposeC = trackVisualViewport({ + pane: paneC, + surface: document.createElement('div'), + viewport: vv, + }) + + disposeB() + disposeC() + + vv.height = 400 + vv.fire() + expect(pane.style.height).toBe('400px') + expect(paneB.style.height).toBe('') + expect(paneC.style.height).toBe('') + }) + it('does not unwire a newer tracker when an older one disposes', () => { // A remount can install the replacement before tearing the old one down. // The stale disposer must clear only its own pane, never the live diff --git a/web/src/lib/viewport.ts b/web/src/lib/viewport.ts index e5cce40..6fd961e 100644 --- a/web/src/lib/viewport.ts +++ b/web/src/lib/viewport.ts @@ -57,8 +57,16 @@ export function trackVisualViewport(opts: { surface: HTMLElement gestureArea?: HTMLElement viewport: ViewportLike | null + /** + * Chrome in normal flow above the pane — the group view's tab strip. The + * formula below assumes the pane's layout box starts at the top of the + * page; a pane that starts topInset pixels down must be that much shorter, + * or its bottom rows (and the prompt) land behind the keyboard by exactly + * the strip's height. + */ + topInset?: number }): () => void { - const { pane, surface, gestureArea = surface, viewport } = opts + const { pane, surface, gestureArea = surface, viewport, topInset = 0 } = opts if (!viewport) return () => {} const apply = () => { @@ -69,25 +77,83 @@ export function trackVisualViewport(opts: { } surface.style.touchAction = '' gestureArea.style.touchAction = '' - pane.style.height = `${viewport.height}px` + pane.style.height = `${Math.max(0, viewport.height - topInset)}px` pane.style.translate = `0px ${viewport.offsetTop}px` } + // The slots are single-occupancy by design (see ViewportLike on why they + // are properties), and trackers legitimately overlap now: the scratch + // modal's terminal mounts over the route's, and split panes stack several + // more. A dispose that nulled the slot would leave every survivor deaf to + // the keyboard — and a dispose that restored "whatever it found on + // install" resurrects a dead tracker's handler the moment they tear down + // out of install order. So the stack is explicit: one shared record per + // viewport, disposal removes exactly its own entry, and the slot always + // belongs to the newest entry still alive (or to whatever non-flue handler + // held it before the first tracker arrived). + const rec = stackFor(viewport) + const me = { apply } + rec.stack.push(me) viewport.onresize = apply viewport.onscroll = apply apply() return () => { - // Each slot is surrendered only if it is still this tracker's. Nulling - // unconditionally would be a disposer reaching past its own lifetime: - // a remount can install the replacement before tearing down the old - // tracker, and the old one would then strip the handlers the new one - // just wired, leaving the pane stuck at whatever the keyboard last did. - if (viewport.onresize === apply) viewport.onresize = null - if (viewport.onscroll === apply) viewport.onscroll = null surface.style.touchAction = '' gestureArea.style.touchAction = '' pane.style.height = '' pane.style.translate = '' + const i = rec.stack.indexOf(me) + if (i === -1) return // disposed twice + const wasTop = i === rec.stack.length - 1 + rec.stack.splice(i, 1) + if (rec.stack.length === 0) stacks.delete(viewport) + // A tracker below the top never held the slot, so it has nothing to hand + // over; and each slot is surrendered only if it is still this tracker's — + // a remount can install the replacement before tearing down the old + // tracker, and the old one must not strip the handlers the new one just + // wired, leaving the pane stuck at whatever the keyboard last did. + if (!wasTop) return + const next = rec.stack[rec.stack.length - 1] + let handedOver = false + if (viewport.onresize === apply) { + viewport.onresize = next ? next.apply : rec.prevResize + handedOver = next !== undefined + } + if (viewport.onscroll === apply) { + viewport.onscroll = next ? next.apply : rec.prevScroll + handedOver = handedOver || next !== undefined + } + // The successor takes the state over now, not on the next keyboard move: + // its pane may have been styled for a viewport several trackers ago. + if (handedOver) next!.apply() + } +} + +/** One tracker on a viewport: what disposal needs to find and remove. */ +interface Tracker { + apply: () => void +} + +/** + * The live trackers per viewport, newest last, plus the handlers the first + * of them displaced — restored when the last one leaves. A WeakMap because + * the real page has one visualViewport forever, but every test conjures its + * own; keying on the object keeps them from sharing a stack. + */ +interface TrackerStack { + stack: Tracker[] + prevResize: ViewportLike['onresize'] + prevScroll: ViewportLike['onscroll'] +} + +const stacks = new WeakMap() + +function stackFor(viewport: ViewportLike): TrackerStack { + let rec = stacks.get(viewport) + if (rec === undefined) { + rec = { stack: [], prevResize: viewport.onresize, prevScroll: viewport.onscroll } + stacks.set(viewport, rec) } + return rec } diff --git a/web/src/router.tsx b/web/src/router.tsx index cfac6f2..43f2b84 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -16,6 +16,7 @@ import { RemoteRoute } from '@/routes/remote' import { SessionsRoute } from '@/routes/sessions' import { SettingsRoute } from '@/routes/settings' import { TerminalRoute } from '@/routes/terminal' +import { ScratchProvider } from '@/scratch/provider' import { NEW_SESSION_PATH, validateNewSessionSearch } from '@/sessions/new-session' import { SwitcherProvider } from '@/switcher/provider' @@ -123,7 +124,15 @@ const rootRoute = createRootRouteWithContext()({ not chosen one. */} - + {/* + The scratch terminal rides beside the switcher for the same + reason the switcher is here: its chord has to answer on any + screen with a session, and its anchor is whatever session the + route says is on screen. + */} + + + ) diff --git a/web/src/routes/sessions.tsx b/web/src/routes/sessions.tsx index 216086f..071a324 100644 --- a/web/src/routes/sessions.tsx +++ b/web/src/routes/sessions.tsx @@ -27,6 +27,7 @@ import { keyOf, LOCAL_MACHINE_ID, type FleetSession, type MachineState } from '@ import { useRefetchOnFocus } from '@/hooks/use-refetch-on-focus' import { takeCwd } from '@/lib/url' import { cn } from '@/lib/utils' +import { foldGroups } from '@/sessions/groups' import { useOpenNewSession, type NewSessionOrigin } from '@/sessions/open-new-session' import { applyView, @@ -46,6 +47,12 @@ import { type SavedView, } from '@/sessions/views-store' +/** + * One empty list, module-wide, so a fleet that has not reported yet does not + * hand every useMemo below a fresh `[]` identity per render. + */ +const EMPTY_SESSIONS: FleetSession[] = [] + /** * The terminal's path, written out rather than imported from src/router.tsx. * @@ -388,9 +395,16 @@ export function SessionsRoute() { // tab is looked at beats waiting out a stretched poll tick. useRefetchOnFocus(useCallback(() => fleet.list(), [fleet])) - const sessions = fleetState?.sessions ?? [] + const merged = fleetState?.sessions ?? EMPTY_SESSIONS const machines = fleetState?.machines ?? null + // Group members fold under their anchor: one row per group, wearing a pane + // count, rather than N rows for what a reader thinks of as one terminal. A + // member whose anchor is gone keeps its row — see foldGroups. + const grouped = useMemo(() => foldGroups(merged), [merged]) + const sessions = grouped.rows + const panes = grouped.panes + const groups = useMemo(() => applyView(sessions, view), [sessions, view]) /** How many rows the view folded away for having ended. See hiddenExited. */ const hiddenEnded = useMemo(() => hiddenExited(sessions, view), [sessions, view]) @@ -752,6 +766,7 @@ export function SessionsRoute() { {showTable && ( >(() => new Set()) + + // The URL keeps naming the session that was opened; the group is resolved + // from it. A member's URL resolves the same group, so a link to any pane + // opens the whole surface. It holds steady across a beat where the rows do not + // name the URL session yet — a Restart navigates to an id the next list + // has not delivered — so the group surface does not collapse and rebuild + // around a fact that is merely in flight. A genuinely different session + // (the ref remembers which id it answered for) starts from itself. + const anchorRef = useRef({ resolvedFor: sessionId, anchor: sessionId }) + const own = rows.find((s) => s.id === sessionId) + const anchorId = + own !== undefined + ? anchorIdOf(own) + : anchorRef.current.resolvedFor === sessionId + ? anchorRef.current.anchor + : sessionId + anchorRef.current = { resolvedFor: sessionId, anchor: anchorId } + + // Which members this view has seen alive. An exited member keeps its pane + // — the exit overlay is owed to whoever watched it die — but only for a + // death witnessed here: a fresh load must not dredge the retention + // window's corpses back onto the surface. Written during render, which is + // safe for a ref because adding to a set is idempotent. + const seenRunning = useRef(new Set()) + for (const s of rows) if (s.state === 'running') seenRunning.current.add(s.id) + + const members = groupMembers(rows, anchorId).filter( + (s) => + !dismissed.has(s.id) && + (s.id === sessionId || s.state !== 'exited' || seenRunning.current.has(s.id)), + ) + const havePanes = members.length > 0 + const panes = havePanes + ? members.map((s) => ({ id: s.id, label: displayName(s) })) + : [{ id: sessionId, label: '' }] + + // The mobile tab in front. Falls back inside SessionGroup when it names a + // pane that has gone, and follows the URL when the URL moves. + const [active, setActive] = useState(sessionId) + useEffect(() => setActive(sessionId), [sessionId]) + const isMobile = useIsMobile() + + // The desktop arrangement: one split tree per tab, owned here rather than + // in SessionGroup because the verbs that change it (the menu rows, the + // chords) live here; persisted per group and per device. Every setTabTrees + // writes through saveTabs inside the updater, which is idempotent, so + // StrictMode's double-invoke costs a duplicate write and nothing else. + const storageKey = `flue.group.${deviceId}.${anchorId}` + const [tabTrees, setTabTrees] = useState(() => loadTabs(storageKey)) + useEffect(() => setTabTrees(loadTabs(storageKey)), [storageKey]) + + const paneIds = panes.map((p) => p.id) + + // The tabs follow the members: panes that closed leave their tree, tabs + // that emptied fold away, and a member that appeared without a recorded + // placement — a split made from another device — gets a tab of its own. + // Keyed on the id list's spelling, so the fleet's poll ticks cost nothing + // while nothing changes. Gated on real members: before the fleet's first + // answer the pane list is a placeholder for the URL session, and + // reconciling against that would prune a freshly loaded layout to one + // leaf — and persist the damage — on every reload. + const paneKey = paneIds.join(',') + useEffect(() => { + if (!havePanes) return + setTabTrees((t) => { + const next = reconcileTabs(t, paneKey.split(',')) + if (next !== t) saveTabs(storageKey, next) + return next + }) + }, [havePanes, paneKey, storageKey]) + + // Which pane last held the keyboard, for the chord to target: ⇧⌘D splits + // the pane being typed in, not the URL's. Written by a passive focus + // listener over the data attribute every Terminal pane carries. + const focusedPane = useRef(null) + useEffect(() => { + const onFocus = (e: FocusEvent) => { + const el = e.target instanceof Element ? e.target.closest('[data-flue-session]') : null + const id = el?.getAttribute('data-flue-session') + if (id != null && id !== '') focusedPane.current = id + } + window.addEventListener('focusin', onFocus) + return () => window.removeEventListener('focusin', onFocus) + }, []) + const goTo = useCallback( + (id: string) => + void navigate({ + to: '/d/$deviceId/s/$sessionId', + params: { deviceId, sessionId: id }, + replace: true, + }), + [navigate, deviceId], + ) + + /* + * Split: another session in this group, in the directory of the pane that + * asked — and, for the two split verbs, placed beside that very pane in + * the tree, so ⇧⌘D over the right column of an A|B split stacks inside + * that column rather than rearranging the whole surface. "New tab" is the + * third verb: same spawn, tabs rendering. Click/chord-driven, like every + * spawn in this app — StrictMode runs mount effects twice and a spawning + * effect can only ever detach one of its shells. The ref is handed + * straight back; the pane mounts through the refreshed list. + */ + const rowsRef = useRef(rows) + rowsRef.current = rows + const split = useCallback( + (paneId: string, cwd: string | null, verb: GroupLayout) => { + if (client === null) return + const reqId = client.spawn({ + cwd: cwd ?? rowsRef.current.find((s) => s.id === paneId)?.cwd, + cols: 80, + rows: 24, + group: anchorId, + }) + if (reqId === null) return + const offs: Array<() => void> = [] + const settle = () => { + for (const off of offs) off() + } + offs.push( + client.onAttached((a) => { + if (a.reqId !== reqId) return + settle() + client.detach(a.ref) + // Place the new pane now, so the layout is settled before the + // refreshed list mounts it — the reconcile effect would otherwise + // guess and give it a tab of its own. A split lands beside the + // pane that asked, inside that pane's tab; a new tab is appended + // whole. splitInTabs declines when the target has meanwhile gone, + // and the reconcile pass then adopts the newcomer anyway. + setTabTrees((t) => { + const base = t.length === 0 ? [{ leaf: anchorId } as PaneTree] : t + const next = + verb === 'tabs' + ? [...base, { leaf: a.id } as PaneTree] + : splitInTabs(base, paneId, verb, a.id) + saveTabs(storageKey, next) + return next + }) + // The pane appears when the rows say so; asking now is what makes + // that a beat rather than the fleet's next three-second poll. + client.list() + setActive(a.id) + }), + client.onError((e) => { + if (e.reqId === reqId) settle() + }), + client.onStatus((s) => { + if (s !== 'open') settle() + }), + ) + }, + [client, storageKey, anchorId], + ) + + // A divider settled: commit the ratio into its tab's tree and persist, + // once per drag. + const onRatio = useCallback( + (tab: number, path: TreePath, ratio: number) => { + setTabTrees((t) => { + const tree = t[tab] + if (tree === undefined) return t + const nextTree = withRatio(tree, path, ratio) + if (nextTree === tree) return t + const next = [...t] + next[tab] = nextTree + saveTabs(storageKey, next) + return next + }) + }, + [storageKey], + ) + + // The split chords: ⌘D / ⇧⌘D on a Mac, the Ctrl+Shift family elsewhere + // (lib/split-keys.ts). Capture phase for the switcher's reason — left to + // bubble, xterm turns the keystroke into bytes first. The target is the + // pane holding the keyboard, or the URL's when none does; everything is + // read through refs so the listener mounts once. + const splitRef = useRef(split) + splitRef.current = split + const paneIdsRef = useRef(paneIds) + paneIdsRef.current = paneIds + const urlPaneRef = useRef(sessionId) + urlPaneRef.current = sessionId + useEffect(() => { + if (!canMultiplex) return + const apple = isApplePlatform() + const onKey = (e: KeyboardEvent) => { + const dir = matchSplitChord(e, apple) + const newTab = dir === null && matchNewTabChord(e, apple) + if (dir === null && !newTab) return + e.preventDefault() + e.stopPropagation() + const focused = focusedPane.current + const target = + focused !== null && paneIdsRef.current.includes(focused) ? focused : urlPaneRef.current + splitRef.current(target, null, dir ?? 'tabs') + } + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) + }, [canMultiplex]) + + /* + * Walking the tabs: ⌥⌘←/→ (Ctrl+Alt+←/→ off a Mac) steps to the + * neighbouring tab — of trees on a desktop, of panes on a phone. Purely + * client-side, so it works against any daemon, and mounted once with every + * moving part behind a ref. + */ + const tabsRef = useRef(tabTrees) + tabsRef.current = tabTrees + const shownRef = useRef(sessionId) + const mobileRef = useRef(isMobile) + mobileRef.current = isMobile + useEffect(() => { + const apple = isApplePlatform() + const onKey = (e: KeyboardEvent) => { + const step = matchTabCycleChord(e, apple) + if (step === null) return + const ids = paneIdsRef.current + if (ids.length <= 1) return + e.preventDefault() + e.stopPropagation() + if (mobileRef.current) { + const at = Math.max(0, ids.indexOf(shownRef.current)) + setActive(ids[(at + step + ids.length) % ids.length]!) + return + } + const tabs = tabsRef.current + if (tabs.length <= 1) return + const at = Math.max(0, tabOf(tabs, shownRef.current)) + setActive(leafIds(tabs[(at + step + tabs.length) % tabs.length]!)[0]!) + } + window.addEventListener('keydown', onKey, true) + return () => window.removeEventListener('keydown', onKey, true) + }, []) + + // Which pane wears the control strip. The chips belong to the surface, + // not to a pane — a strip on every pane is four chips times N, and one + // pinned to the URL pane floats mid-screen and dies with it. So they sit + // on whichever pane owns the surface's top-right corner — of the tab in + // front, on a desktop — and pass along when that pane goes. + const shownTab = panes.some((p) => p.id === active) ? active : panes[0]!.id + shownRef.current = shownTab + const activeTree = tabTrees[Math.max(0, tabOf(tabTrees, shownTab))] + const chipsPane = isMobile || activeTree === undefined ? shownTab : topRightLeaf(activeTree) + // A machine the fleet does not hold: never paired on this browser, or its // pinned key gone. Said in a pill, the way the terminal answers a session // the daemon has never heard of — though unlike that answer this one is // provisional for a breath at boot, which is what the subscription above // exists to notice. if (client === null) return - // Keyed by machine and session, so navigating between two sessions builds a - // new terminal rather than feeding one emulator two sessions' scrollback. - // The effect's dependency array would do this too; the key makes the state - // React holds — the phase pill, the keyboard mode — reset with it. return ( - - void navigate({ - to: '/d/$deviceId/s/$sessionId', - params: { deviceId, sessionId: id }, - replace: true, - }) - } - onClosed={() => void navigate({ to: '/', replace: true })} - // This machine and this directory, because that is what a `+` inside a - // session means. Both are only a prefill — the dialog offers the rest - // of the fleet, and a session started from here need not be a sibling. - onNewSession={(cwd) => setCreating({ machineId: deviceId, cwd: cwd ?? '' })} + split(shownTab, null, 'tabs') : undefined} + renderPane={(id, viewportInset, fit) => ( + // Keyed by machine, session, inset and pinning, so navigating + // between two sessions — or the tab strip appearing above one, or + // a pane moving between a split and a lone rendering — builds a + // new terminal rather than feeding one emulator two sessions' + // scrollback. The key also resets the state React holds: the + // phase pill, the keyboard mode. + { + // Fired by the exit itself — there is no overlay any more. A + // session that was already over when this view opened is being + // *read*, which is what the daemon's exited-retention window + // is for, so only a shell seen alive here folds its pane away. + if (!seenRunning.current.has(id)) return + const remaining = paneIds.filter((p) => p !== id) + // The chord must never aim at this pane again. Its ref is + // cleared now rather than left to the next focusin, because a + // chord in the gap would read the dead pane's id and fall + // through to whatever the paneIds guard makes of it. + if (focusedPane.current === id) focusedPane.current = remaining[0] ?? null + if (remaining.length === 0) { + // replace: a dead session's URL is not worth a Back stop. + void navigate({ to: '/', replace: true }) + return + } + setDismissed((prev) => new Set(prev).add(id)) + if (id === sessionId) goTo(remaining[0]!) + else if (id === active) setActive(remaining[0]!) + }} + // This machine and this directory, because that is what a `+` + // inside a session means. Both are only a prefill — the dialog + // offers the rest of the fleet, and a session started from here + // need not be a sibling. + onNewSession={(cwd) => setCreating({ machineId: deviceId, cwd: cwd ?? '' })} + onSplit={canMultiplex ? (cwd, verb) => split(id, cwd, verb) : undefined} + /> + )} /> , machineId: string): FleetSession[] { + const [rows, setRows] = useState([]) + useEffect(() => { + setRows([]) + return fleet.onFleet((sessions) => { + const mine = sessions.filter((s) => s.machineId === machineId) + // An empty answer while rows are held is kept out: the fleet nulls a + // machine's rows on every socket blip, and adopting that emptiness + // would collapse the group layout — unmounting and rebuilding every + // pane's emulator — for a one-second Wi-Fi hiccup. Holding the last + // known rows costs nothing real: a session that genuinely ended + // announces itself to its own pane (exit, or the not-found reply on + // reattach), and the rows refresh the moment the machine answers. + if (mine.length === 0) return + setRows((prev) => (sameRows(prev, mine) ? prev : mine)) + }) + }, [fleet, machineId]) + return rows +} + +/** Whether two row lists would render the same group view. */ +function sameRows(a: FleetSession[], b: FleetSession[]): boolean { + return a.length === b.length && a.every((s, i) => rowSig(s) === rowSig(b[i]!)) +} + +function rowSig(s: FleetSession): string { + return `${s.id}|${s.group ?? ''}|${s.state}|${s.cwd}|${displayName(s)}` +} + +/** Whether `client` has announced a capability, kept current across welcomes. */ +function useHasCap(client: FlueClient | null, cap: string): boolean { + const [has, setHas] = useState(() => client?.hasCap(cap) ?? false) + const capRef = useRef(cap) + capRef.current = cap + useEffect(() => { + if (client === null) { + setHas(false) + return + } + setHas(client.hasCap(capRef.current)) + return client.onWelcome(() => setHas(client.hasCap(capRef.current))) + }, [client]) + return has +} + /** What the new-session form needs off the fleet, and nothing else. */ interface FormFleet { machines: Array<{ id: string; name: string }> diff --git a/web/src/scratch/context.ts b/web/src/scratch/context.ts new file mode 100644 index 0000000..bb06d85 --- /dev/null +++ b/web/src/scratch/context.ts @@ -0,0 +1,30 @@ +import { createContext, useContext } from 'react' + +/** + * What the rest of the app may ask of the scratch terminal. `toggle` is what + * the double-Ctrl chord does, exposed for the chip a phone needs — no Ctrl to + * tap twice there. `enabled` says whether the surface exists at all right + * now: there is a session on screen to anchor the scratch to, and its daemon + * has announced the `multiplex` capability. + */ +export interface Scratch { + toggle(): void + enabled: boolean +} + +/** + * In its own module rather than beside the provider, and not by taste: the + * provider renders a Terminal inside its dialog, and the Terminal's control + * strip reads this context for its chip — a shared file is what keeps that + * from being an import cycle. + */ +export const ScratchContext = createContext(null) + +/** + * The scratch terminal's controls, from a component that cannot be sure the + * provider is mounted. The no-op default is for tests that mount the terminal + * alone, exactly as useSwitcher answers them. + */ +export function useScratch(): Scratch { + return useContext(ScratchContext) ?? { toggle: () => {}, enabled: false } +} diff --git a/web/src/scratch/double-ctrl.test.ts b/web/src/scratch/double-ctrl.test.ts new file mode 100644 index 0000000..980fa9c --- /dev/null +++ b/web/src/scratch/double-ctrl.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' + +import { createDoubleCtrl, type DoubleCtrl } from './double-ctrl' + +/** Drive the detector like a keyboard would, with a controllable clock. */ +function rig(windowMs = 350) { + let t = 0 + const chord = createDoubleCtrl({ windowMs, now: () => t }) + const at = (ms: number) => { + t = ms + } + const down = (key: string, repeat = false) => chord.keydown({ key, repeat }) + const up = (key: string) => chord.keyup({ key }) + return { chord, at, down, up } +} + +/** One bare Ctrl tap: press then release, both at the current clock. */ +function tap(r: ReturnType): boolean { + r.down('Control') + return r.up('Control') +} + +describe('createDoubleCtrl', () => { + it('fires on two bare taps inside the window', () => { + const r = rig() + expect(tap(r)).toBe(false) + r.at(200) + expect(tap(r)).toBe(true) + }) + + it('does not fire when the second press comes too late', () => { + const r = rig(350) + tap(r) + r.at(400) + expect(tap(r)).toBe(false) + // But that late tap starts a fresh pair. + r.at(500) + expect(tap(r)).toBe(true) + }) + + it('never counts a real chord: Ctrl+C ends with a Ctrl keyup that must not be half a tap', () => { + const r = rig() + r.down('Control') + r.down('c') + r.up('c') + expect(r.up('Control')).toBe(false) + // One genuine tap after the chord is only ever the first of a pair. + r.at(100) + expect(tap(r)).toBe(false) + r.at(200) + expect(tap(r)).toBe(true) + }) + + it('is spoiled by any key between the taps', () => { + const r = rig() + tap(r) + r.down('a') + r.up('a') + r.at(100) + expect(tap(r)).toBe(false) + }) + + it('cancels a second press that turns into a chord', () => { + const r = rig() + tap(r) + r.at(100) + r.down('Control') + r.down('c') // tap, then Ctrl+C: an interrupt, not a chord completion + r.up('c') + expect(r.up('Control')).toBe(false) + }) + + it('ignores auto-repeat of a held Ctrl', () => { + const r = rig() + tap(r) + r.at(100) + r.down('Control') + r.down('Control', true) + r.down('Control', true) + expect(r.up('Control')).toBe(true) + }) + + it('proves nothing from a release it never saw pressed', () => { + const r = rig() + tap(r) + r.at(100) + // Focus came back mid-hold: keyup with no keydown behind it. + expect(r.up('Control')).toBe(false) + }) + + it('forgets everything on reset', () => { + const r = rig() + tap(r) + r.chord.reset() + r.at(100) + expect(tap(r)).toBe(false) + r.at(200) + expect(tap(r)).toBe(true) + }) + + it('keeps firing on later pairs', () => { + const r = rig() + tap(r) + r.at(100) + expect(tap(r)).toBe(true) + r.at(300) + tap(r) + r.at(400) + expect(tap(r)).toBe(true) + }) +}) + +// The type is exported for the provider; keep the import honest. +const _typecheck: DoubleCtrl = createDoubleCtrl() +void _typecheck diff --git a/web/src/scratch/double-ctrl.ts b/web/src/scratch/double-ctrl.ts new file mode 100644 index 0000000..d5688fa --- /dev/null +++ b/web/src/scratch/double-ctrl.ts @@ -0,0 +1,107 @@ +/* + * The double-Ctrl chord: two bare Ctrl press-and-release taps, close + * together, with no other key anywhere between them. + * + * A bare Ctrl tap is the one modifier gesture that cannot collide with + * terminal input — a lone modifier sends nothing to the pty — and it is not + * on any browser's reserved list (#64 is about Ctrl+W and friends, which are + * chords). The hazard is the real chord: Ctrl+C ends with a Ctrl keyup, and + * counting that release as half a tap would open the scratch terminal on + * every second interrupt. Hence "bare": a tap is spoiled by any other key + * going down while Ctrl is held, and the gap between taps is spoiled by any + * other key at all. + * + * This is a pure state machine over keydown/keyup so the timing rules are + * testable without a DOM. The caller wires it to window listeners in the + * capture phase — the same reason the switcher's chords run there: left to + * bubble, xterm's handler would never let the events out of the terminal. + */ + +export interface DoubleCtrlOptions { + /** + * How close the two taps must be: the second press within this many + * milliseconds of the first release. Roomier than a double-click default + * because two taps of the same finger on the same key are slower than two + * clicks of a button. + */ + windowMs?: number + /** The clock, for tests. */ + now?: () => number +} + +export interface DoubleCtrl { + /** Feed a keydown. True when this event completed the chord. */ + keydown(e: Pick): boolean + /** Feed a keyup. True when this event completed the chord. */ + keyup(e: Pick): boolean + /** Forget everything — call when the window loses focus and releases go missing. */ + reset(): void +} + +const DEFAULT_WINDOW_MS = 350 + +export function createDoubleCtrl(opts: DoubleCtrlOptions = {}): DoubleCtrl { + const windowMs = opts.windowMs ?? DEFAULT_WINDOW_MS + const now = opts.now ?? (() => performance.now()) + + /** Ctrl is currently held. */ + let held = false + /** Another key went down while this Ctrl was held — it is a chord. */ + let chorded = false + /** When the first bare tap's release landed, or null when no tap stands. */ + let tappedAt: number | null = null + /** + * The standing second press: Ctrl went down in time and bare so far, and + * only its release remains. Kept apart from `tappedAt` so a second press + * that turns into a chord (Ctrl tap, then Ctrl+C) cancels cleanly. + */ + let arming = false + + const reset = () => { + held = false + chorded = false + tappedAt = null + arming = false + } + + return { + keydown(e) { + if (e.key === 'Control') { + if (e.repeat) return false + held = true + chorded = false + arming = tappedAt !== null && now() - tappedAt <= windowMs + return false + } + // Any other key: a held Ctrl becomes a chord, and a standing tap is + // spoiled — "no other key in between" is what keeps Ctrl+C, C, Ctrl+C + // from reading as taps around a keystroke. + chorded = true + tappedAt = null + arming = false + return false + }, + + keyup(e) { + if (e.key !== 'Control') return false + // A release with no press behind it — focus returned mid-hold, or the + // browser ate the keydown — proves nothing. + if (!held) return false + held = false + if (chorded) { + chorded = false + tappedAt = null + arming = false + return false + } + if (arming) { + reset() + return true + } + tappedAt = now() + return false + }, + + reset, + } +} diff --git a/web/src/scratch/provider.test.tsx b/web/src/scratch/provider.test.tsx new file mode 100644 index 0000000..0de3ff9 --- /dev/null +++ b/web/src/scratch/provider.test.tsx @@ -0,0 +1,151 @@ +import { act, render, screen } from '@testing-library/react' +import { RouterProvider } from '@tanstack/react-router' +import { describe, expect, it } from 'vitest' + +import { FleetClient } from '@/fleet/fleet' +import { FleetProvider } from '@/fleet/provider' +import { createFlueRouter } from '@/router' +import type { SessionInfo } from '@/client/protocol' +import { fakeClient, type FakeSocket } from '@/testing/socket' + +/** One session as a daemon reports it, with the dull fields filled in. */ +function row(over: Partial & { id: string }): SessionInfo { + return { + title: '', + name: '', + tags: [], + pinned: false, + cwd: '/home/karn', + cmd: ['zsh'], + state: 'running', + exitCode: 0, + cols: 80, + rows: 24, + createdAt: '2026-01-01T00:00:00Z', + lastActive: '2026-01-01T00:00:00Z', + ...over, + } +} + +/** + * The real router at `path` over one scripted machine, exactly as the + * switcher's tests mount it: the scratch provider lives in the root route, + * and the chord listener is the part worth testing through the real tree. + */ +async function mountApp(path: string) { + window.history.replaceState(null, '', path) + const router = createFlueRouter() + await router.load() + const local = fakeClient() + const fleet = new FleetClient([ + { id: 'local', name: 'macbook', client: local.client, pinned: false }, + ]) + let view!: ReturnType + await act(async () => { + view = render( + + + , + ) + }) + return { ...view, router, local } +} + +/** Open the machine's first socket speaking `multiplex`, and list `sessions`. */ +function connect(machine: { sockets: FakeSocket[] }, sessions: SessionInfo[]) { + const sock = machine.sockets[0]! + act(() => { + if (!sock.opened) sock.open() + sock.emitControl({ + type: 'welcome', + daemonId: 'local', + host: 'macbook', + ver: '0.5.0', + caps: ['multiplex'], + }) + sock.emitControl({ type: 'sessions', sessions }) + }) + return sock +} + +/** Two bare Ctrl taps — the scratch chord, played on the window. */ +function tapCtrlTwice() { + act(() => { + for (let i = 0; i < 2; i++) { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Control', bubbles: true })) + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Control', bubbles: true })) + } + }) +} + +describe('ScratchProvider', () => { + it('adopts the running scratch the chord finds and opens the modal', async () => { + const { local } = await mountApp('/d/local/s/s1') + const sock = connect(local, [row({ id: 's1' })]) + + tapCtrlTwice() + expect(sock.ofType('list').length).toBeGreaterThan(0) + act(() => { + sock.emitControl({ + type: 'sessions', + sessions: [ + row({ id: 's1' }), + row({ id: 'sc1', group: 's1', ephemeral: true }), + ], + }) + }) + + expect(screen.getByText('Scratch terminal')).toBeTruthy() + expect(sock.ofType('spawn')).toEqual([]) + }) + + it('stays closed when the answer arrives after navigating to another session', async () => { + const { router, local } = await mountApp('/d/local/s/s1') + const sock = connect(local, [row({ id: 's1' }), row({ id: 's2' })]) + + tapCtrlTwice() + // The route moves while the list round-trip is out: the scratch that + // resolves belongs to s1, and popping it over s2 would be a modal about + // a session that is no longer on screen. + await act(async () => { + await router.navigate({ + to: '/d/$deviceId/s/$sessionId', + params: { deviceId: 'local', sessionId: 's2' }, + replace: true, + }) + }) + act(() => { + sock.emitControl({ + type: 'sessions', + sessions: [ + row({ id: 's1' }), + row({ id: 's2' }), + row({ id: 'sc1', group: 's1', ephemeral: true }), + ], + }) + }) + + expect(screen.queryByText('Scratch terminal')).toBeNull() + expect(sock.ofType('spawn')).toEqual([]) + }) + + it('declines to spawn when the anchor is no longer a running session', async () => { + const { local } = await mountApp('/d/local/s/s1') + const sock = connect(local, [row({ id: 's1' })]) + + tapCtrlTwice() + // The parent exited between the chord and the answer. A scratch grouped + // under an exited parent is closed by the daemon's next sweep within + // seconds of being born — spawning it is a shell with a death warrant, + // in the daemon's default directory no less. + act(() => { + sock.emitControl({ + type: 'sessions', + sessions: [row({ id: 's1', state: 'exited', exitCode: 0 })], + }) + }) + + expect(sock.ofType('spawn')).toEqual([]) + expect(screen.queryByText('Scratch terminal')).toBeNull() + }) +}) diff --git a/web/src/scratch/provider.tsx b/web/src/scratch/provider.tsx new file mode 100644 index 0000000..2579143 --- /dev/null +++ b/web/src/scratch/provider.tsx @@ -0,0 +1,355 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { useRouterState } from '@tanstack/react-router' +import { Dialog } from 'radix-ui' +import { SquareTerminalIcon, XIcon } from 'lucide-react' + +import { Button } from '@/components/ui/button' + +import type { FlueClient } from '@/client/client' +import type { SessionInfo } from '@/client/protocol' +import { FlueClientContext } from '@/client/provider' +import { Terminal } from '@/components/terminal' +import { useFleet } from '@/fleet/provider' +import { LOCAL_MACHINE_ID } from '@/fleet/types' +import { useIsMobile } from '@/hooks/use-mobile' +import { createDoubleCtrl } from './double-ctrl' +import { ScratchContext } from './context' + +/** 80x24 is a starting point, not a decision; the terminal corrects it. */ +const SPAWN_COLS = 80 +const SPAWN_ROWS = 24 + +/** The modal header's height in px (h-11 on mobile below), for the viewport pinning. */ +const HEADER_PX = 44 + +/** What the modal is showing: which machine's client, whose scratch, and whose cwd it started in. */ +interface OpenScratch { + machineId: string + sessionId: string + parentId: string +} + +/** + * The scratch terminal: double-tap Ctrl over a session and a modal opens with + * a shell on the same machine, in that session's directory. + * + * The shell is an `ephemeral` session grouped under the one on screen, and + * that grouping is its whole lifecycle: dismissing the modal only detaches, + * so a dev server started in it keeps serving; tapping the chord again finds + * the same session still running and reattaches; and the daemon closes it + * when the parent session ends (spec/protocol.md, "Groups and ephemeral + * sessions"). "Keep" is the way out of that bargain — it clears the flag and + * the scratch becomes an ordinary member of the group, a split pane from the + * next render on. + * + * Mounted above the terminal route like the switcher, and for the same + * reason: the chord has to work wherever a session is on screen. It renders + * nothing and listens for two keys while closed. + */ +export function ScratchProvider({ children }: { children: ReactNode }) { + const fleet = useFleet() + const isMobile = useIsMobile() + const [open, setOpen] = useState(null) + + // Which session the chord would anchor to: the deepest route match, read + // the way the switcher reads it — a param hook here would answer for the + // root route, which has no params on any screen. + const params = useRouterState({ + select: (s) => + (s.matches[s.matches.length - 1]?.params ?? {}) as { + deviceId?: string + sessionId?: string + }, + }) + const machineId = params.deviceId ?? LOCAL_MACHINE_ID + const parentId = params.sessionId ?? null + + // Whether the chord and the chip do anything right now: a session on + // screen, its machine reachable, and its daemon speaking `multiplex`. The + // cap arrives on the welcome, so this listens rather than reads once. + const [enabled, setEnabled] = useState(false) + useEffect(() => { + if (parentId === null) { + setEnabled(false) + return + } + // Re-resolved on every fleet reshaping, not read once: a direct load of + // a remote machine's session renders before the fleet has adopted its + // remote sources, so the first look legitimately finds no client — and a + // welcome subscription is only worth holding on the client that exists. + const offs: Array<() => void> = [] + let offWelcome: (() => void) | null = null + let heard: unknown = null + const recompute = () => { + const client = fleet.clientFor(machineId) + setEnabled(client !== null && client.hasCap('multiplex')) + if (client !== null && heard !== client) { + heard = client + offWelcome?.() + offWelcome = client.onWelcome(recompute) + } + } + recompute() + offs.push(fleet.onFleet(recompute)) + return () => { + for (const off of offs) off() + offWelcome?.() + } + }, [fleet, machineId, parentId]) + + // One resolution in flight at a time: a chord tapped thrice while the list + // round-trip is out must not spawn three scratches. + const resolving = useRef(false) + // Where the user is *now*, for a resolution that started somewhere else: + // the list round-trip can outlive the route it was asked from, and a modal + // that pops over the next session carries a scratch about the last one. + const routeRef = useRef({ machineId, parentId }) + routeRef.current = { machineId, parentId } + const openRef = useRef(open) + openRef.current = open + + const dismiss = useCallback(() => { + // Detach only — the Terminal's unmount does that by itself. The shell + // runs on; that is the point. + setOpen(null) + }, []) + + const toggle = useCallback(() => { + if (openRef.current !== null) { + setOpen(null) + return + } + if (parentId === null || resolving.current) return + const client = fleet.clientFor(machineId) + if (client === null || !client.hasCap('multiplex')) return + + // Resolve against the daemon's own list rather than the fleet's rows, + // which hide ephemeral sessions on purpose: the running scratch this + // parent already has is exactly what those rows will not say. + resolving.current = true + const offs: Array<() => void> = [] + const settle = () => { + resolving.current = false + for (const off of offs) off() + } + + const anchor = parentId + // True while the user is still where the chord was tapped. Checked at + // every landing, not once: each answer arrives on its own tick, and the + // route may have moved during any of the waits. + const stillHere = () => + routeRef.current.parentId === anchor && routeRef.current.machineId === machineId + const adopt = (rows: SessionInfo[]) => { + const existing = rows.find( + (s) => s.group === anchor && s.ephemeral === true && s.state === 'running', + ) + if (existing !== undefined) { + settle() + if (!stillHere()) return + setOpen({ machineId, sessionId: existing.id, parentId: anchor }) + return + } + const parent = rows.find((s) => s.id === anchor) + if (parent === undefined || parent.state !== 'running' || !stillHere()) { + // No running parent to belong to (the daemon's sweep would close the + // newborn within seconds anyway), or the user has already left. + settle() + return + } + const cwd = parent.cwd + const reqId = client.spawn({ + cwd, + cols: SPAWN_COLS, + rows: SPAWN_ROWS, + group: anchor, + ephemeral: true, + }) + if (reqId === null) { + settle() + return + } + offs.push( + client.onAttached((a) => { + if (a.reqId !== reqId) return + settle() + // Hand the ref straight back — the modal's Terminal attaches for + // itself, exactly as every navigation target does. + client.detach(a.ref) + // Left during the spawn: the scratch exists and keeps running + // under its parent — the next chord over that session adopts it — + // but it is not popped over whatever screen the user is on now. + if (!stillHere()) return + setOpen({ machineId, sessionId: a.id, parentId: anchor }) + }), + client.onError((e) => { + if (e.reqId === reqId) settle() + }), + ) + } + + let answered = false + offs.push( + client.onSessions((rows) => { + // The first list after the ask answers the "is one already running" + // question; later ones are other screens' polls. + if (answered) return + answered = true + adopt(rows) + }), + // Replies do not survive their socket. + client.onStatus((s) => { + if (s !== 'open') settle() + }), + ) + client.list() + }, [fleet, machineId, parentId]) + + const toggleRef = useRef(toggle) + toggleRef.current = toggle + const enabledRef = useRef(enabled) + enabledRef.current = enabled + + /* + * The chord, in the capture phase for the switcher's reason: left to + * bubble, xterm turns keys into bytes before anything above it runs. A + * bare Ctrl tap sends nothing to the pty, so listening costs the terminal + * nothing; the detector (scratch/double-ctrl.ts) is what keeps a real + * Ctrl+C chord from counting as half a tap. + * + * Escape, while the modal is up, dismisses — also captured, and stopped, + * so it cannot double as input to the scratch shell it is closing. + */ + useEffect(() => { + const chord = createDoubleCtrl() + const onKeyDown = (e: KeyboardEvent) => { + if (openRef.current !== null && e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + chord.reset() + setOpen(null) + return + } + if (chord.keydown(e) && (enabledRef.current || openRef.current !== null)) { + toggleRef.current() + } + } + const onKeyUp = (e: KeyboardEvent) => { + if (chord.keyup(e) && (enabledRef.current || openRef.current !== null)) { + toggleRef.current() + } + } + const onFocusLost = () => chord.reset() + // The focus-loss event's name is spelled in two halves because it is also + // a Tailwind utility name, and a quoted string of it in any scanned + // source compiles a stray rule into the shipped stylesheet — see the + // scanner notes at the top of src/styles.css and styles.build.test.ts. + const focusLost = 'blu' + 'r' + window.addEventListener('keydown', onKeyDown, true) + window.addEventListener('keyup', onKeyUp, true) + window.addEventListener(focusLost, onFocusLost) + return () => { + window.removeEventListener('keydown', onKeyDown, true) + window.removeEventListener('keyup', onKeyUp, true) + window.removeEventListener(focusLost, onFocusLost) + } + }, []) + + const client = open !== null ? fleet.clientFor(open.machineId) : null + + const keep = useCallback(() => { + if (openRef.current === null) return + const c = fleet.clientFor(openRef.current.machineId) + // Clearing the flag is the promotion: the daemon moves it to ordinary + // retention and stops tying it to the parent, and the refreshed list + // surfaces it as a member — a pane in the group view — now rather than + // on the fleet's next three-second poll. + c?.update({ id: openRef.current.sessionId, ephemeral: false }) + c?.list() + setOpen(null) + }, [fleet]) + + // Memoised, and load-bearing rather than tidy: every terminal's control + // strip reads this context for its chip, and a fresh value object per + // provider render would re-render every mounted terminal on every + // navigation this provider sees. + const scratch = useMemo(() => ({ toggle, enabled }), [toggle, enabled]) + + return ( + + {children} + !o && dismiss()}> + + {/* A dim and nothing frosted: the session underneath is what the scratch + is *about*, and frosting it over reads as leaving the page. */} + + e.preventDefault()} + // Autofocus is refused so the terminal keeps the keyboard it + // takes for itself on mount. Radix would otherwise focus the + // first tabbable — the Keep button — and the first Enter typed + // at the shell would silently promote the scratch instead. + onOpenAutoFocus={(e) => e.preventDefault()} + // The switcher's box, deliberately: same width, same anchor + // height, same popover surface and hairline — the two overlays a + // chord can summon should read as siblings. + className={ + 'fixed inset-0 z-50 flex flex-col overflow-hidden bg-popover text-popover-foreground outline-none ' + + 'sm:inset-auto sm:top-[12vh] sm:left-1/2 sm:h-[64vh] sm:w-[56rem] sm:max-w-[calc(100vw-2rem)] sm:-translate-x-1/2 ' + + 'sm:rounded-lg sm:shadow-high sm:ring-1 sm:ring-hairline' + } + > + {/* + The frame is one slim header: hairline, a quiet title, the two + verbs. The Terminal below renders minimal chrome, so nothing in + it navigates away from under the dialog. + */} +
+
+
+ {open !== null && client !== null && ( + + + + )} +
+
+
+
+
+ ) +} diff --git a/web/src/sessions/groups.test.ts b/web/src/sessions/groups.test.ts new file mode 100644 index 0000000..c32aeb6 --- /dev/null +++ b/web/src/sessions/groups.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionInfo } from '@/client/protocol' +import type { FleetSession } from '@/fleet/types' +import { anchorIdOf, foldGroups, groupMembers } from './groups' + +function row(over: Partial & { id: string }): FleetSession { + return { + title: '', + name: '', + tags: [], + pinned: false, + cwd: '/home/karn', + cmd: ['zsh', '-l'], + state: 'running', + exitCode: 0, + cols: 80, + rows: 24, + createdAt: '2026-08-01T10:00:00Z', + lastActive: '2026-08-01T10:00:00Z', + machineId: 'local', + machineName: 'this machine', + ...over, + } +} + +describe('anchorIdOf', () => { + it('is the session itself when it stands alone', () => { + expect(anchorIdOf({ id: 's1' })).toBe('s1') + expect(anchorIdOf({ id: 's1', group: '' })).toBe('s1') + }) + + it('is the anchor for a member, so any member URL opens the group', () => { + expect(anchorIdOf({ id: 's2', group: 's1' })).toBe('s1') + }) +}) + +describe('groupMembers', () => { + const anchor = row({ id: 'a' }) + const early = row({ id: 'm1', group: 'a', createdAt: '2026-08-01T10:01:00Z' }) + const late = row({ id: 'm2', group: 'a', createdAt: '2026-08-01T10:02:00Z' }) + + it('yields the anchor first, then members oldest split first', () => { + const got = groupMembers([late, anchor, early] as SessionInfo[], 'a') + expect(got.map((s) => s.id)).toEqual(['a', 'm1', 'm2']) + }) + + it('keeps an exited member — its pane owes the reader an exit overlay', () => { + const ended = row({ id: 'm1', group: 'a', state: 'exited' }) + const got = groupMembers([anchor, ended] as SessionInfo[], 'a') + expect(got.map((s) => s.id)).toEqual(['a', 'm1']) + }) + + it('excludes a scratch terminal — the modal owns it, never the layout', () => { + const scratch = row({ id: 'sc', group: 'a', ephemeral: true }) + const got = groupMembers([anchor, scratch, early] as SessionInfo[], 'a') + expect(got.map((s) => s.id)).toEqual(['a', 'm1']) + }) + + it('survives a missing anchor: the members are the group now', () => { + const got = groupMembers([late, early] as SessionInfo[], 'a') + expect(got.map((s) => s.id)).toEqual(['m1', 'm2']) + }) + + it('leaves strangers out', () => { + const other = row({ id: 'x', group: 'b' }) + const plain = row({ id: 'y' }) + const got = groupMembers([anchor, other, plain] as SessionInfo[], 'a') + expect(got.map((s) => s.id)).toEqual(['a']) + }) +}) + +describe('foldGroups', () => { + it('folds members under their anchor and counts the panes', () => { + const anchor = row({ id: 'a' }) + const m1 = row({ id: 'm1', group: 'a' }) + const m2 = row({ id: 'm2', group: 'a' }) + const plain = row({ id: 'p' }) + + const { rows, panes } = foldGroups([anchor, m1, plain, m2]) + expect(rows.map((s) => s.id)).toEqual(['a', 'p']) + expect(panes.get('local/a')).toBe(3) + expect(panes.has('local/p')).toBe(false) + }) + + it('keeps a member whose anchor is gone — no session may vanish', () => { + const orphan = row({ id: 'm1', group: 'gone' }) + const { rows, panes } = foldGroups([orphan]) + expect(rows.map((s) => s.id)).toEqual(['m1']) + expect(panes.size).toBe(0) + }) + + it('does not fold a running member under an exited anchor', () => { + // The list hides exited rows by default, so a live shell folded under a + // dead anchor would vanish from the list, the search and the bulk bar + // for the whole exited-retention window. + const deadAnchor = row({ id: 'a', state: 'exited' }) + const live = row({ id: 'm1', group: 'a' }) + const alsoDead = row({ id: 'm2', group: 'a', state: 'exited' }) + const { rows, panes } = foldGroups([deadAnchor, live, alsoDead]) + expect(rows.map((s) => s.id)).toEqual(['a', 'm1']) + // The exited member still folds — it is as over as its anchor. + expect(panes.get('local/a')).toBe(2) + }) + + it('folds per machine: the same ids on two machines are two groups', () => { + const anchorHere = row({ id: 'a' }) + const memberThere = row({ id: 'm1', group: 'a', machineId: 'remote', machineName: 'far' }) + const { rows } = foldGroups([anchorHere, memberThere]) + // The remote member's anchor is on another machine, so it must not fold + // under the local one. + expect(rows.map((s) => s.id)).toEqual(['a', 'm1']) + }) +}) diff --git a/web/src/sessions/groups.ts b/web/src/sessions/groups.ts new file mode 100644 index 0000000..80851ff --- /dev/null +++ b/web/src/sessions/groups.ts @@ -0,0 +1,86 @@ +/* + * Session groups, client side. + * + * The daemon records one optional fact — `group`, the id of the session a + * member was split from — and everything else about groups is a rendering + * decision made here: which sessions share a surface, what order the panes + * come in, and what the sessions list folds away. Groups are flat by + * construction: a member's `group` names its anchor and nothing nests, so + * every question below is answered by one field read, never a walk. + */ + +import type { SessionInfo } from '@/client/protocol' +import { keyOf, type FleetSession } from '@/fleet/types' + +/** + * The id of the group a session belongs to: its anchor's, or its own when it + * stands alone. This is what the terminal route resolves a URL through, so a + * link to any member opens the whole group. + */ +export function anchorIdOf(s: Pick): string { + return s.group !== undefined && s.group !== '' ? s.group : s.id +} + +/** + * The sessions that share one group surface, panes-in-order: the anchor + * first when it still exists, then members oldest split first, so panes keep + * their places as the list refreshes around them. + * + * Exited members stay in — a pane whose process ended shows its exit + * overlay rather than vanishing mid-read — and ephemeral ones stay out: a + * scratch terminal belongs to the modal, never to the pane layout. An anchor + * that is gone (closed and reaped) simply yields the members, which is what + * lets a group survive its anchor. + */ +export function groupMembers(rows: T[], anchorId: string): T[] { + const anchor = rows.filter((s) => s.id === anchorId && s.ephemeral !== true) + const members = rows + .filter((s) => s.group === anchorId && s.id !== anchorId && s.ephemeral !== true) + .sort((a, b) => + a.createdAt === b.createdAt + ? a.id.localeCompare(b.id) + : a.createdAt.localeCompare(b.createdAt), + ) + return [...anchor, ...members] +} + +/** What foldGroups hands back: the rows left on show, and each anchor's pane count. */ +export interface FoldedSessions { + rows: FleetSession[] + /** keyOf(anchor row) -> total panes in its group, only when more than one. */ + panes: Map +} + +/** + * Fold group members under their anchors for the sessions list: one row per + * group, wearing a pane count, rather than N rows for what a reader thinks + * of as one terminal. + * + * Machine-scoped, because two daemons mint ids with no knowledge of each + * other — a member folds only under an anchor on its own machine. A member + * whose anchor is not in the list at all (closed, reaped, or filtered away + * upstream) keeps its row: a session must never vanish from every surface at once. + */ +export function foldGroups(rows: FleetSession[]): FoldedSessions { + const anchors = new Map() + for (const s of rows) { + if (s.group === undefined || s.group === '') anchors.set(keyOf(s), s) + } + + const out: FleetSession[] = [] + const panes = new Map() + for (const s of rows) { + const anchorKey = `${s.machineId}/${anchorIdOf(s)}` + const anchor = s.group !== undefined && s.group !== '' ? anchors.get(anchorKey) : undefined + // A running member never folds under an exited anchor. The list hides + // exited rows by default, and a fold that put a live shell behind a + // hidden corpse would be that shell gone from the list, the search and + // the bulk bar for the whole exited-retention window. + if (anchor === undefined || (anchor.state === 'exited' && s.state !== 'exited')) { + out.push(s) + continue + } + panes.set(anchorKey, (panes.get(anchorKey) ?? 1) + 1) + } + return { rows: out, panes } +} diff --git a/web/src/sessions/pane-tree.test.ts b/web/src/sessions/pane-tree.test.ts new file mode 100644 index 0000000..71bc214 --- /dev/null +++ b/web/src/sessions/pane-tree.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest' + +import { + leaf, + leafIds, + parseTree, + prune, + reconcile, + reconcileTabs, + splitInTabs, + splitLeaf, + tabOf, + topRightLeaf, + withRatio, + type PaneTree, +} from './pane-tree' + +const AB: PaneTree = { split: 'row', ratio: 0.5, a: leaf('a'), b: leaf('b') } + +describe('splitLeaf', () => { + it('replaces the target leaf with a split of it and the newcomer', () => { + const t = splitLeaf(leaf('a'), 'a', 'row', 'b') + expect(t).toEqual(AB) + }) + + it('splits inside one side without touching the other — the ⇧⌘D case', () => { + // A|B, then split B downward: A stays a full-height column, B becomes a + // stack. The whole surface must not change axis. + const t = splitLeaf(AB, 'b', 'column', 'c') + expect(t).toEqual({ + split: 'row', + ratio: 0.5, + a: leaf('a'), + b: { split: 'column', ratio: 0.5, a: leaf('b'), b: leaf('c') }, + }) + }) + + it('returns the same tree when the target is not in it', () => { + expect(splitLeaf(AB, 'missing', 'row', 'c')).toBe(AB) + }) +}) + +describe('prune', () => { + it('collapses a split whose side has gone, giving the survivor the whole box', () => { + expect(prune(AB, new Set(['b']))).toEqual(leaf('b')) + }) + + it('prunes deep and keeps untouched subtrees by reference', () => { + const deep: PaneTree = { + split: 'row', + ratio: 0.3, + a: leaf('a'), + b: { split: 'column', ratio: 0.6, a: leaf('b'), b: leaf('c') }, + } + const t = prune(deep, new Set(['a', 'c'])) + expect(t).toEqual({ split: 'row', ratio: 0.3, a: leaf('a'), b: leaf('c') }) + expect(prune(deep, new Set(['a', 'b', 'c']))).toBe(deep) + }) + + it('is null when nothing survives', () => { + expect(prune(AB, new Set())).toBeNull() + }) +}) + +describe('reconcile', () => { + it('adopts a newcomer the tree has no placement for, off the root axis', () => { + const t = reconcile(AB, ['a', 'b', 'c']) + expect(leafIds(t!)).toEqual(['a', 'b', 'c']) + }) + + it('prunes what has gone and answers the same reference when nothing changed', () => { + expect(reconcile(AB, ['a', 'b'])).toBe(AB) + expect(reconcile(AB, ['a'])).toEqual(leaf('a')) + }) + + it('builds from nothing and empties to null', () => { + expect(leafIds(reconcile(null, ['a', 'b'])!)).toEqual(['a', 'b']) + expect(reconcile(AB, [])).toBeNull() + }) +}) + +describe('withRatio', () => { + it('sets the ratio at a path and clamps it away from the edges', () => { + const deep: PaneTree = { + split: 'row', + ratio: 0.5, + a: leaf('a'), + b: { split: 'column', ratio: 0.5, a: leaf('b'), b: leaf('c') }, + } + const t = withRatio(deep, ['b'], 0.7) + expect(t).not.toBe(deep) + expect((t as { b: { ratio: number } }).b.ratio).toBe(0.7) + expect(((withRatio(deep, [], 0.01) as { ratio: number }).ratio)).toBe(0.15) + }) + + it('answers the same tree for a path that names no split', () => { + expect(withRatio(AB, ['a'], 0.7)).toBe(AB) + }) +}) + +describe('tabs of trees', () => { + it('splits inside the tab that holds the target, leaving the others alone', () => { + const tabs = [AB, leaf('c')] + const next = splitInTabs(tabs, 'c', 'column', 'd') + expect(next[0]).toBe(AB) + expect(next[1]).toEqual({ split: 'column', ratio: 0.5, a: leaf('c'), b: leaf('d') }) + }) + + it('declines with the same array when no tab holds the target', () => { + // Same reference, not just same contents: the caller holds this in React + // state and saves it to localStorage, so a clone would cost a render and + // a write for a split that placed nothing. + const tabs = [AB, leaf('c')] + expect(splitInTabs(tabs, 'missing', 'row', 'd')).toBe(tabs) + }) + + it('reconciles: prunes emptied tabs, gives an unplaced newcomer its own tab', () => { + const tabs = [AB, leaf('c')] + const next = reconcileTabs(tabs, ['a', 'b', 'x']) + expect(next.map(leafIds)).toEqual([['a', 'b'], ['x']]) + // And the same reference when nothing changed — this lives in React state. + expect(reconcileTabs(tabs, ['a', 'b', 'c'])).toBe(tabs) + }) + + it('finds the tab holding a pane', () => { + expect(tabOf([AB, leaf('c')], 'b')).toBe(0) + expect(tabOf([AB, leaf('c')], 'c')).toBe(1) + expect(tabOf([AB], 'zz')).toBe(-1) + }) + + it('names the top-right pane: rightward through rows, upward through columns', () => { + expect(topRightLeaf(AB)).toBe('b') + expect( + topRightLeaf({ + split: 'row', + ratio: 0.5, + a: leaf('a'), + b: { split: 'column', ratio: 0.5, a: leaf('b'), b: leaf('c') }, + }), + ).toBe('b') + }) +}) + +describe('parseTree', () => { + it('round-trips a stored tree and refuses garbage', () => { + expect(parseTree(JSON.stringify(AB))).toEqual(AB) + expect(parseTree(null)).toBeNull() + expect(parseTree('not json')).toBeNull() + expect(parseTree(JSON.stringify({ split: 'row', ratio: 2, a: { leaf: 'a' } }))).toBeNull() + expect(parseTree(JSON.stringify({ leaf: 42 }))).toBeNull() + }) +}) diff --git a/web/src/sessions/pane-tree.ts b/web/src/sessions/pane-tree.ts new file mode 100644 index 0000000..dd4bfae --- /dev/null +++ b/web/src/sessions/pane-tree.ts @@ -0,0 +1,272 @@ +/* + * The split tree: how a group's panes are arranged on a desktop. + * + * A binary tree, tmux-shaped. A leaf is a session; a split holds two + * subtrees along one axis and the fraction the first one takes. Splitting + * always targets a pane — the one whose menu row or chord asked — and + * replaces that leaf with a split of the old pane and the new one, so ⇧⌘D + * over the right column of an A|B split yields A beside a B-over-C stack, + * never a wholesale change of axis. + * + * Pure data and pure functions, so the arithmetic is testable without a DOM + * and the route can hold the tree as plain state. Every mutation returns a + * new tree and never touches the old one; helpers return the *same* + * reference when there is nothing to change, which is what lets a caller use + * them inside a React state updater without manufacturing re-renders. + */ + +import type { SplitDirection } from '@/lib/split-keys' + +export type PaneTree = + | { leaf: string } + | { split: SplitDirection; ratio: number; a: PaneTree; b: PaneTree } + +/** A path from the root to a node: which child to take at each split. */ +export type TreePath = Array<'a' | 'b'> + +export function leaf(id: string): PaneTree { + return { leaf: id } +} + +/** Every session id in the tree, left to right. */ +export function leafIds(t: PaneTree): string[] { + if ('leaf' in t) return [t.leaf] + return [...leafIds(t.a), ...leafIds(t.b)] +} + +/** + * Replace the leaf holding `targetId` with a split of it and `newId` along + * `dir`, halves each. The same tree comes back when the target is not in it + * — the caller's placement raced a close, and inventing a position would be + * worse than declining. + */ +export function splitLeaf( + t: PaneTree, + targetId: string, + dir: SplitDirection, + newId: string, +): PaneTree { + if ('leaf' in t) { + if (t.leaf !== targetId) return t + return { split: dir, ratio: 0.5, a: t, b: leaf(newId) } + } + const a = splitLeaf(t.a, targetId, dir, newId) + if (a !== t.a) return { ...t, a } + const b = splitLeaf(t.b, targetId, dir, newId) + if (b !== t.b) return { ...t, b } + return t +} + +/** + * Drop every leaf not in `keep`, collapsing splits so the survivor takes its + * parent's whole box. Null when nothing survives. + */ +export function prune(t: PaneTree, keep: ReadonlySet): PaneTree | null { + if ('leaf' in t) return keep.has(t.leaf) ? t : null + const a = prune(t.a, keep) + const b = prune(t.b, keep) + if (a === null) return b + if (b === null) return a + if (a === t.a && b === t.b) return t + return { ...t, a, b } +} + +/** + * Bring the tree in line with the panes that actually exist: prune what has + * gone, and hang what appeared without a recorded placement — a split made + * on another device, say — off the root's own axis. The same reference comes + * back when the tree already agrees. + */ +export function reconcile(t: PaneTree | null, ids: readonly string[]): PaneTree | null { + if (ids.length === 0) return null + const keep = new Set(ids) + let next = t === null ? null : prune(t, keep) + const have = next === null ? new Set() : new Set(leafIds(next)) + for (const id of ids) { + if (have.has(id)) continue + have.add(id) + next = + next === null + ? leaf(id) + : { split: 'split' in next ? next.split : 'row', ratio: 0.5, a: next, b: leaf(id) } + } + return next +} + +/** + * The leaf whose box touches the surface's top-right corner: rightward at a + * row split, upward at a column split. It is where the control strip lives — + * the chips belong to the surface, not to a pane, so they sit at the + * surface's own corner and pass to the next corner pane when that one + * closes. + */ +export function topRightLeaf(t: PaneTree): string { + if ('leaf' in t) return t.leaf + return topRightLeaf(t.split === 'row' ? t.b : t.a) +} + +/** + * Bring a tab list in line with the panes that exist: prune every tab's + * tree, drop tabs that emptied, and give each unplaced newcomer a tab of its + * own — which is both what "new tab" resolves to before its placement lands + * and the honest home for a member some other device split. The same array + * comes back when nothing changed. + */ +export function reconcileTabs(tabs: readonly PaneTree[], ids: readonly string[]): PaneTree[] { + const keep = new Set(ids) + let changed = false + const out: PaneTree[] = [] + for (const t of tabs) { + const next = prune(t, keep) + if (next === null) { + changed = true + continue + } + if (next !== t) changed = true + out.push(next) + } + const have = new Set(out.flatMap(leafIds)) + for (const id of ids) { + if (have.has(id)) continue + have.add(id) + out.push(leaf(id)) + changed = true + } + // The caller holds this in React state: an unchanged answer must be the + // same reference, or every reconcile pass would be a render. + return changed ? out : (tabs as PaneTree[]) +} + +/** + * Split `targetId`'s leaf inside whichever tab holds it. The same array when + * no tab does — the caller's placement raced a close. + */ +export function splitInTabs( + tabs: readonly PaneTree[], + targetId: string, + dir: SplitDirection, + newId: string, +): PaneTree[] { + for (let i = 0; i < tabs.length; i++) { + const next = splitLeaf(tabs[i]!, targetId, dir, newId) + if (next !== tabs[i]) { + const out = [...tabs] + out[i] = next + return out + } + } + // The same reference, as promised above: this lives in React state, and a + // clone would spend a render (and a localStorage write) on a no-op. + return tabs as PaneTree[] +} + +/** The index of the tab holding `id`, or -1. */ +export function tabOf(tabs: readonly PaneTree[], id: string): number { + return tabs.findIndex((t) => leafIds(t).includes(id)) +} + +/** Read a group's persisted tab list from localStorage, empty when none. */ +export function loadTabs(storageKey: string): PaneTree[] { + try { + const raw = localStorage.getItem(`${storageKey}:tabs`) + if (raw === null) return [] + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter(valid) + } catch { + return [] + } +} + +/** + * Persist a group's tab list. Idempotent on purpose: the route calls it + * inside state updaters, which StrictMode double-invokes. + */ +export function saveTabs(storageKey: string, tabs: readonly PaneTree[]) { + try { + if (tabs.length === 0) localStorage.removeItem(`${storageKey}:tabs`) + else localStorage.setItem(`${storageKey}:tabs`, JSON.stringify(tabs)) + } catch { + // Storage full or unavailable: the layout still works, it just will not + // survive a reload. Not worth surfacing. + } +} + +/** The node at a path, or null when the path outruns the tree. */ +export function nodeAt(t: PaneTree, path: TreePath): PaneTree | null { + let node: PaneTree = t + for (const step of path) { + if ('leaf' in node) return null + node = node[step] + } + return node +} + +/** + * The tree with the split at `path` wearing `ratio`, clamped away from the + * edges so no pane can be dragged to nothing. The same tree when the path + * names no split. + */ +export function withRatio(t: PaneTree, path: TreePath, ratio: number): PaneTree { + if (path.length === 0) { + const clamped = Math.min(0.85, Math.max(0.15, ratio)) + if ('leaf' in t || t.ratio === clamped) return t + return { ...t, ratio: clamped } + } + if ('leaf' in t) return t + const step = path[0]! + const child = withRatio(t[step], path.slice(1), ratio) + if (child === t[step]) return t + return { ...t, [step]: child } +} + +/** Read a group's persisted tree from localStorage, or null. */ +export function loadTree(storageKey: string): PaneTree | null { + try { + return parseTree(localStorage.getItem(`${storageKey}:tree`)) + } catch { + return null + } +} + +/** + * Persist a group's tree. Idempotent on purpose: the route calls it inside + * state updaters, which StrictMode double-invokes. + */ +export function saveTree(storageKey: string, tree: PaneTree | null) { + try { + if (tree === null) localStorage.removeItem(`${storageKey}:tree`) + else localStorage.setItem(`${storageKey}:tree`, JSON.stringify(tree)) + } catch { + // Storage full or unavailable: the layout still works, it just will not + // survive a reload. Not worth surfacing. + } +} + +/** Parse a stored tree, refusing anything that does not hold together. */ +export function parseTree(raw: string | null): PaneTree | null { + if (raw === null) return null + try { + const parsed: unknown = JSON.parse(raw) + return valid(parsed) ? parsed : null + } catch { + return null + } +} + +function valid(node: unknown): node is PaneTree { + if (typeof node !== 'object' || node === null) return false + if ('leaf' in node) { + return typeof (node as { leaf: unknown }).leaf === 'string' && Object.keys(node).length === 1 + } + const s = node as { split?: unknown; ratio?: unknown; a?: unknown; b?: unknown } + return ( + (s.split === 'row' || s.split === 'column') && + typeof s.ratio === 'number' && + Number.isFinite(s.ratio) && + s.ratio > 0 && + s.ratio < 1 && + valid(s.a) && + valid(s.b) + ) +} diff --git a/web/src/switcher/order.test.ts b/web/src/switcher/order.test.ts index 9c6b9d4..d548c0a 100644 --- a/web/src/switcher/order.test.ts +++ b/web/src/switcher/order.test.ts @@ -389,3 +389,75 @@ describe('the cycle', () => { expect(stepCycle(order, 'local/over', 1)?.id).toBe('a') }) }) + +describe('the group section', () => { + const anchor = s({ id: 'a', name: 'api' }) + const member = s({ id: 'm1', name: 'logs', group: 'a', createdAt: '2026-01-02T00:00:00Z' }) + const other = s({ id: 'x', name: 'elsewhere' }) + + it('leads the resting palette with the current group’s other terminals', () => { + const palette = buildPalette({ + sessions: [other, member, anchor], + recents: [], + search: '', + currentKey: 'local/a', + }) + expect(sectionKeys(palette)[0]).toBe('group') + const group = palette.sections[0]! + expect(group.label).toBe('This session') + // The session the tab is on gets no *group* row — this section is a + // take-me-there control, and here is not a there — though it keeps its + // ordinary row further down, marked current, as it always has. The + // sibling does not resurface below. + expect(group.rows.map((r) => r.key)).toEqual(['local/m1']) + const all = palette.sections.find((sec) => sec.key === 'all')! + expect(all.rows.map((r) => r.key)).toEqual(['local/a', 'local/x']) + }) + + it('resolves the group from a member’s own tab too', () => { + const palette = buildPalette({ + sessions: [anchor, member], + recents: [], + search: '', + currentKey: 'local/m1', + }) + expect(palette.sections[0]!.rows.map((r) => r.key)).toEqual(['local/a']) + }) + + it('is absent outside a session and for a group of one', () => { + for (const currentKey of [null, 'local/x']) { + const palette = buildPalette({ + sessions: [anchor, member, other], + recents: [], + search: '', + currentKey, + }) + expect(sectionKeys(palette)).not.toContain('group') + } + }) + + it('leaves a pinned sibling to the Pinned run — the number chords hang off it', () => { + const pinnedMember = s({ id: 'm2', name: 'pinned one', group: 'a', pinned: true }) + const palette = buildPalette({ + sessions: [anchor, member, pinnedMember], + recents: [], + search: '', + currentKey: 'local/a', + }) + const group = palette.sections.find((sec) => sec.key === 'group')! + const pinned = palette.sections.find((sec) => sec.key === 'pinned')! + expect(group.rows.map((r) => r.key)).toEqual(['local/m1']) + expect(pinned.rows.map((r) => r.key)).toEqual(['local/m2']) + }) + + it('never crosses machines, however the ids collide', () => { + const farMember = s({ id: 'm1', group: 'a', machineId: 'far', machineName: 'far' }) + const palette = buildPalette({ + sessions: [anchor, farMember], + recents: [], + search: '', + currentKey: 'local/a', + }) + expect(sectionKeys(palette)).not.toContain('group') + }) +}) diff --git a/web/src/switcher/order.ts b/web/src/switcher/order.ts index e79634e..b261a59 100644 --- a/web/src/switcher/order.ts +++ b/web/src/switcher/order.ts @@ -8,6 +8,7 @@ */ import type { FleetSession } from '@/fleet/types' import { keyOf } from '@/fleet/types' +import { anchorIdOf, groupMembers } from '@/sessions/groups' import { displayName, filterSessions, orderSessions } from '@/sessions/view' import { visitKey, type RecentVisit } from './recents' @@ -54,7 +55,7 @@ export type SwitcherRow = /** A headed run of rows. Sections with nothing in them are never returned. */ export interface SwitcherSection { - key: 'pinned' | 'recent' | 'all' | 'results' + key: 'group' | 'pinned' | 'recent' | 'all' | 'results' label: string rows: SwitcherRow[] } @@ -125,9 +126,32 @@ function restingSections( ): SwitcherSection[] { // The dead do not rest here — see the ended-sessions note on buildPalette. const usable = sessions.filter((s) => s.state !== 'exited') + const pinned = pinnedOrder(usable) const spoken = new Set(pinned.map(keyOf)) + // The other terminals of the group this tab is inside — its panes and tabs + // — lead the palette: from within a split, "the shell next to this one" is + // the likeliest there of all, and the list folds members away, so this is + // the one place they are offered by name. The session the tab is *on* is + // deliberately not a row (a take-me-there control does not offer here), + // and a pinned sibling keeps its Pinned row instead — the ⌃⇧1..9 badges + // number that run, and a section that poached from it would renumber the + // chords. + const siblings: FleetSession[] = [] + if (currentKey !== null) { + const here = live.get(currentKey) + if (here !== undefined) { + const machineRows = usable.filter((s) => s.machineId === here.machineId) + for (const s of groupMembers(machineRows, anchorIdOf(here))) { + const key = keyOf(s) + if (key === currentKey || spoken.has(key)) continue + spoken.add(key) + siblings.push(s) + } + } + } + const recentRows: SwitcherRow[] = [] for (const visit of recents) { const key = visitKey(visit) @@ -156,6 +180,11 @@ function restingSections( ).map((session) => liveRow(session, null, currentKey)) return trim([ + { + key: 'group', + label: 'This session', + rows: siblings.map((s) => liveRow(s, null, currentKey)), + }, { key: 'pinned', label: 'Pinned', rows: pinned.map((s, at) => liveRow(s, badgeAt(at), currentKey)) }, { key: 'recent', label: 'Recent', rows: recentRows }, { key: 'all', label: 'All sessions', rows: rest },