add per-pane display ownership
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user