diff --git a/CHANGELOG.md b/CHANGELOG.md index 94c725c..7967163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - TCP attaches now require a lightweight bearer token stored under `$XDG_DATA_HOME/patterm/clients/token`; local unix-socket attaches remain exempt and rely on socket file permissions. +- The daemon now tracks a display owner per pane so a second client + viewing the same pane does not resize the underlying PTY/emulator; + ownership is released on detach and the next focuser can claim and + resize the pane. - patterm can now keep multiple local projects loaded in one loopback daemon core, with command-palette entries to switch the current client view or open another project without tearing down processes diff --git a/internal/app/client_net.go b/internal/app/client_net.go index 033bd84..ad36de4 100644 --- a/internal/app/client_net.go +++ b/internal/app/client_net.go @@ -134,6 +134,8 @@ type netClient struct { mu sync.Mutex focusedID string + paneSize protocol.Size + ownerView bool chrome chromeModel renderer *viewportRenderer palette *clientCommandPrompt @@ -287,10 +289,13 @@ func (c *netClient) handleFrame(f protocol.Frame) error { } c.mu.Lock() c.focusedID = msg.PaneID - c.renderer = newViewportRenderer(c.layout) + c.paneSize = msg.Size + c.ownerView = msg.DisplayOwner + c.renderer = newViewportRenderer(c.renderLayoutLocked(msg.Size)) renderer := c.renderer c.mu.Unlock() c.clearViewport() + c.drawChrome() c.writeWrapped(renderer.Render(msg.Bytes)) case protocol.FramePaneChunk: msg, err := protocol.Decode[protocol.PaneChunk](f) @@ -300,6 +305,11 @@ func (c *netClient) handleFrame(f protocol.Frame) error { c.mu.Lock() focused := c.focusedID renderer := c.renderer + c.paneSize = msg.Size + c.ownerView = msg.DisplayOwner + if renderer != nil && (msg.Size.Cols != 0 || msg.Size.Rows != 0) { + renderer.SetLayout(c.renderLayoutLocked(msg.Size)) + } c.mu.Unlock() if msg.PaneID == focused && renderer != nil { c.writeWrapped(renderer.Render(msg.Bytes)) @@ -508,7 +518,7 @@ func (c *netClient) resize(cols, rows uint16) error { c.mu.Lock() c.layout = newTerminalLayout(cols, rows) if c.renderer != nil { - c.renderer.SetLayout(c.layout) + c.renderer.SetLayout(c.renderLayoutLocked(c.paneSize)) } size := protocol.Size{Cols: c.layout.childCols(), Rows: c.layout.childRows()} c.mu.Unlock() @@ -519,6 +529,17 @@ func (c *netClient) resize(cols, rows uint16) error { return c.t.Send(f) } +func (c *netClient) renderLayoutLocked(size protocol.Size) terminalLayout { + l := c.layout + if size.Cols != 0 && size.Cols < l.mainCols { + l.mainCols = size.Cols + } + if size.Rows != 0 && size.Rows < l.mainRows { + l.mainRows = size.Rows + } + return l +} + func (c *netClient) enterScreen() { _, _ = c.out.Write([]byte("\x1b[?1049h\x1b[H\x1b[2J\x1b[?25h\x1b[?1000h\x1b[?1006h")) c.installScrollRegion() @@ -589,6 +610,13 @@ func (c *netClient) drawChrome() { if model.FocusedID != "" { status = fmt.Sprintf("%s · %s", model.FocusedID, status) } + c.mu.Lock() + size := c.paneSize + ownerView := c.ownerView + c.mu.Unlock() + if model.FocusedID != "" && !ownerView && size.Cols != 0 && size.Rows != 0 { + status = fmt.Sprintf("viewing at owner size %dx%d · %s", size.Cols, size.Rows, status) + } if prompt != nil { status = "command: " + string(prompt.buf) } diff --git a/internal/app/client_subscriber.go b/internal/app/client_subscriber.go index c8c728f..b22b4e4 100644 --- a/internal/app/client_subscriber.go +++ b/internal/app/client_subscriber.go @@ -15,6 +15,8 @@ const defaultClientSubscriberQueue = 256 // needing a fresh snapshot. type clientSubscriber struct { projectKey string + project *Project + clientID string frames chan protocol.Frame mu sync.Mutex @@ -22,12 +24,18 @@ type clientSubscriber struct { lifecycleDirty bool } -func newClientSubscriber(projectKey string, size int) *clientSubscriber { +func newClientSubscriber(project *Project, clientID string, size int) *clientSubscriber { if size <= 0 { size = defaultClientSubscriberQueue } + projectKey := "" + if project != nil { + projectKey = project.Key + } return &clientSubscriber{ projectKey: projectKey, + project: project, + clientID: clientID, frames: make(chan protocol.Frame, size), snapshotRequired: make(map[string]bool), lifecycleDirty: false, @@ -72,7 +80,12 @@ func (s *clientSubscriber) OnChildStateChanged(id string, state IdleState) { func (s *clientSubscriber) OnPTYOut(childID string, chunk []byte) { cp := append([]byte(nil), chunk...) - f, err := protocol.NewFrame(protocol.FramePaneChunk, protocol.PaneChunk{PaneID: childID, Bytes: cp}) + var size protocol.Size + var ownerID string + if s.project != nil { + size, ownerID, _ = s.project.PaneDisplay(childID) + } + f, err := protocol.NewFrame(protocol.FramePaneChunk, protocol.PaneChunk{PaneID: childID, Bytes: cp, Size: size, DisplayOwner: ownerID == "" || ownerID == s.clientID}) if err != nil { return } diff --git a/internal/app/client_subscriber_test.go b/internal/app/client_subscriber_test.go index e50fe72..d9b325c 100644 --- a/internal/app/client_subscriber_test.go +++ b/internal/app/client_subscriber_test.go @@ -7,7 +7,7 @@ import ( ) func TestClientSubscriberCopiesChunksAndMarksSnapshotOnOverflow(t *testing.T) { - sub := newClientSubscriber("project", 1) + sub := newClientSubscriber(&Project{Key: "project"}, "client", 1) chunk := []byte("first") sub.OnPTYOut("p_123456", chunk) chunk[0] = 'X' diff --git a/internal/app/daemon_core.go b/internal/app/daemon_core.go index 0154f3f..6a8f350 100644 --- a/internal/app/daemon_core.go +++ b/internal/app/daemon_core.go @@ -13,6 +13,7 @@ import ( "github.com/hjbdev/patterm/internal/persist" "github.com/hjbdev/patterm/internal/preset" "github.com/hjbdev/patterm/internal/projectkey" + "github.com/hjbdev/patterm/internal/protocol" "github.com/hjbdev/patterm/internal/scratchpad" "github.com/hjbdev/patterm/internal/trust" ) @@ -30,9 +31,17 @@ type Project struct { Host *toolHost savedProcess []persist.Entry + displayMu sync.Mutex + displayOwners map[string]paneDisplayOwner + lastActive time.Time } +type paneDisplayOwner struct { + ClientID string + Size protocol.Size +} + type projectSummary struct { Key string Dir string @@ -111,17 +120,18 @@ func (r *ProjectRegistry) Open(ctx context.Context, dir string) (*Project, error go sess.runClassifier(ctx) p := &Project{ - Key: key, - Dir: abs, - Name: filepath.Base(abs), - Session: sess, - Pads: pads, - Trust: trustStore, - Persist: persistStore, - Launcher: launcher, - Host: host, - savedProcess: savedProcesses, - lastActive: time.Now(), + Key: key, + Dir: abs, + Name: filepath.Base(abs), + Session: sess, + Pads: pads, + Trust: trustStore, + Persist: persistStore, + Launcher: launcher, + Host: host, + savedProcess: savedProcesses, + displayOwners: make(map[string]paneDisplayOwner), + lastActive: time.Now(), } r.mu.Lock() @@ -156,6 +166,73 @@ func (r *ProjectRegistry) DefaultProject() *Project { return r.projects[r.defaultProjectKey] } +func (p *Project) ClaimPaneDisplay(clientID, paneID string, size protocol.Size) (protocol.Size, bool) { + if p == nil || paneID == "" { + return size, true + } + if size.Cols == 0 || size.Rows == 0 { + size = protocol.Size{Cols: 80, Rows: 24} + } + p.displayMu.Lock() + if p.displayOwners == nil { + p.displayOwners = make(map[string]paneDisplayOwner) + } + owner, ok := p.displayOwners[paneID] + if !ok || owner.ClientID == "" || owner.ClientID == clientID { + p.displayOwners[paneID] = paneDisplayOwner{ClientID: clientID, Size: size} + p.displayMu.Unlock() + p.Session.ResizeChild(paneID, size.Cols, size.Rows) + return size, true + } + p.displayMu.Unlock() + return owner.Size, false +} + +func (p *Project) ResizeClientDisplays(clientID string, size protocol.Size) { + if p == nil || size.Cols == 0 || size.Rows == 0 { + return + } + p.displayMu.Lock() + var panes []string + for paneID, owner := range p.displayOwners { + if owner.ClientID != clientID { + continue + } + owner.Size = size + p.displayOwners[paneID] = owner + panes = append(panes, paneID) + } + p.displayMu.Unlock() + for _, paneID := range panes { + p.Session.ResizeChild(paneID, size.Cols, size.Rows) + } + p.Launcher.SetSize(size.Cols, size.Rows) + p.Host.SetSize(size.Cols, size.Rows) +} + +func (p *Project) ReleaseClientDisplays(clientID string) { + if p == nil { + return + } + p.displayMu.Lock() + for paneID, owner := range p.displayOwners { + if owner.ClientID == clientID { + delete(p.displayOwners, paneID) + } + } + p.displayMu.Unlock() +} + +func (p *Project) PaneDisplay(paneID string) (protocol.Size, string, bool) { + if p == nil || paneID == "" { + return protocol.Size{}, "", false + } + p.displayMu.Lock() + defer p.displayMu.Unlock() + owner, ok := p.displayOwners[paneID] + return owner.Size, owner.ClientID, ok +} + func (r *ProjectRegistry) Shutdown() { r.mu.Lock() projects := make([]*Project, 0, len(r.projects)) diff --git a/internal/app/daemon_net.go b/internal/app/daemon_net.go index 4163bd3..5619759 100644 --- a/internal/app/daemon_net.go +++ b/internal/app/daemon_net.go @@ -294,14 +294,9 @@ func handleDaemonAttach(ctx context.Context, registry *ProjectRegistry, t protoc _ = sendProtocolError(t, "no project open") return } - if attach.TermSize.Cols > 0 && attach.TermSize.Rows > 0 { - project.Session.ResizeAll(attach.TermSize.Cols, attach.TermSize.Rows) - project.Launcher.SetSize(attach.TermSize.Cols, attach.TermSize.Rows) - project.Host.SetSize(attach.TermSize.Cols, attach.TermSize.Rows) - } - + clientID := fmt.Sprintf("c-%d", time.Now().UnixNano()) view := ClientView{ - ID: fmt.Sprintf("c-%d", time.Now().UnixNano()), + ID: clientID, ProjectKey: project.Key, ProjectName: project.Name, Cols: attach.TermSize.Cols, @@ -309,16 +304,18 @@ func handleDaemonAttach(ctx context.Context, registry *ProjectRegistry, t protoc } if child := firstRunningTopLevel(project.Session.Children()); child != nil { view.FocusChild(child.ID) + project.ClaimPaneDisplay(clientID, child.ID, attach.TermSize) } - sub := newClientSubscriber(project.Key, defaultClientSubscriberQueue) + sub := newClientSubscriber(project, clientID, defaultClientSubscriberQueue) project.Session.SubscribeClient(sub) defer project.Session.UnsubscribeClient(sub) + defer project.ReleaseClientDisplays(clientID) _ = sendHello(t, project, view.ID) _ = sendProjectList(t, registry, project.Key) _ = sendChrome(t, project, view) if view.FocusedID != "" { - _ = sendSnapshot(t, project, view.FocusedID) + _ = sendSnapshot(t, project, clientID, view.FocusedID) } // Close the transport when the daemon context is cancelled (shutdown or @@ -361,22 +358,28 @@ func handleDaemonAttach(ctx context.Context, registry *ProjectRegistry, t protoc case protocol.FrameResize: msg, err := protocol.Decode[protocol.Resize](f) if err == nil { - project.Session.ResizeAll(msg.Size.Cols, msg.Size.Rows) - project.Launcher.SetSize(msg.Size.Cols, msg.Size.Rows) - project.Host.SetSize(msg.Size.Cols, msg.Size.Rows) + view.Resize(msg.Size.Cols, msg.Size.Rows) + if view.FocusedID != "" { + if _, _, ok := project.PaneDisplay(view.FocusedID); !ok { + project.ClaimPaneDisplay(clientID, view.FocusedID, msg.Size) + } + } + project.ResizeClientDisplays(clientID, msg.Size) } case protocol.FrameFocus: msg, err := protocol.Decode[protocol.Focus](f) if err == nil && msg.PaneID != "" { view.FocusChild(msg.PaneID) + project.ClaimPaneDisplay(clientID, msg.PaneID, protocol.Size{Cols: view.Cols, Rows: view.Rows}) _ = sendChrome(t, project, view) - _ = sendSnapshot(t, project, msg.PaneID) + _ = sendSnapshot(t, project, clientID, msg.PaneID) } case protocol.FramePaletteCommand: if child := handleDaemonPaletteCommand(project, f); child != nil { view.FocusChild(child.ID) + project.ClaimPaneDisplay(clientID, child.ID, protocol.Size{Cols: view.Cols, Rows: view.Rows}) _ = sendChrome(t, project, view) - _ = sendSnapshot(t, project, child.ID) + _ = sendSnapshot(t, project, clientID, child.ID) } } select { @@ -451,12 +454,18 @@ func sendChrome(t protocol.Transport, p *Project, view ClientView) error { return t.Send(f) } -func sendSnapshot(t protocol.Transport, p *Project, paneID string) error { +func sendSnapshot(t protocol.Transport, p *Project, clientID, paneID string) error { b, err := p.Session.SerializeChild(paneID) if err != nil { return nil } - f, err := protocol.NewFrame(protocol.FramePaneSnapshot, protocol.PaneSnapshot{PaneID: paneID, Bytes: b}) + size, ownerID, _ := p.PaneDisplay(paneID) + f, err := protocol.NewFrame(protocol.FramePaneSnapshot, protocol.PaneSnapshot{ + PaneID: paneID, + Bytes: b, + Size: size, + DisplayOwner: ownerID == "" || ownerID == clientID, + }) if err != nil { return err } diff --git a/internal/app/daemon_net_test.go b/internal/app/daemon_net_test.go index 5060af9..98978c2 100644 --- a/internal/app/daemon_net_test.go +++ b/internal/app/daemon_net_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/hjbdev/patterm/internal/preset" "github.com/hjbdev/patterm/internal/protocol" ) @@ -160,6 +161,76 @@ func TestDaemonTCPTokenAuthAndUnixExemption(t *testing.T) { } } +func TestDaemonPaneDisplayOwnerSizing(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + projectDir := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + reg := newProjectRegistry(preset.Set{}, defaultSettings(), nil, 80, 24) + defer reg.Shutdown() + project, err := reg.Open(ctx, projectDir) + if err != nil { + t.Fatalf("open project: %v", err) + } + + client1, daemon1 := protocol.NewLoopbackPair() + go handleDaemonConn(ctx, cancel, reg, daemon1, "") + sendFrame(t, client1, protocol.FrameAttach, protocol.Attach{ + ProjectPath: projectDir, + TermSize: protocol.Size{Cols: 80, Rows: 24}, + }) + expectFrame(t, client1, protocol.FrameHello) + expectFrame(t, client1, protocol.FrameProjectList) + expectFrame(t, client1, protocol.FrameChrome) + + data, _ := json.Marshal(map[string]any{ + "argv": []string{"sh", "-c", "trap 'exit 0' TERM; while :; do sleep 1; done"}, + "name": "owner-pane", + }) + sendFrame(t, client1, protocol.FramePaletteCommand, protocol.PaletteCommand{ + Kind: "spawn_command", + Data: data, + }) + paneID := waitForLifecycleID(t, client1, protocol.LifecycleSpawned, 3*time.Second) + snap1 := waitForSnapshot(t, client1, paneID, 3*time.Second) + if !snap1.DisplayOwner || snap1.Size != (protocol.Size{Cols: 80, Rows: 24}) { + t.Fatalf("owner snapshot = owner:%v size:%+v, want owner true size 80x24", snap1.DisplayOwner, snap1.Size) + } + waitForEmulatorSize(t, project, paneID, 80, 24) + + client2, daemon2 := protocol.NewLoopbackPair() + go handleDaemonConn(ctx, cancel, reg, daemon2, "") + sendFrame(t, client2, protocol.FrameAttach, protocol.Attach{ + ProjectPath: projectDir, + TermSize: protocol.Size{Cols: 100, Rows: 30}, + }) + expectFrame(t, client2, protocol.FrameHello) + expectFrame(t, client2, protocol.FrameProjectList) + expectFrame(t, client2, protocol.FrameChrome) + snap2 := waitForSnapshot(t, client2, paneID, 3*time.Second) + if snap2.DisplayOwner || snap2.Size != (protocol.Size{Cols: 80, Rows: 24}) { + t.Fatalf("viewer snapshot = owner:%v size:%+v, want owner false size 80x24", snap2.DisplayOwner, snap2.Size) + } + sendFrame(t, client2, protocol.FrameResize, protocol.Resize{Size: protocol.Size{Cols: 100, Rows: 30}}) + time.Sleep(100 * time.Millisecond) + waitForEmulatorSize(t, project, paneID, 80, 24) + + sendFrame(t, client1, protocol.FrameDetach, protocol.Detach{}) + _ = client1.Close() + time.Sleep(100 * time.Millisecond) + sendFrame(t, client2, protocol.FrameFocus, protocol.Focus{PaneID: paneID}) + snap3 := waitForSnapshot(t, client2, paneID, 3*time.Second) + if !snap3.DisplayOwner || snap3.Size != (protocol.Size{Cols: 100, Rows: 30}) { + t.Fatalf("claimed snapshot = owner:%v size:%+v, want owner true size 100x30", snap3.DisplayOwner, snap3.Size) + } + waitForEmulatorSize(t, project, paneID, 100, 30) + + sendFrame(t, client2, protocol.FrameDetach, protocol.Detach{}) + _ = client2.Close() +} + func waitForSocket(t *testing.T, socket string, errCh <-chan error) { t.Helper() deadline := time.Now().Add(3 * time.Second) @@ -297,6 +368,81 @@ func waitForLifecycle(t *testing.T, tr protocol.Transport, kind protocol.Lifecyc t.Fatalf("lifecycle %s not received", kind) } +func waitForLifecycleID(t *testing.T, tr protocol.Transport, kind protocol.LifecycleKind, timeout time.Duration) string { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + f, err, ok := recvFrameWithin(tr, time.Until(deadline)) + if !ok { + break + } + if err != nil { + t.Fatalf("recv lifecycle: %v", err) + } + if f.Type != protocol.FrameLifecycle { + continue + } + msg, err := protocol.Decode[protocol.Lifecycle](f) + if err != nil { + t.Fatalf("decode lifecycle: %v", err) + } + if msg.Kind == kind { + return msg.ChildID + } + } + t.Fatalf("lifecycle %s not received", kind) + return "" +} + +func waitForSnapshot(t *testing.T, tr protocol.Transport, paneID string, timeout time.Duration) protocol.PaneSnapshot { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + f, err, ok := recvFrameWithin(tr, time.Until(deadline)) + if !ok { + break + } + if err != nil { + t.Fatalf("recv snapshot: %v", err) + } + if f.Type != protocol.FramePaneSnapshot { + continue + } + msg, err := protocol.Decode[protocol.PaneSnapshot](f) + if err != nil { + t.Fatalf("decode snapshot: %v", err) + } + if msg.PaneID == paneID { + return msg + } + } + t.Fatalf("snapshot for %s not received", paneID) + return protocol.PaneSnapshot{} +} + +func waitForEmulatorSize(t *testing.T, project *Project, paneID string, cols, rows uint16) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if c := project.Session.FindChild(paneID); c != nil { + if em := c.Emulator(); em != nil { + gotCols, gotRows := em.Size() + if gotCols == cols && gotRows == rows { + return + } + } + } + time.Sleep(25 * time.Millisecond) + } + if c := project.Session.FindChild(paneID); c != nil { + if em := c.Emulator(); em != nil { + gotCols, gotRows := em.Size() + t.Fatalf("emulator size = %dx%d, want %dx%d", gotCols, gotRows, cols, rows) + } + } + t.Fatalf("pane %s missing emulator", paneID) +} + func recvFrameWithin(tr protocol.Transport, timeout time.Duration) (protocol.Frame, error, bool) { type result struct { f protocol.Frame diff --git a/internal/app/session.go b/internal/app/session.go index 9f0af47..155f1f6 100644 --- a/internal/app/session.go +++ b/internal/app/session.go @@ -742,6 +742,22 @@ func (s *Session) ResizeAll(cols, rows uint16) { } } +func (s *Session) ResizeChild(id string, cols, rows uint16) { + if cols == 0 || rows == 0 { + return + } + c := s.FindChild(id) + if c == nil { + return + } + if pty := c.PTY(); pty != nil { + _ = pty.Resize(cols, rows) + } + if em := c.Emulator(); em != nil { + _ = em.Resize(cols, rows) + } +} + // SerializeChild returns the VT bytes that reproduce the child's // current screen state. Used to repaint a child after the user switches // focus or closes the palette. diff --git a/internal/protocol/frame.go b/internal/protocol/frame.go index 15a4136..982297c 100644 --- a/internal/protocol/frame.go +++ b/internal/protocol/frame.go @@ -108,13 +108,17 @@ type Chrome struct { } type PaneSnapshot struct { - PaneID string `json:"pane_id"` - Bytes []byte `json:"bytes"` + PaneID string `json:"pane_id"` + Bytes []byte `json:"bytes"` + Size Size `json:"size,omitempty"` + DisplayOwner bool `json:"display_owner,omitempty"` } type PaneChunk struct { - PaneID string `json:"pane_id"` - Bytes []byte `json:"bytes"` + PaneID string `json:"pane_id"` + Bytes []byte `json:"bytes"` + Size Size `json:"size,omitempty"` + DisplayOwner bool `json:"display_owner,omitempty"` } type LifecycleKind string