Persistent daemon + thin networked client #9
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user