feat: improve local terminal workflows
This commit is contained in:
@@ -3,11 +3,18 @@
|
||||
package localshell
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/andydixon/nexus/internal/terminalenv"
|
||||
"github.com/creack/pty"
|
||||
@@ -15,26 +22,28 @@ import (
|
||||
|
||||
// Client manages a local shell PTY session.
|
||||
type Client struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
ptmx *os.File
|
||||
shell string
|
||||
onData func([]byte)
|
||||
onError func(error)
|
||||
onClose func()
|
||||
closed bool
|
||||
cols uint16
|
||||
rows uint16
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
ptmx *os.File
|
||||
shell string
|
||||
startDir string
|
||||
onData func([]byte)
|
||||
onError func(error)
|
||||
onClose func()
|
||||
closed bool
|
||||
cols uint16
|
||||
rows uint16
|
||||
}
|
||||
|
||||
// Config holds configuration for a local shell session.
|
||||
type Config struct {
|
||||
Shell string // Path to the shell binary (e.g. /bin/zsh)
|
||||
Cols uint16
|
||||
Rows uint16
|
||||
OnData func([]byte)
|
||||
OnError func(error)
|
||||
OnClose func()
|
||||
Shell string // Path to the shell binary (e.g. /bin/zsh)
|
||||
Cols uint16
|
||||
Rows uint16
|
||||
StartDir string // Initial working directory; defaults to the user's home directory.
|
||||
OnData func([]byte)
|
||||
OnError func(error)
|
||||
OnClose func()
|
||||
}
|
||||
|
||||
// DefaultShell returns the system default shell.
|
||||
@@ -67,13 +76,20 @@ func NewClient(cfg Config) *Client {
|
||||
if rows == 0 {
|
||||
rows = 24
|
||||
}
|
||||
startDir := cfg.StartDir
|
||||
if startDir == "" {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
startDir = home
|
||||
}
|
||||
}
|
||||
return &Client{
|
||||
shell: shell,
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
onData: cfg.OnData,
|
||||
onError: cfg.OnError,
|
||||
onClose: cfg.OnClose,
|
||||
shell: shell,
|
||||
startDir: startDir,
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
onData: cfg.OnData,
|
||||
onError: cfg.OnError,
|
||||
onClose: cfg.OnClose,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,17 +98,16 @@ func (c *Client) Start() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.closed {
|
||||
return fmt.Errorf("shell already closed")
|
||||
}
|
||||
if c.ptmx != nil {
|
||||
return fmt.Errorf("shell already running")
|
||||
}
|
||||
|
||||
cmd := exec.Command(c.shell)
|
||||
cmd.Env = terminalenv.LocalShellEnv(os.Environ())
|
||||
|
||||
// Start in the user's home directory.
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
cmd.Dir = home
|
||||
}
|
||||
cmd.Dir = c.startDir
|
||||
|
||||
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{
|
||||
Cols: c.cols,
|
||||
@@ -106,7 +121,7 @@ func (c *Client) Start() error {
|
||||
c.ptmx = ptmx
|
||||
|
||||
// Read loop — forward PTY output to the frontend.
|
||||
go c.readLoop()
|
||||
go c.readLoop(ptmx)
|
||||
|
||||
// Wait for process exit.
|
||||
go func() {
|
||||
@@ -114,6 +129,9 @@ func (c *Client) Start() error {
|
||||
c.mu.Lock()
|
||||
wasClosed := c.closed
|
||||
c.closed = true
|
||||
if c.ptmx == ptmx {
|
||||
c.ptmx = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if !wasClosed && c.onClose != nil {
|
||||
@@ -126,6 +144,13 @@ func (c *Client) Start() error {
|
||||
|
||||
// Write sends data to the shell's stdin (PTY).
|
||||
func (c *Client) Write(data []byte) error {
|
||||
// Capture the shell's current directory before it handles a command or
|
||||
// EOF. This lets a replacement shell resume in the same directory after
|
||||
// an ordinary `exit`, without injecting shell-specific prompt hooks.
|
||||
if bytesContainCommandBoundary(data) {
|
||||
c.captureWorkingDirectory()
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
ptmx := c.ptmx
|
||||
c.mu.Unlock()
|
||||
@@ -140,7 +165,15 @@ func (c *Client) Write(data []byte) error {
|
||||
// Resize updates the PTY window size.
|
||||
func (c *Client) Resize(cols, rows uint16) error {
|
||||
c.mu.Lock()
|
||||
if cols > 0 {
|
||||
c.cols = cols
|
||||
}
|
||||
if rows > 0 {
|
||||
c.rows = rows
|
||||
}
|
||||
ptmx := c.ptmx
|
||||
cols = c.cols
|
||||
rows = c.rows
|
||||
c.mu.Unlock()
|
||||
|
||||
if ptmx == nil {
|
||||
@@ -152,26 +185,36 @@ func (c *Client) Resize(cols, rows uint16) error {
|
||||
// Close terminates the shell process and PTY.
|
||||
func (c *Client) Close() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.closed = true
|
||||
ptmx := c.ptmx
|
||||
cmd := c.cmd
|
||||
c.ptmx = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
if c.ptmx != nil {
|
||||
c.ptmx.Close()
|
||||
c.ptmx = nil
|
||||
if ptmx != nil {
|
||||
_ = ptmx.Close()
|
||||
}
|
||||
if c.cmd != nil && c.cmd.Process != nil {
|
||||
c.cmd.Process.Kill()
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) readLoop() {
|
||||
// WorkingDirectory returns the last directory observed for the shell.
|
||||
func (c *Client) WorkingDirectory() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.startDir
|
||||
}
|
||||
|
||||
func (c *Client) readLoop(ptmx *os.File) {
|
||||
defer ptmx.Close()
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := c.ptmx.Read(buf)
|
||||
n, err := ptmx.Read(buf)
|
||||
if n > 0 && c.onData != nil {
|
||||
c.onData(buf[:n])
|
||||
}
|
||||
@@ -179,10 +222,73 @@ func (c *Client) readLoop() {
|
||||
c.mu.Lock()
|
||||
wasClosed := c.closed
|
||||
c.mu.Unlock()
|
||||
if !wasClosed && c.onError != nil {
|
||||
if !wasClosed && !isNormalPTYClosure(err) && c.onError != nil {
|
||||
c.onError(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func bytesContainCommandBoundary(data []byte) bool {
|
||||
for _, b := range data {
|
||||
if b == '\r' || b == '\n' || b == 0x04 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isNormalPTYClosure(err error) bool {
|
||||
return errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) || errors.Is(err, syscall.EIO)
|
||||
}
|
||||
|
||||
func (c *Client) captureWorkingDirectory() {
|
||||
c.mu.Lock()
|
||||
cmd := c.cmd
|
||||
c.mu.Unlock()
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
|
||||
dir := processWorkingDirectory(cmd.Process.Pid)
|
||||
if dir == "" || !isDirectory(dir) {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.cmd == cmd {
|
||||
c.startDir = dir
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func processWorkingDirectory(pid int) string {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
dir, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/cwd")
|
||||
if err == nil {
|
||||
return dir
|
||||
}
|
||||
case "darwin":
|
||||
// macOS does not expose /proc. lsof is part of the base system and its
|
||||
// field output gives us the cwd without parsing a human-readable table.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, "/usr/sbin/lsof", "-a", "-p", strconv.Itoa(pid), "-d", "cwd", "-Fn").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if strings.HasPrefix(line, "n") {
|
||||
return strings.TrimPrefix(line, "n")
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isDirectory(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user