add per-pane display ownership

This commit is contained in:
2026-05-27 14:30:47 +01:00
parent 63cb8a4388
commit 6d15626e05
9 changed files with 333 additions and 36 deletions

View File

@@ -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 - TCP attaches now require a lightweight bearer token stored under
`$XDG_DATA_HOME/patterm/clients/token`; local unix-socket attaches `$XDG_DATA_HOME/patterm/clients/token`; local unix-socket attaches
remain exempt and rely on socket file permissions. 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 - patterm can now keep multiple local projects loaded in one loopback
daemon core, with command-palette entries to switch the current daemon core, with command-palette entries to switch the current
client view or open another project without tearing down processes client view or open another project without tearing down processes

View File

@@ -134,6 +134,8 @@ type netClient struct {
mu sync.Mutex mu sync.Mutex
focusedID string focusedID string
paneSize protocol.Size
ownerView bool
chrome chromeModel chrome chromeModel
renderer *viewportRenderer renderer *viewportRenderer
palette *clientCommandPrompt palette *clientCommandPrompt
@@ -287,10 +289,13 @@ func (c *netClient) handleFrame(f protocol.Frame) error {
} }
c.mu.Lock() c.mu.Lock()
c.focusedID = msg.PaneID 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 renderer := c.renderer
c.mu.Unlock() c.mu.Unlock()
c.clearViewport() c.clearViewport()
c.drawChrome()
c.writeWrapped(renderer.Render(msg.Bytes)) c.writeWrapped(renderer.Render(msg.Bytes))
case protocol.FramePaneChunk: case protocol.FramePaneChunk:
msg, err := protocol.Decode[protocol.PaneChunk](f) msg, err := protocol.Decode[protocol.PaneChunk](f)
@@ -300,6 +305,11 @@ func (c *netClient) handleFrame(f protocol.Frame) error {
c.mu.Lock() c.mu.Lock()
focused := c.focusedID focused := c.focusedID
renderer := c.renderer 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() c.mu.Unlock()
if msg.PaneID == focused && renderer != nil { if msg.PaneID == focused && renderer != nil {
c.writeWrapped(renderer.Render(msg.Bytes)) c.writeWrapped(renderer.Render(msg.Bytes))
@@ -508,7 +518,7 @@ func (c *netClient) resize(cols, rows uint16) error {
c.mu.Lock() c.mu.Lock()
c.layout = newTerminalLayout(cols, rows) c.layout = newTerminalLayout(cols, rows)
if c.renderer != nil { 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()} size := protocol.Size{Cols: c.layout.childCols(), Rows: c.layout.childRows()}
c.mu.Unlock() c.mu.Unlock()
@@ -519,6 +529,17 @@ func (c *netClient) resize(cols, rows uint16) error {
return c.t.Send(f) 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() { func (c *netClient) enterScreen() {
_, _ = c.out.Write([]byte("\x1b[?1049h\x1b[H\x1b[2J\x1b[?25h\x1b[?1000h\x1b[?1006h")) _, _ = c.out.Write([]byte("\x1b[?1049h\x1b[H\x1b[2J\x1b[?25h\x1b[?1000h\x1b[?1006h"))
c.installScrollRegion() c.installScrollRegion()
@@ -589,6 +610,13 @@ func (c *netClient) drawChrome() {
if model.FocusedID != "" { if model.FocusedID != "" {
status = fmt.Sprintf("%s · %s", model.FocusedID, status) 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 { if prompt != nil {
status = "command: " + string(prompt.buf) status = "command: " + string(prompt.buf)
} }

View File

@@ -15,6 +15,8 @@ const defaultClientSubscriberQueue = 256
// needing a fresh snapshot. // needing a fresh snapshot.
type clientSubscriber struct { type clientSubscriber struct {
projectKey string projectKey string
project *Project
clientID string
frames chan protocol.Frame frames chan protocol.Frame
mu sync.Mutex mu sync.Mutex
@@ -22,12 +24,18 @@ type clientSubscriber struct {
lifecycleDirty bool lifecycleDirty bool
} }
func newClientSubscriber(projectKey string, size int) *clientSubscriber { func newClientSubscriber(project *Project, clientID string, size int) *clientSubscriber {
if size <= 0 { if size <= 0 {
size = defaultClientSubscriberQueue size = defaultClientSubscriberQueue
} }
projectKey := ""
if project != nil {
projectKey = project.Key
}
return &clientSubscriber{ return &clientSubscriber{
projectKey: projectKey, projectKey: projectKey,
project: project,
clientID: clientID,
frames: make(chan protocol.Frame, size), frames: make(chan protocol.Frame, size),
snapshotRequired: make(map[string]bool), snapshotRequired: make(map[string]bool),
lifecycleDirty: false, lifecycleDirty: false,
@@ -72,7 +80,12 @@ func (s *clientSubscriber) OnChildStateChanged(id string, state IdleState) {
func (s *clientSubscriber) OnPTYOut(childID string, chunk []byte) { func (s *clientSubscriber) OnPTYOut(childID string, chunk []byte) {
cp := append([]byte(nil), chunk...) 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 { if err != nil {
return return
} }

View File

@@ -7,7 +7,7 @@ import (
) )
func TestClientSubscriberCopiesChunksAndMarksSnapshotOnOverflow(t *testing.T) { func TestClientSubscriberCopiesChunksAndMarksSnapshotOnOverflow(t *testing.T) {
sub := newClientSubscriber("project", 1) sub := newClientSubscriber(&Project{Key: "project"}, "client", 1)
chunk := []byte("first") chunk := []byte("first")
sub.OnPTYOut("p_123456", chunk) sub.OnPTYOut("p_123456", chunk)
chunk[0] = 'X' chunk[0] = 'X'

View File

@@ -13,6 +13,7 @@ import (
"github.com/hjbdev/patterm/internal/persist" "github.com/hjbdev/patterm/internal/persist"
"github.com/hjbdev/patterm/internal/preset" "github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/projectkey" "github.com/hjbdev/patterm/internal/projectkey"
"github.com/hjbdev/patterm/internal/protocol"
"github.com/hjbdev/patterm/internal/scratchpad" "github.com/hjbdev/patterm/internal/scratchpad"
"github.com/hjbdev/patterm/internal/trust" "github.com/hjbdev/patterm/internal/trust"
) )
@@ -30,9 +31,17 @@ type Project struct {
Host *toolHost Host *toolHost
savedProcess []persist.Entry savedProcess []persist.Entry
displayMu sync.Mutex
displayOwners map[string]paneDisplayOwner
lastActive time.Time lastActive time.Time
} }
type paneDisplayOwner struct {
ClientID string
Size protocol.Size
}
type projectSummary struct { type projectSummary struct {
Key string Key string
Dir string Dir string
@@ -111,17 +120,18 @@ func (r *ProjectRegistry) Open(ctx context.Context, dir string) (*Project, error
go sess.runClassifier(ctx) go sess.runClassifier(ctx)
p := &Project{ p := &Project{
Key: key, Key: key,
Dir: abs, Dir: abs,
Name: filepath.Base(abs), Name: filepath.Base(abs),
Session: sess, Session: sess,
Pads: pads, Pads: pads,
Trust: trustStore, Trust: trustStore,
Persist: persistStore, Persist: persistStore,
Launcher: launcher, Launcher: launcher,
Host: host, Host: host,
savedProcess: savedProcesses, savedProcess: savedProcesses,
lastActive: time.Now(), displayOwners: make(map[string]paneDisplayOwner),
lastActive: time.Now(),
} }
r.mu.Lock() r.mu.Lock()
@@ -156,6 +166,73 @@ func (r *ProjectRegistry) DefaultProject() *Project {
return r.projects[r.defaultProjectKey] 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() { func (r *ProjectRegistry) Shutdown() {
r.mu.Lock() r.mu.Lock()
projects := make([]*Project, 0, len(r.projects)) projects := make([]*Project, 0, len(r.projects))

View File

@@ -294,14 +294,9 @@ func handleDaemonAttach(ctx context.Context, registry *ProjectRegistry, t protoc
_ = sendProtocolError(t, "no project open") _ = sendProtocolError(t, "no project open")
return return
} }
if attach.TermSize.Cols > 0 && attach.TermSize.Rows > 0 { clientID := fmt.Sprintf("c-%d", time.Now().UnixNano())
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)
}
view := ClientView{ view := ClientView{
ID: fmt.Sprintf("c-%d", time.Now().UnixNano()), ID: clientID,
ProjectKey: project.Key, ProjectKey: project.Key,
ProjectName: project.Name, ProjectName: project.Name,
Cols: attach.TermSize.Cols, 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 { if child := firstRunningTopLevel(project.Session.Children()); child != nil {
view.FocusChild(child.ID) 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) project.Session.SubscribeClient(sub)
defer project.Session.UnsubscribeClient(sub) defer project.Session.UnsubscribeClient(sub)
defer project.ReleaseClientDisplays(clientID)
_ = sendHello(t, project, view.ID) _ = sendHello(t, project, view.ID)
_ = sendProjectList(t, registry, project.Key) _ = sendProjectList(t, registry, project.Key)
_ = sendChrome(t, project, view) _ = sendChrome(t, project, view)
if view.FocusedID != "" { 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 // 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: case protocol.FrameResize:
msg, err := protocol.Decode[protocol.Resize](f) msg, err := protocol.Decode[protocol.Resize](f)
if err == nil { if err == nil {
project.Session.ResizeAll(msg.Size.Cols, msg.Size.Rows) view.Resize(msg.Size.Cols, msg.Size.Rows)
project.Launcher.SetSize(msg.Size.Cols, msg.Size.Rows) if view.FocusedID != "" {
project.Host.SetSize(msg.Size.Cols, msg.Size.Rows) if _, _, ok := project.PaneDisplay(view.FocusedID); !ok {
project.ClaimPaneDisplay(clientID, view.FocusedID, msg.Size)
}
}
project.ResizeClientDisplays(clientID, msg.Size)
} }
case protocol.FrameFocus: case protocol.FrameFocus:
msg, err := protocol.Decode[protocol.Focus](f) msg, err := protocol.Decode[protocol.Focus](f)
if err == nil && msg.PaneID != "" { if err == nil && msg.PaneID != "" {
view.FocusChild(msg.PaneID) view.FocusChild(msg.PaneID)
project.ClaimPaneDisplay(clientID, msg.PaneID, protocol.Size{Cols: view.Cols, Rows: view.Rows})
_ = sendChrome(t, project, view) _ = sendChrome(t, project, view)
_ = sendSnapshot(t, project, msg.PaneID) _ = sendSnapshot(t, project, clientID, msg.PaneID)
} }
case protocol.FramePaletteCommand: case protocol.FramePaletteCommand:
if child := handleDaemonPaletteCommand(project, f); child != nil { if child := handleDaemonPaletteCommand(project, f); child != nil {
view.FocusChild(child.ID) view.FocusChild(child.ID)
project.ClaimPaneDisplay(clientID, child.ID, protocol.Size{Cols: view.Cols, Rows: view.Rows})
_ = sendChrome(t, project, view) _ = sendChrome(t, project, view)
_ = sendSnapshot(t, project, child.ID) _ = sendSnapshot(t, project, clientID, child.ID)
} }
} }
select { select {
@@ -451,12 +454,18 @@ func sendChrome(t protocol.Transport, p *Project, view ClientView) error {
return t.Send(f) 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) b, err := p.Session.SerializeChild(paneID)
if err != nil { if err != nil {
return 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 { if err != nil {
return err return err
} }

View File

@@ -11,6 +11,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/protocol" "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) { func waitForSocket(t *testing.T, socket string, errCh <-chan error) {
t.Helper() t.Helper()
deadline := time.Now().Add(3 * time.Second) 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) 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) { func recvFrameWithin(tr protocol.Transport, timeout time.Duration) (protocol.Frame, error, bool) {
type result struct { type result struct {
f protocol.Frame f protocol.Frame

View File

@@ -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 // SerializeChild returns the VT bytes that reproduce the child's
// current screen state. Used to repaint a child after the user switches // current screen state. Used to repaint a child after the user switches
// focus or closes the palette. // focus or closes the palette.

View File

@@ -108,13 +108,17 @@ type Chrome struct {
} }
type PaneSnapshot struct { type PaneSnapshot struct {
PaneID string `json:"pane_id"` PaneID string `json:"pane_id"`
Bytes []byte `json:"bytes"` Bytes []byte `json:"bytes"`
Size Size `json:"size,omitempty"`
DisplayOwner bool `json:"display_owner,omitempty"`
} }
type PaneChunk struct { type PaneChunk struct {
PaneID string `json:"pane_id"` PaneID string `json:"pane_id"`
Bytes []byte `json:"bytes"` Bytes []byte `json:"bytes"`
Size Size `json:"size,omitempty"`
DisplayOwner bool `json:"display_owner,omitempty"`
} }
type LifecycleKind string type LifecycleKind string