Merge pull request #2 from bnema/bugfix/post-picker-batch
fix(bugs): apply post-picker bugfix batch
18 files · +514 -213
M internal/adapters/pty/pty_linux.go +3 -1
| @@ -18,6 +18,7 @@ import ( | |||
| 18 | 18 | "time" | |
| 19 | 19 | ||
| 20 | 20 | "github.com/bnema/vev/internal/domain" | |
| 21 | + | "github.com/bnema/vev/internal/platform" | |
| 21 | 22 | "github.com/bnema/vev/internal/ports" | |
| 22 | 23 | "golang.org/x/sys/unix" | |
| 23 | 24 | ) | |
| @@ -37,7 +38,7 @@ func NewFactory() *Factory { return &Factory{} } | |||
| 37 | 38 | // verbatim (nil means inherit the current process environment); the caller | |
| 38 | 39 | // decides TERM and friends. sz sets the terminal window size before the child | |
| 39 | 40 | // starts, so the child observes the correct dimensions on its first query. | |
| 40 | - | func (Factory) Open(command string, args []string, env []string, sz domain.Size) (ports.PTY, error) { | |
| 41 | + | func (Factory) Open(command string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) { | |
| 41 | 42 | masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0) | |
| 42 | 43 | if err != nil { | |
| 43 | 44 | return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err) | |
| @@ -80,6 +81,7 @@ func (Factory) Open(command string, args []string, env []string, sz domain.Size) | |||
| 80 | 81 | ||
| 81 | 82 | cmd := exec.Command(command, args...) | |
| 82 | 83 | cmd.Env = env | |
| 84 | + | cmd.Dir = platform.DirOrHome(dir) | |
| 83 | 85 | cmd.Stdin = slave | |
| 84 | 86 | cmd.Stdout = slave | |
| 85 | 87 | cmd.Stderr = slave | |
| @@ -18,6 +18,7 @@ import ( | |||||
| 18 | "time" | 18 | "time" | ||
| 19 | 19 | ||||
| 20 | "github.com/bnema/vev/internal/domain" | 20 | "github.com/bnema/vev/internal/domain" | ||
| 21 | + | "github.com/bnema/vev/internal/platform" | |||
| 21 | "github.com/bnema/vev/internal/ports" | 22 | "github.com/bnema/vev/internal/ports" | ||
| 22 | "golang.org/x/sys/unix" | 23 | "golang.org/x/sys/unix" | ||
| 23 | ) | 24 | ) | ||
| @@ -37,7 +38,7 @@ func NewFactory() *Factory { return &Factory{} } | |||||
| 37 | // verbatim (nil means inherit the current process environment); the caller | 38 | // verbatim (nil means inherit the current process environment); the caller | ||
| 38 | // decides TERM and friends. sz sets the terminal window size before the child | 39 | // decides TERM and friends. sz sets the terminal window size before the child | ||
| 39 | // starts, so the child observes the correct dimensions on its first query. | 40 | // starts, so the child observes the correct dimensions on its first query. | ||
| 40 | - | func (Factory) Open(command string, args []string, env []string, sz domain.Size) (ports.PTY, error) { | 41 | + | func (Factory) Open(command string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) { |
| 41 | masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0) | 42 | masterFd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0) | ||
| 42 | if err != nil { | 43 | if err != nil { | ||
| 43 | return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err) | 44 | return nil, fmt.Errorf("pty: open /dev/ptmx: %w", err) | ||
| @@ -80,6 +81,7 @@ func (Factory) Open(command string, args []string, env []string, sz domain.Size) | |||||
| 80 | 81 | ||||
| 81 | cmd := exec.Command(command, args...) | 82 | cmd := exec.Command(command, args...) | ||
| 82 | cmd.Env = env | 83 | cmd.Env = env | ||
| 84 | + | cmd.Dir = platform.DirOrHome(dir) | |||
| 83 | cmd.Stdin = slave | 85 | cmd.Stdin = slave | ||
| 84 | cmd.Stdout = slave | 86 | cmd.Stdout = slave | ||
| 85 | cmd.Stderr = slave | 87 | cmd.Stderr = slave | ||
M internal/adapters/pty/pty_linux_test.go +6 -6
| @@ -53,7 +53,7 @@ func TestOpen_ChildOutputToEOF(t *testing.T) { | |||
| 53 | 53 | t.Skip("skipping pty integration test in -short mode") | |
| 54 | 54 | } | |
| 55 | 55 | f := newFactory() | |
| 56 | - | p, err := f.Open("sh", []string{"-c", "printf hello"}, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | |
| 56 | + | p, err := f.Open("sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) | |
| 57 | 57 | require.NoError(t, err) | |
| 58 | 58 | t.Cleanup(func() { _ = p.Close() }) | |
| 59 | 59 | ||
| @@ -70,7 +70,7 @@ func TestOpen_EchoRoundtrip(t *testing.T) { | |||
| 70 | 70 | t.Skip("skipping pty integration test in -short mode") | |
| 71 | 71 | } | |
| 72 | 72 | f := newFactory() | |
| 73 | - | p, err := f.Open("cat", nil, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | |
| 73 | + | p, err := f.Open("cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) | |
| 74 | 74 | require.NoError(t, err) | |
| 75 | 75 | t.Cleanup(func() { _ = p.Close() }) | |
| 76 | 76 | ||
| @@ -127,7 +127,7 @@ func TestResize_SttySize(t *testing.T) { | |||
| 127 | 127 | for _, tt := range tests { | |
| 128 | 128 | t.Run(tt.name, func(t *testing.T) { | |
| 129 | 129 | f := newFactory() | |
| 130 | - | p, err := f.Open("sh", []string{"-c", tt.script}, os.Environ(), tt.initial) | |
| 130 | + | p, err := f.Open("sh", []string{"-c", tt.script}, os.Environ(), "", tt.initial) | |
| 131 | 131 | require.NoError(t, err) | |
| 132 | 132 | t.Cleanup(func() { _ = p.Close() }) | |
| 133 | 133 | ||
| @@ -146,7 +146,7 @@ func TestClose_ReapsChildNoZombie(t *testing.T) { | |||
| 146 | 146 | t.Skip("skipping pty integration test in -short mode") | |
| 147 | 147 | } | |
| 148 | 148 | f := newFactory() | |
| 149 | - | p, err := f.Open("sh", []string{"-c", "sleep 30"}, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | |
| 149 | + | p, err := f.Open("sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) | |
| 150 | 150 | require.NoError(t, err) | |
| 151 | 151 | ||
| 152 | 152 | pid := p.Pid() | |
| @@ -177,7 +177,7 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) { | |||
| 177 | 177 | t.Skip("skipping pty integration test in -short mode") | |
| 178 | 178 | } | |
| 179 | 179 | f := newFactory() | |
| 180 | - | p, err := f.Open("sh", []string{"-c", "sleep 60"}, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | |
| 180 | + | p, err := f.Open("sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) | |
| 181 | 181 | require.NoError(t, err) | |
| 182 | 182 | pid := p.Pid() | |
| 183 | 183 | ||
| @@ -198,7 +198,7 @@ func TestResize_DeliversSIGWINCH(t *testing.T) { | |||
| 198 | 198 | // deliver SIGWINCH to the foreground process group. | |
| 199 | 199 | script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done` | |
| 200 | 200 | f := newFactory() | |
| 201 | - | p, err := f.Open("sh", []string{"-c", script}, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | |
| 201 | + | p, err := f.Open("sh", []string{"-c", script}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) | |
| 202 | 202 | require.NoError(t, err) | |
| 203 | 203 | t.Cleanup(func() { _ = p.Close() }) | |
| 204 | 204 | ||
| @@ -53,7 +53,7 @@ func TestOpen_ChildOutputToEOF(t *testing.T) { | |||||
| 53 | t.Skip("skipping pty integration test in -short mode") | 53 | t.Skip("skipping pty integration test in -short mode") | ||
| 54 | } | 54 | } | ||
| 55 | f := newFactory() | 55 | f := newFactory() | ||
| 56 | - | p, err := f.Open("sh", []string{"-c", "printf hello"}, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | 56 | + | p, err := f.Open("sh", []string{"-c", "printf hello"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) |
| 57 | require.NoError(t, err) | 57 | require.NoError(t, err) | ||
| 58 | t.Cleanup(func() { _ = p.Close() }) | 58 | t.Cleanup(func() { _ = p.Close() }) | ||
| 59 | 59 | ||||
| @@ -70,7 +70,7 @@ func TestOpen_EchoRoundtrip(t *testing.T) { | |||||
| 70 | t.Skip("skipping pty integration test in -short mode") | 70 | t.Skip("skipping pty integration test in -short mode") | ||
| 71 | } | 71 | } | ||
| 72 | f := newFactory() | 72 | f := newFactory() | ||
| 73 | - | p, err := f.Open("cat", nil, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | 73 | + | p, err := f.Open("cat", nil, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) |
| 74 | require.NoError(t, err) | 74 | require.NoError(t, err) | ||
| 75 | t.Cleanup(func() { _ = p.Close() }) | 75 | t.Cleanup(func() { _ = p.Close() }) | ||
| 76 | 76 | ||||
| @@ -127,7 +127,7 @@ func TestResize_SttySize(t *testing.T) { | |||||
| 127 | for _, tt := range tests { | 127 | for _, tt := range tests { | ||
| 128 | t.Run(tt.name, func(t *testing.T) { | 128 | t.Run(tt.name, func(t *testing.T) { | ||
| 129 | f := newFactory() | 129 | f := newFactory() | ||
| 130 | - | p, err := f.Open("sh", []string{"-c", tt.script}, os.Environ(), tt.initial) | 130 | + | p, err := f.Open("sh", []string{"-c", tt.script}, os.Environ(), "", tt.initial) |
| 131 | require.NoError(t, err) | 131 | require.NoError(t, err) | ||
| 132 | t.Cleanup(func() { _ = p.Close() }) | 132 | t.Cleanup(func() { _ = p.Close() }) | ||
| 133 | 133 | ||||
| @@ -146,7 +146,7 @@ func TestClose_ReapsChildNoZombie(t *testing.T) { | |||||
| 146 | t.Skip("skipping pty integration test in -short mode") | 146 | t.Skip("skipping pty integration test in -short mode") | ||
| 147 | } | 147 | } | ||
| 148 | f := newFactory() | 148 | f := newFactory() | ||
| 149 | - | p, err := f.Open("sh", []string{"-c", "sleep 30"}, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | 149 | + | p, err := f.Open("sh", []string{"-c", "sleep 30"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) |
| 150 | require.NoError(t, err) | 150 | require.NoError(t, err) | ||
| 151 | 151 | ||||
| 152 | pid := p.Pid() | 152 | pid := p.Pid() | ||
| @@ -177,7 +177,7 @@ func TestClose_TerminatesLongRunningChild(t *testing.T) { | |||||
| 177 | t.Skip("skipping pty integration test in -short mode") | 177 | t.Skip("skipping pty integration test in -short mode") | ||
| 178 | } | 178 | } | ||
| 179 | f := newFactory() | 179 | f := newFactory() | ||
| 180 | - | p, err := f.Open("sh", []string{"-c", "sleep 60"}, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | 180 | + | p, err := f.Open("sh", []string{"-c", "sleep 60"}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) |
| 181 | require.NoError(t, err) | 181 | require.NoError(t, err) | ||
| 182 | pid := p.Pid() | 182 | pid := p.Pid() | ||
| 183 | 183 | ||||
| @@ -198,7 +198,7 @@ func TestResize_DeliversSIGWINCH(t *testing.T) { | |||||
| 198 | // deliver SIGWINCH to the foreground process group. | 198 | // deliver SIGWINCH to the foreground process group. | ||
| 199 | script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done` | 199 | script := `trap 'echo GOTWINCH; exit 0' WINCH; echo READY; i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done` | ||
| 200 | f := newFactory() | 200 | f := newFactory() | ||
| 201 | - | p, err := f.Open("sh", []string{"-c", script}, os.Environ(), domain.Size{Cols: 80, Rows: 24}) | 201 | + | p, err := f.Open("sh", []string{"-c", script}, os.Environ(), "", domain.Size{Cols: 80, Rows: 24}) |
| 202 | require.NoError(t, err) | 202 | require.NoError(t, err) | ||
| 203 | t.Cleanup(func() { _ = p.Close() }) | 203 | t.Cleanup(func() { _ = p.Close() }) | ||
| 204 | 204 | ||||
M internal/app/run.go +4 -0
| @@ -216,6 +216,10 @@ func runDaemon() error { | |||
| 216 | 216 | // attach loop. Logging goes to the shared file: the client must never write | |
| 217 | 217 | // to the console while the terminal is raw. | |
| 218 | 218 | func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error { | |
| 219 | + | if os.Getenv("VEV") != "" { | |
| 220 | + | return errors.New("vev: sessions should be nested with care; unset VEV to force") | |
| 221 | + | } | |
| 222 | + | ||
| 219 | 223 | logFile, err := setupLogging() | |
| 220 | 224 | if err != nil { | |
| 221 | 225 | return err | |
| @@ -216,6 +216,10 @@ func runDaemon() error { | |||||
| 216 | // attach loop. Logging goes to the shared file: the client must never write | 216 | // attach loop. Logging goes to the shared file: the client must never write | ||
| 217 | // to the console while the terminal is raw. | 217 | // to the console while the terminal is raw. | ||
| 218 | func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error { | 218 | func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) error { | ||
| 219 | + | if os.Getenv("VEV") != "" { | |||
| 220 | + | return errors.New("vev: sessions should be nested with care; unset VEV to force") | |||
| 221 | + | } | |||
| 222 | + | ||||
| 219 | logFile, err := setupLogging() | 223 | logFile, err := setupLogging() | ||
| 220 | if err != nil { | 224 | if err != nil { | ||
| 221 | return err | 225 | return err | ||
M internal/app/run_test.go +13 -0
| @@ -1,7 +1,9 @@ | |||
| 1 | 1 | package app | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "errors" | |
| 6 | + | "strings" | |
| 5 | 7 | "testing" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/vev/internal/ports" | |
| @@ -76,3 +78,14 @@ func TestParseArgs(t *testing.T) { | |||
| 76 | 78 | }) | |
| 77 | 79 | } | |
| 78 | 80 | } | |
| 81 | + | ||
| 82 | + | func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) { | |
| 83 | + | t.Setenv("VEV", "outer") | |
| 84 | + | err := runAttach(context.Background(), ports.IntentEphemeral, "", "") | |
| 85 | + | if err == nil { | |
| 86 | + | t.Fatal("runAttach with VEV set returned nil error") | |
| 87 | + | } | |
| 88 | + | if !strings.Contains(err.Error(), "sessions should be nested with care") { | |
| 89 | + | t.Fatalf("runAttach error = %q, want nested-session warning", err) | |
| 90 | + | } | |
| 91 | + | } | |
| @@ -1,7 +1,9 @@ | |||||
| 1 | package app | 1 | package app | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "errors" | 5 | "errors" | ||
| 6 | + | "strings" | |||
| 5 | "testing" | 7 | "testing" | ||
| 6 | 8 | ||||
| 7 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| @@ -76,3 +78,14 @@ func TestParseArgs(t *testing.T) { | |||||
| 76 | }) | 78 | }) | ||
| 77 | } | 79 | } | ||
| 78 | } | 80 | } | ||
| 81 | + | ||||
| 82 | + | func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) { | |||
| 83 | + | t.Setenv("VEV", "outer") | |||
| 84 | + | err := runAttach(context.Background(), ports.IntentEphemeral, "", "") | |||
| 85 | + | if err == nil { | |||
| 86 | + | t.Fatal("runAttach with VEV set returned nil error") | |||
| 87 | + | } | |||
| 88 | + | if !strings.Contains(err.Error(), "sessions should be nested with care") { | |||
| 89 | + | t.Fatalf("runAttach error = %q, want nested-session warning", err) | |||
| 90 | + | } | |||
| 91 | + | } | |||
M internal/app/spawn.go +2 -0
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "time" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 14 | + | "github.com/bnema/vev/internal/platform" | |
| 14 | 15 | "github.com/bnema/vev/internal/ports" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| @@ -144,6 +145,7 @@ func realSpawn() error { | |||
| 144 | 145 | defer func() { _ = devNull.Close() }() | |
| 145 | 146 | ||
| 146 | 147 | cmd := exec.Command("/proc/self/exe", "--daemon") | |
| 148 | + | cmd.Dir = platform.DirOrHome("") | |
| 147 | 149 | cmd.Stdin = devNull | |
| 148 | 150 | cmd.Stdout = devNull | |
| 149 | 151 | cmd.Stderr = devNull | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "time" | 11 | "time" | ||
| 12 | 12 | ||||
| 13 | "github.com/bnema/vev/internal/adapters/ipc" | 13 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| 14 | + | "github.com/bnema/vev/internal/platform" | |||
| 14 | "github.com/bnema/vev/internal/ports" | 15 | "github.com/bnema/vev/internal/ports" | ||
| 15 | ) | 16 | ) | ||
| 16 | 17 | ||||
| @@ -144,6 +145,7 @@ func realSpawn() error { | |||||
| 144 | defer func() { _ = devNull.Close() }() | 145 | defer func() { _ = devNull.Close() }() | ||
| 145 | 146 | ||||
| 146 | cmd := exec.Command("/proc/self/exe", "--daemon") | 147 | cmd := exec.Command("/proc/self/exe", "--daemon") | ||
| 148 | + | cmd.Dir = platform.DirOrHome("") | |||
| 147 | cmd.Stdin = devNull | 149 | cmd.Stdin = devNull | ||
| 148 | cmd.Stdout = devNull | 150 | cmd.Stdout = devNull | ||
| 149 | cmd.Stderr = devNull | 151 | cmd.Stderr = devNull | ||
A internal/platform/dir.go +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | package platform | |
| 2 | + | ||
| 3 | + | import "os" | |
| 4 | + | ||
| 5 | + | // DirOrHome returns dir when it names an existing directory. Otherwise it | |
| 6 | + | // falls back to the current user's home directory, then to / as a last resort. | |
| 7 | + | func DirOrHome(dir string) string { | |
| 8 | + | if dir != "" { | |
| 9 | + | if st, err := os.Stat(dir); err == nil && st.IsDir() { | |
| 10 | + | return dir | |
| 11 | + | } | |
| 12 | + | } | |
| 13 | + | if home, err := os.UserHomeDir(); err == nil && home != "" { | |
| 14 | + | return home | |
| 15 | + | } | |
| 16 | + | return "/" | |
| 17 | + | } | |
| @@ -0,0 +1,17 @@ | |||||
| 1 | + | package platform | |||
| 2 | + | ||||
| 3 | + | import "os" | |||
| 4 | + | ||||
| 5 | + | // DirOrHome returns dir when it names an existing directory. Otherwise it | |||
| 6 | + | // falls back to the current user's home directory, then to / as a last resort. | |||
| 7 | + | func DirOrHome(dir string) string { | |||
| 8 | + | if dir != "" { | |||
| 9 | + | if st, err := os.Stat(dir); err == nil && st.IsDir() { | |||
| 10 | + | return dir | |||
| 11 | + | } | |||
| 12 | + | } | |||
| 13 | + | if home, err := os.UserHomeDir(); err == nil && home != "" { | |||
| 14 | + | return home | |||
| 15 | + | } | |||
| 16 | + | return "/" | |||
| 17 | + | } | |||
M internal/ports/frame.go +1 -1
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 4 | - | const ProtocolVersion uint16 = 1 | |
| 4 | + | const ProtocolVersion uint16 = 2 | |
| 5 | 5 | ||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package ports | 1 | package ports | ||
| 2 | 2 | ||||
| 3 | // ProtocolVersion is the current vev IPC wire protocol version. | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | ||
| 4 | - | const ProtocolVersion uint16 = 1 | 4 | + | const ProtocolVersion uint16 = 2 |
| 5 | 5 | ||||
| 6 | // MsgType identifies the kind of payload carried by a Frame. | 6 | // MsgType identifies the kind of payload carried by a Frame. | ||
| 7 | type MsgType uint8 | 7 | type MsgType uint8 | ||
M internal/ports/mocks/mocks.go +20 -14
| @@ -327,8 +327,8 @@ func (_m *MockPTYFactory) EXPECT() *MockPTYFactory_Expecter { | |||
| 327 | 327 | } | |
| 328 | 328 | ||
| 329 | 329 | // Open provides a mock function for the type MockPTYFactory | |
| 330 | - | func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, sz domain.Size) (ports.PTY, error) { | |
| 331 | - | ret := _mock.Called(cmd, args, env, sz) | |
| 330 | + | func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) { | |
| 331 | + | ret := _mock.Called(cmd, args, env, dir, sz) | |
| 332 | 332 | ||
| 333 | 333 | if len(ret) == 0 { | |
| 334 | 334 | panic("no return value specified for Open") | |
| @@ -336,18 +336,18 @@ func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, sz do | |||
| 336 | 336 | ||
| 337 | 337 | var r0 ports.PTY | |
| 338 | 338 | var r1 error | |
| 339 | - | if returnFunc, ok := ret.Get(0).(func(string, []string, []string, domain.Size) (ports.PTY, error)); ok { | |
| 340 | - | return returnFunc(cmd, args, env, sz) | |
| 339 | + | if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) (ports.PTY, error)); ok { | |
| 340 | + | return returnFunc(cmd, args, env, dir, sz) | |
| 341 | 341 | } | |
| 342 | - | if returnFunc, ok := ret.Get(0).(func(string, []string, []string, domain.Size) ports.PTY); ok { | |
| 343 | - | r0 = returnFunc(cmd, args, env, sz) | |
| 342 | + | if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) ports.PTY); ok { | |
| 343 | + | r0 = returnFunc(cmd, args, env, dir, sz) | |
| 344 | 344 | } else { | |
| 345 | 345 | if ret.Get(0) != nil { | |
| 346 | 346 | r0 = ret.Get(0).(ports.PTY) | |
| 347 | 347 | } | |
| 348 | 348 | } | |
| 349 | - | if returnFunc, ok := ret.Get(1).(func(string, []string, []string, domain.Size) error); ok { | |
| 350 | - | r1 = returnFunc(cmd, args, env, sz) | |
| 349 | + | if returnFunc, ok := ret.Get(1).(func(string, []string, []string, string, domain.Size) error); ok { | |
| 350 | + | r1 = returnFunc(cmd, args, env, dir, sz) | |
| 351 | 351 | } else { | |
| 352 | 352 | r1 = ret.Error(1) | |
| 353 | 353 | } | |
| @@ -363,12 +363,13 @@ type MockPTYFactory_Open_Call struct { | |||
| 363 | 363 | // - cmd string | |
| 364 | 364 | // - args []string | |
| 365 | 365 | // - env []string | |
| 366 | + | // - dir string | |
| 366 | 367 | // - sz domain.Size | |
| 367 | - | func (_e *MockPTYFactory_Expecter) Open(cmd any, args any, env any, sz any) *MockPTYFactory_Open_Call { | |
| 368 | - | return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", cmd, args, env, sz)} | |
| 368 | + | func (_e *MockPTYFactory_Expecter) Open(cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call { | |
| 369 | + | return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", cmd, args, env, dir, sz)} | |
| 369 | 370 | } | |
| 370 | 371 | ||
| 371 | - | func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env []string, sz domain.Size)) *MockPTYFactory_Open_Call { | |
| 372 | + | func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env []string, dir string, sz domain.Size)) *MockPTYFactory_Open_Call { | |
| 372 | 373 | _c.Call.Run(func(args mock.Arguments) { | |
| 373 | 374 | var arg0 string | |
| 374 | 375 | if args[0] != nil { | |
| @@ -382,15 +383,20 @@ func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env | |||
| 382 | 383 | if args[2] != nil { | |
| 383 | 384 | arg2 = args[2].([]string) | |
| 384 | 385 | } | |
| 385 | - | var arg3 domain.Size | |
| 386 | + | var arg3 string | |
| 386 | 387 | if args[3] != nil { | |
| 387 | - | arg3 = args[3].(domain.Size) | |
| 388 | + | arg3 = args[3].(string) | |
| 389 | + | } | |
| 390 | + | var arg4 domain.Size | |
| 391 | + | if args[4] != nil { | |
| 392 | + | arg4 = args[4].(domain.Size) | |
| 388 | 393 | } | |
| 389 | 394 | run( | |
| 390 | 395 | arg0, | |
| 391 | 396 | arg1, | |
| 392 | 397 | arg2, | |
| 393 | 398 | arg3, | |
| 399 | + | arg4, | |
| 394 | 400 | ) | |
| 395 | 401 | }) | |
| 396 | 402 | return _c | |
| @@ -401,7 +407,7 @@ func (_c *MockPTYFactory_Open_Call) Return(pTY ports.PTY, err error) *MockPTYFac | |||
| 401 | 407 | return _c | |
| 402 | 408 | } | |
| 403 | 409 | ||
| 404 | - | func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(cmd string, args []string, env []string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call { | |
| 410 | + | func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call { | |
| 405 | 411 | _c.Call.Return(run) | |
| 406 | 412 | return _c | |
| 407 | 413 | } | |
| @@ -327,8 +327,8 @@ func (_m *MockPTYFactory) EXPECT() *MockPTYFactory_Expecter { | |||||
| 327 | } | 327 | } | ||
| 328 | 328 | ||||
| 329 | // Open provides a mock function for the type MockPTYFactory | 329 | // Open provides a mock function for the type MockPTYFactory | ||
| 330 | - | func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, sz domain.Size) (ports.PTY, error) { | 330 | + | func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) { |
| 331 | - | ret := _mock.Called(cmd, args, env, sz) | 331 | + | ret := _mock.Called(cmd, args, env, dir, sz) |
| 332 | 332 | ||||
| 333 | if len(ret) == 0 { | 333 | if len(ret) == 0 { | ||
| 334 | panic("no return value specified for Open") | 334 | panic("no return value specified for Open") | ||
| @@ -336,18 +336,18 @@ func (_mock *MockPTYFactory) Open(cmd string, args []string, env []string, sz do | |||||
| 336 | 336 | ||||
| 337 | var r0 ports.PTY | 337 | var r0 ports.PTY | ||
| 338 | var r1 error | 338 | var r1 error | ||
| 339 | - | if returnFunc, ok := ret.Get(0).(func(string, []string, []string, domain.Size) (ports.PTY, error)); ok { | 339 | + | if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) (ports.PTY, error)); ok { |
| 340 | - | return returnFunc(cmd, args, env, sz) | 340 | + | return returnFunc(cmd, args, env, dir, sz) |
| 341 | } | 341 | } | ||
| 342 | - | if returnFunc, ok := ret.Get(0).(func(string, []string, []string, domain.Size) ports.PTY); ok { | 342 | + | if returnFunc, ok := ret.Get(0).(func(string, []string, []string, string, domain.Size) ports.PTY); ok { |
| 343 | - | r0 = returnFunc(cmd, args, env, sz) | 343 | + | r0 = returnFunc(cmd, args, env, dir, sz) |
| 344 | } else { | 344 | } else { | ||
| 345 | if ret.Get(0) != nil { | 345 | if ret.Get(0) != nil { | ||
| 346 | r0 = ret.Get(0).(ports.PTY) | 346 | r0 = ret.Get(0).(ports.PTY) | ||
| 347 | } | 347 | } | ||
| 348 | } | 348 | } | ||
| 349 | - | if returnFunc, ok := ret.Get(1).(func(string, []string, []string, domain.Size) error); ok { | 349 | + | if returnFunc, ok := ret.Get(1).(func(string, []string, []string, string, domain.Size) error); ok { |
| 350 | - | r1 = returnFunc(cmd, args, env, sz) | 350 | + | r1 = returnFunc(cmd, args, env, dir, sz) |
| 351 | } else { | 351 | } else { | ||
| 352 | r1 = ret.Error(1) | 352 | r1 = ret.Error(1) | ||
| 353 | } | 353 | } | ||
| @@ -363,12 +363,13 @@ type MockPTYFactory_Open_Call struct { | |||||
| 363 | // - cmd string | 363 | // - cmd string | ||
| 364 | // - args []string | 364 | // - args []string | ||
| 365 | // - env []string | 365 | // - env []string | ||
| 366 | + | // - dir string | |||
| 366 | // - sz domain.Size | 367 | // - sz domain.Size | ||
| 367 | - | func (_e *MockPTYFactory_Expecter) Open(cmd any, args any, env any, sz any) *MockPTYFactory_Open_Call { | 368 | + | func (_e *MockPTYFactory_Expecter) Open(cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call { |
| 368 | - | return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", cmd, args, env, sz)} | 369 | + | return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", cmd, args, env, dir, sz)} |
| 369 | } | 370 | } | ||
| 370 | 371 | ||||
| 371 | - | func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env []string, sz domain.Size)) *MockPTYFactory_Open_Call { | 372 | + | func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env []string, dir string, sz domain.Size)) *MockPTYFactory_Open_Call { |
| 372 | _c.Call.Run(func(args mock.Arguments) { | 373 | _c.Call.Run(func(args mock.Arguments) { | ||
| 373 | var arg0 string | 374 | var arg0 string | ||
| 374 | if args[0] != nil { | 375 | if args[0] != nil { | ||
| @@ -382,15 +383,20 @@ func (_c *MockPTYFactory_Open_Call) Run(run func(cmd string, args []string, env | |||||
| 382 | if args[2] != nil { | 383 | if args[2] != nil { | ||
| 383 | arg2 = args[2].([]string) | 384 | arg2 = args[2].([]string) | ||
| 384 | } | 385 | } | ||
| 385 | - | var arg3 domain.Size | 386 | + | var arg3 string |
| 386 | if args[3] != nil { | 387 | if args[3] != nil { | ||
| 387 | - | arg3 = args[3].(domain.Size) | 388 | + | arg3 = args[3].(string) |
| 389 | + | } | |||
| 390 | + | var arg4 domain.Size | |||
| 391 | + | if args[4] != nil { | |||
| 392 | + | arg4 = args[4].(domain.Size) | |||
| 388 | } | 393 | } | ||
| 389 | run( | 394 | run( | ||
| 390 | arg0, | 395 | arg0, | ||
| 391 | arg1, | 396 | arg1, | ||
| 392 | arg2, | 397 | arg2, | ||
| 393 | arg3, | 398 | arg3, | ||
| 399 | + | arg4, | |||
| 394 | ) | 400 | ) | ||
| 395 | }) | 401 | }) | ||
| 396 | return _c | 402 | return _c | ||
| @@ -401,7 +407,7 @@ func (_c *MockPTYFactory_Open_Call) Return(pTY ports.PTY, err error) *MockPTYFac | |||||
| 401 | return _c | 407 | return _c | ||
| 402 | } | 408 | } | ||
| 403 | 409 | ||||
| 404 | - | func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(cmd string, args []string, env []string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call { | 410 | + | func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call { |
| 405 | _c.Call.Return(run) | 411 | _c.Call.Return(run) | ||
| 406 | return _c | 412 | return _c | ||
| 407 | } | 413 | } | ||
M internal/ports/ports.go +1 -1
| @@ -16,7 +16,7 @@ type PTY interface { | |||
| 16 | 16 | ||
| 17 | 17 | // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal. | |
| 18 | 18 | type PTYFactory interface { | |
| 19 | - | Open(cmd string, args []string, env []string, sz domain.Size) (PTY, error) | |
| 19 | + | Open(cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error) | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | // Terminal is the CLIENT-side controlling terminal. | |
| @@ -16,7 +16,7 @@ type PTY interface { | |||||
| 16 | 16 | ||||
| 17 | // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal. | 17 | // PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal. | ||
| 18 | type PTYFactory interface { | 18 | type PTYFactory interface { | ||
| 19 | - | Open(cmd string, args []string, env []string, sz domain.Size) (PTY, error) | 19 | + | Open(cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error) |
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
| 22 | // Terminal is the CLIENT-side controlling terminal. | 22 | // Terminal is the CLIENT-side controlling terminal. | ||
M internal/ports/wire.go +13 -0
| @@ -52,6 +52,7 @@ type Hello struct { | |||
| 52 | 52 | Name string | |
| 53 | 53 | Size domain.Size | |
| 54 | 54 | TermEnv string | |
| 55 | + | Cwd string | |
| 55 | 56 | } | |
| 56 | 57 | ||
| 57 | 58 | // Input carries raw bytes typed/pasted by the client, destined for the PTY. | |
| @@ -193,6 +194,14 @@ func (r *payloadReader) done() error { | |||
| 193 | 194 | return nil | |
| 194 | 195 | } | |
| 195 | 196 | ||
| 197 | + | // PeekHelloVersion returns the leading protocol version from a Hello payload. | |
| 198 | + | func PeekHelloVersion(b []byte) (uint16, bool) { | |
| 199 | + | if len(b) < 2 { | |
| 200 | + | return 0, false | |
| 201 | + | } | |
| 202 | + | return binary.BigEndian.Uint16(b), true | |
| 203 | + | } | |
| 204 | + | ||
| 196 | 205 | // MarshalHello encodes h into a Hello message payload. | |
| 197 | 206 | func MarshalHello(h Hello) []byte { | |
| 198 | 207 | w := payloadWriter{} | |
| @@ -202,6 +211,7 @@ func MarshalHello(h Hello) []byte { | |||
| 202 | 211 | w.putUint16(uint16(h.Size.Cols)) | |
| 203 | 212 | w.putUint16(uint16(h.Size.Rows)) | |
| 204 | 213 | w.putString(h.TermEnv) | |
| 214 | + | w.putString(h.Cwd) | |
| 205 | 215 | return w.b | |
| 206 | 216 | } | |
| 207 | 217 | ||
| @@ -232,6 +242,9 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 232 | 242 | if h.TermEnv, err = r.getString(); err != nil { | |
| 233 | 243 | return Hello{}, err | |
| 234 | 244 | } | |
| 245 | + | if h.Cwd, err = r.getString(); err != nil { | |
| 246 | + | return Hello{}, err | |
| 247 | + | } | |
| 235 | 248 | if err := r.done(); err != nil { | |
| 236 | 249 | return Hello{}, err | |
| 237 | 250 | } | |
| @@ -52,6 +52,7 @@ type Hello struct { | |||||
| 52 | Name string | 52 | Name string | ||
| 53 | Size domain.Size | 53 | Size domain.Size | ||
| 54 | TermEnv string | 54 | TermEnv string | ||
| 55 | + | Cwd string | |||
| 55 | } | 56 | } | ||
| 56 | 57 | ||||
| 57 | // Input carries raw bytes typed/pasted by the client, destined for the PTY. | 58 | // Input carries raw bytes typed/pasted by the client, destined for the PTY. | ||
| @@ -193,6 +194,14 @@ func (r *payloadReader) done() error { | |||||
| 193 | return nil | 194 | return nil | ||
| 194 | } | 195 | } | ||
| 195 | 196 | ||||
| 197 | + | // PeekHelloVersion returns the leading protocol version from a Hello payload. | |||
| 198 | + | func PeekHelloVersion(b []byte) (uint16, bool) { | |||
| 199 | + | if len(b) < 2 { | |||
| 200 | + | return 0, false | |||
| 201 | + | } | |||
| 202 | + | return binary.BigEndian.Uint16(b), true | |||
| 203 | + | } | |||
| 204 | + | ||||
| 196 | // MarshalHello encodes h into a Hello message payload. | 205 | // MarshalHello encodes h into a Hello message payload. | ||
| 197 | func MarshalHello(h Hello) []byte { | 206 | func MarshalHello(h Hello) []byte { | ||
| 198 | w := payloadWriter{} | 207 | w := payloadWriter{} | ||
| @@ -202,6 +211,7 @@ func MarshalHello(h Hello) []byte { | |||||
| 202 | w.putUint16(uint16(h.Size.Cols)) | 211 | w.putUint16(uint16(h.Size.Cols)) | ||
| 203 | w.putUint16(uint16(h.Size.Rows)) | 212 | w.putUint16(uint16(h.Size.Rows)) | ||
| 204 | w.putString(h.TermEnv) | 213 | w.putString(h.TermEnv) | ||
| 214 | + | w.putString(h.Cwd) | |||
| 205 | return w.b | 215 | return w.b | ||
| 206 | } | 216 | } | ||
| 207 | 217 | ||||
| @@ -232,6 +242,9 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||||
| 232 | if h.TermEnv, err = r.getString(); err != nil { | 242 | if h.TermEnv, err = r.getString(); err != nil { | ||
| 233 | return Hello{}, err | 243 | return Hello{}, err | ||
| 234 | } | 244 | } | ||
| 245 | + | if h.Cwd, err = r.getString(); err != nil { | |||
| 246 | + | return Hello{}, err | |||
| 247 | + | } | |||
| 235 | if err := r.done(); err != nil { | 248 | if err := r.done(); err != nil { | ||
| 236 | return Hello{}, err | 249 | return Hello{}, err | ||
| 237 | } | 250 | } | ||
M internal/ports/wire_test.go +4 -2
| @@ -64,8 +64,9 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||
| 64 | 64 | Name: "w0", | |
| 65 | 65 | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 66 | 66 | TermEnv: "xterm-256color", | |
| 67 | + | Cwd: "/tmp/project", | |
| 67 | 68 | }, | |
| 68 | - | want: []byte{0x00, 0x01, 0x01, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72}, | |
| 69 | + | want: []byte{0x00, 0x01, 0x01, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74}, | |
| 69 | 70 | }, | |
| 70 | 71 | { | |
| 71 | 72 | name: "empty strings", | |
| @@ -75,8 +76,9 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||
| 75 | 76 | Name: "", | |
| 76 | 77 | Size: domain.Size{Cols: 0, Rows: 0}, | |
| 77 | 78 | TermEnv: "", | |
| 79 | + | Cwd: "", | |
| 78 | 80 | }, | |
| 79 | - | want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | |
| 81 | + | want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | |
| 80 | 82 | }, | |
| 81 | 83 | } | |
| 82 | 84 | ||
| @@ -64,8 +64,9 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||||
| 64 | Name: "w0", | 64 | Name: "w0", | ||
| 65 | Size: domain.Size{Cols: 80, Rows: 24}, | 65 | Size: domain.Size{Cols: 80, Rows: 24}, | ||
| 66 | TermEnv: "xterm-256color", | 66 | TermEnv: "xterm-256color", | ||
| 67 | + | Cwd: "/tmp/project", | |||
| 67 | }, | 68 | }, | ||
| 68 | - | want: []byte{0x00, 0x01, 0x01, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72}, | 69 | + | want: []byte{0x00, 0x01, 0x01, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74}, |
| 69 | }, | 70 | }, | ||
| 70 | { | 71 | { | ||
| 71 | name: "empty strings", | 72 | name: "empty strings", | ||
| @@ -75,8 +76,9 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||||
| 75 | Name: "", | 76 | Name: "", | ||
| 76 | Size: domain.Size{Cols: 0, Rows: 0}, | 77 | Size: domain.Size{Cols: 0, Rows: 0}, | ||
| 77 | TermEnv: "", | 78 | TermEnv: "", | ||
| 79 | + | Cwd: "", | |||
| 78 | }, | 80 | }, | ||
| 79 | - | want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | 81 | + | want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, |
| 80 | }, | 82 | }, | ||
| 81 | } | 83 | } | ||
| 82 | 84 | ||||
M internal/usecase/client/client.go +5 -0
| @@ -111,12 +111,17 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, | |||
| 111 | 111 | if err != nil { | |
| 112 | 112 | return fmt.Errorf("vev: reading terminal size: %w", err) | |
| 113 | 113 | } | |
| 114 | + | cwd, err := os.Getwd() | |
| 115 | + | if err != nil { | |
| 116 | + | cwd = "" | |
| 117 | + | } | |
| 114 | 118 | hello := ports.Hello{ | |
| 115 | 119 | Version: ports.ProtocolVersion, | |
| 116 | 120 | Intent: intent, | |
| 117 | 121 | Name: name, | |
| 118 | 122 | Size: size, | |
| 119 | 123 | TermEnv: os.Getenv("TERM"), | |
| 124 | + | Cwd: cwd, | |
| 120 | 125 | } | |
| 121 | 126 | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | |
| 122 | 127 | return fmt.Errorf("vev: sending hello: %w", err) | |
| @@ -111,12 +111,17 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, | |||||
| 111 | if err != nil { | 111 | if err != nil { | ||
| 112 | return fmt.Errorf("vev: reading terminal size: %w", err) | 112 | return fmt.Errorf("vev: reading terminal size: %w", err) | ||
| 113 | } | 113 | } | ||
| 114 | + | cwd, err := os.Getwd() | |||
| 115 | + | if err != nil { | |||
| 116 | + | cwd = "" | |||
| 117 | + | } | |||
| 114 | hello := ports.Hello{ | 118 | hello := ports.Hello{ | ||
| 115 | Version: ports.ProtocolVersion, | 119 | Version: ports.ProtocolVersion, | ||
| 116 | Intent: intent, | 120 | Intent: intent, | ||
| 117 | Name: name, | 121 | Name: name, | ||
| 118 | Size: size, | 122 | Size: size, | ||
| 119 | TermEnv: os.Getenv("TERM"), | 123 | TermEnv: os.Getenv("TERM"), | ||
| 124 | + | Cwd: cwd, | |||
| 120 | } | 125 | } | ||
| 121 | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | 126 | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | ||
| 122 | return fmt.Errorf("vev: sending hello: %w", err) | 127 | return fmt.Errorf("vev: sending hello: %w", err) | ||
M internal/usecase/daemon/daemon.go +118 -6
| @@ -134,6 +134,7 @@ type session struct { | |||
| 134 | 134 | tabs []*tab | |
| 135 | 135 | active int | |
| 136 | 136 | client *attachedClient | |
| 137 | + | cwd string | |
| 137 | 138 | } | |
| 138 | 139 | ||
| 139 | 140 | // tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output | |
| @@ -170,11 +171,13 @@ type attachedClient struct { | |||
| 170 | 171 | copyMu sync.Mutex | |
| 171 | 172 | copyMode *scopy.Mode | |
| 172 | 173 | copyPending []byte | |
| 174 | + | copyESC pendingByteTimer | |
| 173 | 175 | copyFeedback string | |
| 174 | 176 | pickerMu sync.Mutex | |
| 175 | 177 | picker *picker.Model | |
| 176 | 178 | pickerPreview *tab | |
| 177 | 179 | pickerPending []byte | |
| 180 | + | pickerESC pendingByteTimer | |
| 178 | 181 | mouseScan mouse.Scanner | |
| 179 | 182 | lastCursor cursorOut | |
| 180 | 183 | sendMu sync.Mutex | |
| @@ -189,6 +192,35 @@ type cursorOut struct { | |||
| 189 | 192 | hasStyle bool | |
| 190 | 193 | } | |
| 191 | 194 | ||
| 195 | + | type pendingByteTimer struct { | |
| 196 | + | timer ports.Timer | |
| 197 | + | done chan struct{} | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func (p *pendingByteTimer) retain(clock ports.Clock, delay time.Duration, onFire func(ports.Timer)) { | |
| 201 | + | p.timer = clock.NewTimer(delay) | |
| 202 | + | p.done = make(chan struct{}) | |
| 203 | + | go func(timer ports.Timer, done <-chan struct{}) { | |
| 204 | + | select { | |
| 205 | + | case <-timer.C(): | |
| 206 | + | case <-done: | |
| 207 | + | return | |
| 208 | + | } | |
| 209 | + | onFire(timer) | |
| 210 | + | }(p.timer, p.done) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func (p *pendingByteTimer) stop() { | |
| 214 | + | if p.timer != nil { | |
| 215 | + | p.timer.Stop() | |
| 216 | + | p.timer = nil | |
| 217 | + | } | |
| 218 | + | if p.done != nil { | |
| 219 | + | close(p.done) | |
| 220 | + | p.done = nil | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 192 | 224 | func (ac *attachedClient) currentSession() *session { | |
| 193 | 225 | ac.sessMu.Lock() | |
| 194 | 226 | defer ac.sessMu.Unlock() | |
| @@ -472,7 +504,11 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |||
| 472 | 504 | func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |
| 473 | 505 | h, err := ports.UnmarshalHello(f.Payload) | |
| 474 | 506 | if err != nil { | |
| 475 | - | _ = tr.Send(frameError(ports.ErrInternal, "malformed hello")) | |
| 507 | + | if version, ok := ports.PeekHelloVersion(f.Payload); ok && version != ports.ProtocolVersion { | |
| 508 | + | _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch")) | |
| 509 | + | } else { | |
| 510 | + | _ = tr.Send(frameError(ports.ErrInternal, "malformed hello")) | |
| 511 | + | } | |
| 476 | 512 | _ = tr.Close() | |
| 477 | 513 | return | |
| 478 | 514 | } | |
| @@ -530,7 +566,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 530 | 566 | switch h.Intent { | |
| 531 | 567 | case ports.IntentEphemeral: | |
| 532 | 568 | name := d.allocEphemeralNameLocked() | |
| 533 | - | sess, err := d.createSessionLocked(name, true, sz) | |
| 569 | + | sess, err := d.createSessionLocked(name, true, h.Cwd, sz) | |
| 534 | 570 | d.mu.Unlock() | |
| 535 | 571 | if err != nil { | |
| 536 | 572 | return nil, nil, err | |
| @@ -546,7 +582,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 546 | 582 | d.mu.Unlock() | |
| 547 | 583 | return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name} | |
| 548 | 584 | } | |
| 549 | - | sess, err := d.createSessionLocked(h.Name, false, sz) | |
| 585 | + | sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz) | |
| 550 | 586 | d.mu.Unlock() | |
| 551 | 587 | if err != nil { | |
| 552 | 588 | return nil, nil, err | |
| @@ -569,9 +605,9 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 569 | 605 | ||
| 570 | 606 | // createSessionLocked spawns a PTY-backed session and starts its reader and | |
| 571 | 607 | // scheduler goroutines. Caller holds d.mu. | |
| 572 | - | func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size) (*session, error) { | |
| 608 | + | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) { | |
| 573 | 609 | tbSize := tabSize(sz) | |
| 574 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), tbSize) | |
| 610 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize) | |
| 575 | 611 | if err != nil { | |
| 576 | 612 | return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err) | |
| 577 | 613 | } | |
| @@ -589,6 +625,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size | |||
| 589 | 625 | ctx: sctx, | |
| 590 | 626 | cancel: cancel, | |
| 591 | 627 | tabs: []*tab{tb}, | |
| 628 | + | cwd: cwd, | |
| 592 | 629 | } | |
| 593 | 630 | d.sessions[id] = sess | |
| 594 | 631 | d.startTabGoroutines(sess, tb) | |
| @@ -597,7 +634,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size | |||
| 597 | 634 | ||
| 598 | 635 | func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |
| 599 | 636 | tbSize := tabSize(sz) | |
| 600 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), tbSize) | |
| 637 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), sess.cwd, tbSize) | |
| 601 | 638 | if err != nil { | |
| 602 | 639 | return fmt.Errorf("daemon: spawning tab for session %q: %w", sess.name, err) | |
| 603 | 640 | } | |
| @@ -894,11 +931,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 894 | 931 | ac.copyMu.Lock() | |
| 895 | 932 | if ac.copyMode == nil { | |
| 896 | 933 | ac.copyPending = nil | |
| 934 | + | d.stopCopyPendingTimerLocked(ac) | |
| 897 | 935 | ac.copyMu.Unlock() | |
| 898 | 936 | tb.mu.Unlock() | |
| 899 | 937 | return | |
| 900 | 938 | } | |
| 901 | 939 | if len(ac.copyPending) > 0 { | |
| 940 | + | d.stopCopyPendingTimerLocked(ac) | |
| 902 | 941 | combined := make([]byte, 0, len(ac.copyPending)+len(data)) | |
| 903 | 942 | combined = append(combined, ac.copyPending...) | |
| 904 | 943 | combined = append(combined, data...) | |
| @@ -938,6 +977,10 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 938 | 977 | changed = true | |
| 939 | 978 | continue | |
| 940 | 979 | } | |
| 980 | + | if len(tail) == 1 { | |
| 981 | + | d.retainCopyESCLocked(ac) | |
| 982 | + | break | |
| 983 | + | } | |
| 941 | 984 | if isCopyEscapePrefix(tail) { | |
| 942 | 985 | ac.copyPending = append(ac.copyPending[:0], tail...) | |
| 943 | 986 | break | |
| @@ -951,6 +994,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 951 | 994 | text = ac.copyMode.SelectedText(snap) | |
| 952 | 995 | } | |
| 953 | 996 | if exit { | |
| 997 | + | d.stopCopyPendingTimerLocked(ac) | |
| 954 | 998 | ac.copyMode = nil | |
| 955 | 999 | } | |
| 956 | 1000 | ac.copyMu.Unlock() | |
| @@ -981,6 +1025,35 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 981 | 1025 | } | |
| 982 | 1026 | } | |
| 983 | 1027 | ||
| 1028 | + | func (d *Daemon) retainCopyESCLocked(ac *attachedClient) { | |
| 1029 | + | mode := ac.copyMode | |
| 1030 | + | ac.copyPending = append(ac.copyPending[:0], keys.ESC) | |
| 1031 | + | ac.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) { | |
| 1032 | + | ac.copyMu.Lock() | |
| 1033 | + | if ac.copyESC.timer != timer { | |
| 1034 | + | ac.copyMu.Unlock() | |
| 1035 | + | return | |
| 1036 | + | } | |
| 1037 | + | ac.copyPending = nil | |
| 1038 | + | ac.copyESC.timer = nil | |
| 1039 | + | ac.copyESC.done = nil | |
| 1040 | + | if ac.copyMode != mode || ac.copyMode == nil { | |
| 1041 | + | ac.copyMu.Unlock() | |
| 1042 | + | return | |
| 1043 | + | } | |
| 1044 | + | ac.copyMode = nil | |
| 1045 | + | ac.copyMu.Unlock() | |
| 1046 | + | ||
| 1047 | + | if sess := ac.currentSession(); sess != nil { | |
| 1048 | + | d.paint(sess, ac, true) | |
| 1049 | + | } | |
| 1050 | + | }) | |
| 1051 | + | } | |
| 1052 | + | ||
| 1053 | + | func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) { | |
| 1054 | + | ac.copyESC.stop() | |
| 1055 | + | } | |
| 1056 | + | ||
| 984 | 1057 | func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) { | |
| 985 | 1058 | if len(data) >= 3 && data[1] == '[' { | |
| 986 | 1059 | switch data[2] { | |
| @@ -1055,10 +1128,12 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||
| 1055 | 1128 | ac.pickerMu.Lock() | |
| 1056 | 1129 | if ac.picker == nil { | |
| 1057 | 1130 | ac.pickerPending = nil | |
| 1131 | + | d.stopPickerPendingTimerLocked(ac) | |
| 1058 | 1132 | ac.pickerMu.Unlock() | |
| 1059 | 1133 | return | |
| 1060 | 1134 | } | |
| 1061 | 1135 | if len(ac.pickerPending) > 0 { | |
| 1136 | + | d.stopPickerPendingTimerLocked(ac) | |
| 1062 | 1137 | combined := make([]byte, 0, len(ac.pickerPending)+len(data)) | |
| 1063 | 1138 | combined = append(combined, ac.pickerPending...) | |
| 1064 | 1139 | combined = append(combined, data...) | |
| @@ -1088,6 +1163,10 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||
| 1088 | 1163 | changed = true | |
| 1089 | 1164 | continue | |
| 1090 | 1165 | } | |
| 1166 | + | if len(tail) == 1 { | |
| 1167 | + | d.retainPickerESCLocked(ac) | |
| 1168 | + | break | |
| 1169 | + | } | |
| 1091 | 1170 | if isPickerEscapePrefix(tail) { | |
| 1092 | 1171 | ac.pickerPending = append(ac.pickerPending[:0], tail...) | |
| 1093 | 1172 | break | |
| @@ -1118,6 +1197,31 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||
| 1118 | 1197 | } | |
| 1119 | 1198 | } | |
| 1120 | 1199 | ||
| 1200 | + | func (d *Daemon) retainPickerESCLocked(ac *attachedClient) { | |
| 1201 | + | ac.pickerPending = append(ac.pickerPending[:0], keys.ESC) | |
| 1202 | + | ac.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) { | |
| 1203 | + | ac.pickerMu.Lock() | |
| 1204 | + | if ac.pickerESC.timer != timer || len(ac.pickerPending) != 1 || ac.pickerPending[0] != keys.ESC || ac.picker == nil { | |
| 1205 | + | ac.pickerMu.Unlock() | |
| 1206 | + | return | |
| 1207 | + | } | |
| 1208 | + | ac.pickerPending = nil | |
| 1209 | + | ac.pickerESC.timer = nil | |
| 1210 | + | ac.pickerESC.done = nil | |
| 1211 | + | ac.picker = nil | |
| 1212 | + | ac.pickerMu.Unlock() | |
| 1213 | + | ||
| 1214 | + | d.unregisterPreview(ac) | |
| 1215 | + | if sess := ac.currentSession(); sess != nil { | |
| 1216 | + | d.paint(sess, ac, true) | |
| 1217 | + | } | |
| 1218 | + | }) | |
| 1219 | + | } | |
| 1220 | + | ||
| 1221 | + | func (d *Daemon) stopPickerPendingTimerLocked(ac *attachedClient) { | |
| 1222 | + | ac.pickerESC.stop() | |
| 1223 | + | } | |
| 1224 | + | ||
| 1121 | 1225 | func routePickerEscape(m *picker.Model, data []byte) (int, bool) { | |
| 1122 | 1226 | if len(data) >= 3 && data[1] == '[' { | |
| 1123 | 1227 | switch data[2] { | |
| @@ -1205,9 +1309,11 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||
| 1205 | 1309 | if previewer == nil { | |
| 1206 | 1310 | return | |
| 1207 | 1311 | } | |
| 1312 | + | cleared := false | |
| 1208 | 1313 | previewer.pickerMu.Lock() | |
| 1209 | 1314 | if previewer.pickerPreview == tb { | |
| 1210 | 1315 | previewer.pickerPreview = nil | |
| 1316 | + | cleared = true | |
| 1211 | 1317 | } | |
| 1212 | 1318 | previewer.pickerMu.Unlock() | |
| 1213 | 1319 | tb.mu.Lock() | |
| @@ -1215,12 +1321,18 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||
| 1215 | 1321 | tb.previewClient = nil | |
| 1216 | 1322 | } | |
| 1217 | 1323 | tb.mu.Unlock() | |
| 1324 | + | if cleared { | |
| 1325 | + | if sess := previewer.currentSession(); sess != nil { | |
| 1326 | + | d.paint(sess, previewer, true) | |
| 1327 | + | } | |
| 1328 | + | } | |
| 1218 | 1329 | } | |
| 1219 | 1330 | ||
| 1220 | 1331 | func (d *Daemon) closePicker(ac *attachedClient) { | |
| 1221 | 1332 | ac.pickerMu.Lock() | |
| 1222 | 1333 | ac.picker = nil | |
| 1223 | 1334 | ac.pickerPending = nil | |
| 1335 | + | d.stopPickerPendingTimerLocked(ac) | |
| 1224 | 1336 | ac.pickerMu.Unlock() | |
| 1225 | 1337 | d.unregisterPreview(ac) | |
| 1226 | 1338 | } | |
| @@ -134,6 +134,7 @@ type session struct { | |||||
| 134 | tabs []*tab | 134 | tabs []*tab | ||
| 135 | active int | 135 | active int | ||
| 136 | client *attachedClient | 136 | client *attachedClient | ||
| 137 | + | cwd string | |||
| 137 | } | 138 | } | ||
| 138 | 139 | ||||
| 139 | // tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output | 140 | // tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output | ||
| @@ -170,11 +171,13 @@ type attachedClient struct { | |||||
| 170 | copyMu sync.Mutex | 171 | copyMu sync.Mutex | ||
| 171 | copyMode *scopy.Mode | 172 | copyMode *scopy.Mode | ||
| 172 | copyPending []byte | 173 | copyPending []byte | ||
| 174 | + | copyESC pendingByteTimer | |||
| 173 | copyFeedback string | 175 | copyFeedback string | ||
| 174 | pickerMu sync.Mutex | 176 | pickerMu sync.Mutex | ||
| 175 | picker *picker.Model | 177 | picker *picker.Model | ||
| 176 | pickerPreview *tab | 178 | pickerPreview *tab | ||
| 177 | pickerPending []byte | 179 | pickerPending []byte | ||
| 180 | + | pickerESC pendingByteTimer | |||
| 178 | mouseScan mouse.Scanner | 181 | mouseScan mouse.Scanner | ||
| 179 | lastCursor cursorOut | 182 | lastCursor cursorOut | ||
| 180 | sendMu sync.Mutex | 183 | sendMu sync.Mutex | ||
| @@ -189,6 +192,35 @@ type cursorOut struct { | |||||
| 189 | hasStyle bool | 192 | hasStyle bool | ||
| 190 | } | 193 | } | ||
| 191 | 194 | ||||
| 195 | + | type pendingByteTimer struct { | |||
| 196 | + | timer ports.Timer | |||
| 197 | + | done chan struct{} | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func (p *pendingByteTimer) retain(clock ports.Clock, delay time.Duration, onFire func(ports.Timer)) { | |||
| 201 | + | p.timer = clock.NewTimer(delay) | |||
| 202 | + | p.done = make(chan struct{}) | |||
| 203 | + | go func(timer ports.Timer, done <-chan struct{}) { | |||
| 204 | + | select { | |||
| 205 | + | case <-timer.C(): | |||
| 206 | + | case <-done: | |||
| 207 | + | return | |||
| 208 | + | } | |||
| 209 | + | onFire(timer) | |||
| 210 | + | }(p.timer, p.done) | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | func (p *pendingByteTimer) stop() { | |||
| 214 | + | if p.timer != nil { | |||
| 215 | + | p.timer.Stop() | |||
| 216 | + | p.timer = nil | |||
| 217 | + | } | |||
| 218 | + | if p.done != nil { | |||
| 219 | + | close(p.done) | |||
| 220 | + | p.done = nil | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 192 | func (ac *attachedClient) currentSession() *session { | 224 | func (ac *attachedClient) currentSession() *session { | ||
| 193 | ac.sessMu.Lock() | 225 | ac.sessMu.Lock() | ||
| 194 | defer ac.sessMu.Unlock() | 226 | defer ac.sessMu.Unlock() | ||
| @@ -472,7 +504,11 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |||||
| 472 | func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | 504 | func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | ||
| 473 | h, err := ports.UnmarshalHello(f.Payload) | 505 | h, err := ports.UnmarshalHello(f.Payload) | ||
| 474 | if err != nil { | 506 | if err != nil { | ||
| 475 | - | _ = tr.Send(frameError(ports.ErrInternal, "malformed hello")) | 507 | + | if version, ok := ports.PeekHelloVersion(f.Payload); ok && version != ports.ProtocolVersion { |
| 508 | + | _ = tr.Send(frameError(ports.ErrVersionMismatch, "protocol version mismatch")) | |||
| 509 | + | } else { | |||
| 510 | + | _ = tr.Send(frameError(ports.ErrInternal, "malformed hello")) | |||
| 511 | + | } | |||
| 476 | _ = tr.Close() | 512 | _ = tr.Close() | ||
| 477 | return | 513 | return | ||
| 478 | } | 514 | } | ||
| @@ -530,7 +566,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 530 | switch h.Intent { | 566 | switch h.Intent { | ||
| 531 | case ports.IntentEphemeral: | 567 | case ports.IntentEphemeral: | ||
| 532 | name := d.allocEphemeralNameLocked() | 568 | name := d.allocEphemeralNameLocked() | ||
| 533 | - | sess, err := d.createSessionLocked(name, true, sz) | 569 | + | sess, err := d.createSessionLocked(name, true, h.Cwd, sz) |
| 534 | d.mu.Unlock() | 570 | d.mu.Unlock() | ||
| 535 | if err != nil { | 571 | if err != nil { | ||
| 536 | return nil, nil, err | 572 | return nil, nil, err | ||
| @@ -546,7 +582,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 546 | d.mu.Unlock() | 582 | d.mu.Unlock() | ||
| 547 | return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name} | 583 | return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name} | ||
| 548 | } | 584 | } | ||
| 549 | - | sess, err := d.createSessionLocked(h.Name, false, sz) | 585 | + | sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz) |
| 550 | d.mu.Unlock() | 586 | d.mu.Unlock() | ||
| 551 | if err != nil { | 587 | if err != nil { | ||
| 552 | return nil, nil, err | 588 | return nil, nil, err | ||
| @@ -569,9 +605,9 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 569 | 605 | ||||
| 570 | // createSessionLocked spawns a PTY-backed session and starts its reader and | 606 | // createSessionLocked spawns a PTY-backed session and starts its reader and | ||
| 571 | // scheduler goroutines. Caller holds d.mu. | 607 | // scheduler goroutines. Caller holds d.mu. | ||
| 572 | - | func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size) (*session, error) { | 608 | + | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) { |
| 573 | tbSize := tabSize(sz) | 609 | tbSize := tabSize(sz) | ||
| 574 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), tbSize) | 610 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize) |
| 575 | if err != nil { | 611 | if err != nil { | ||
| 576 | return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err) | 612 | return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err) | ||
| 577 | } | 613 | } | ||
| @@ -589,6 +625,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size | |||||
| 589 | ctx: sctx, | 625 | ctx: sctx, | ||
| 590 | cancel: cancel, | 626 | cancel: cancel, | ||
| 591 | tabs: []*tab{tb}, | 627 | tabs: []*tab{tb}, | ||
| 628 | + | cwd: cwd, | |||
| 592 | } | 629 | } | ||
| 593 | d.sessions[id] = sess | 630 | d.sessions[id] = sess | ||
| 594 | d.startTabGoroutines(sess, tb) | 631 | d.startTabGoroutines(sess, tb) | ||
| @@ -597,7 +634,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size | |||||
| 597 | 634 | ||||
| 598 | func (d *Daemon) createTab(sess *session, sz domain.Size) error { | 635 | func (d *Daemon) createTab(sess *session, sz domain.Size) error { | ||
| 599 | tbSize := tabSize(sz) | 636 | tbSize := tabSize(sz) | ||
| 600 | - | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), tbSize) | 637 | + | pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), sess.cwd, tbSize) |
| 601 | if err != nil { | 638 | if err != nil { | ||
| 602 | return fmt.Errorf("daemon: spawning tab for session %q: %w", sess.name, err) | 639 | return fmt.Errorf("daemon: spawning tab for session %q: %w", sess.name, err) | ||
| 603 | } | 640 | } | ||
| @@ -894,11 +931,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||||
| 894 | ac.copyMu.Lock() | 931 | ac.copyMu.Lock() | ||
| 895 | if ac.copyMode == nil { | 932 | if ac.copyMode == nil { | ||
| 896 | ac.copyPending = nil | 933 | ac.copyPending = nil | ||
| 934 | + | d.stopCopyPendingTimerLocked(ac) | |||
| 897 | ac.copyMu.Unlock() | 935 | ac.copyMu.Unlock() | ||
| 898 | tb.mu.Unlock() | 936 | tb.mu.Unlock() | ||
| 899 | return | 937 | return | ||
| 900 | } | 938 | } | ||
| 901 | if len(ac.copyPending) > 0 { | 939 | if len(ac.copyPending) > 0 { | ||
| 940 | + | d.stopCopyPendingTimerLocked(ac) | |||
| 902 | combined := make([]byte, 0, len(ac.copyPending)+len(data)) | 941 | combined := make([]byte, 0, len(ac.copyPending)+len(data)) | ||
| 903 | combined = append(combined, ac.copyPending...) | 942 | combined = append(combined, ac.copyPending...) | ||
| 904 | combined = append(combined, data...) | 943 | combined = append(combined, data...) | ||
| @@ -938,6 +977,10 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||||
| 938 | changed = true | 977 | changed = true | ||
| 939 | continue | 978 | continue | ||
| 940 | } | 979 | } | ||
| 980 | + | if len(tail) == 1 { | |||
| 981 | + | d.retainCopyESCLocked(ac) | |||
| 982 | + | break | |||
| 983 | + | } | |||
| 941 | if isCopyEscapePrefix(tail) { | 984 | if isCopyEscapePrefix(tail) { | ||
| 942 | ac.copyPending = append(ac.copyPending[:0], tail...) | 985 | ac.copyPending = append(ac.copyPending[:0], tail...) | ||
| 943 | break | 986 | break | ||
| @@ -951,6 +994,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||||
| 951 | text = ac.copyMode.SelectedText(snap) | 994 | text = ac.copyMode.SelectedText(snap) | ||
| 952 | } | 995 | } | ||
| 953 | if exit { | 996 | if exit { | ||
| 997 | + | d.stopCopyPendingTimerLocked(ac) | |||
| 954 | ac.copyMode = nil | 998 | ac.copyMode = nil | ||
| 955 | } | 999 | } | ||
| 956 | ac.copyMu.Unlock() | 1000 | ac.copyMu.Unlock() | ||
| @@ -981,6 +1025,35 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||||
| 981 | } | 1025 | } | ||
| 982 | } | 1026 | } | ||
| 983 | 1027 | ||||
| 1028 | + | func (d *Daemon) retainCopyESCLocked(ac *attachedClient) { | |||
| 1029 | + | mode := ac.copyMode | |||
| 1030 | + | ac.copyPending = append(ac.copyPending[:0], keys.ESC) | |||
| 1031 | + | ac.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) { | |||
| 1032 | + | ac.copyMu.Lock() | |||
| 1033 | + | if ac.copyESC.timer != timer { | |||
| 1034 | + | ac.copyMu.Unlock() | |||
| 1035 | + | return | |||
| 1036 | + | } | |||
| 1037 | + | ac.copyPending = nil | |||
| 1038 | + | ac.copyESC.timer = nil | |||
| 1039 | + | ac.copyESC.done = nil | |||
| 1040 | + | if ac.copyMode != mode || ac.copyMode == nil { | |||
| 1041 | + | ac.copyMu.Unlock() | |||
| 1042 | + | return | |||
| 1043 | + | } | |||
| 1044 | + | ac.copyMode = nil | |||
| 1045 | + | ac.copyMu.Unlock() | |||
| 1046 | + | ||||
| 1047 | + | if sess := ac.currentSession(); sess != nil { | |||
| 1048 | + | d.paint(sess, ac, true) | |||
| 1049 | + | } | |||
| 1050 | + | }) | |||
| 1051 | + | } | |||
| 1052 | + | ||||
| 1053 | + | func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) { | |||
| 1054 | + | ac.copyESC.stop() | |||
| 1055 | + | } | |||
| 1056 | + | ||||
| 984 | func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) { | 1057 | func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) { | ||
| 985 | if len(data) >= 3 && data[1] == '[' { | 1058 | if len(data) >= 3 && data[1] == '[' { | ||
| 986 | switch data[2] { | 1059 | switch data[2] { | ||
| @@ -1055,10 +1128,12 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||||
| 1055 | ac.pickerMu.Lock() | 1128 | ac.pickerMu.Lock() | ||
| 1056 | if ac.picker == nil { | 1129 | if ac.picker == nil { | ||
| 1057 | ac.pickerPending = nil | 1130 | ac.pickerPending = nil | ||
| 1131 | + | d.stopPickerPendingTimerLocked(ac) | |||
| 1058 | ac.pickerMu.Unlock() | 1132 | ac.pickerMu.Unlock() | ||
| 1059 | return | 1133 | return | ||
| 1060 | } | 1134 | } | ||
| 1061 | if len(ac.pickerPending) > 0 { | 1135 | if len(ac.pickerPending) > 0 { | ||
| 1136 | + | d.stopPickerPendingTimerLocked(ac) | |||
| 1062 | combined := make([]byte, 0, len(ac.pickerPending)+len(data)) | 1137 | combined := make([]byte, 0, len(ac.pickerPending)+len(data)) | ||
| 1063 | combined = append(combined, ac.pickerPending...) | 1138 | combined = append(combined, ac.pickerPending...) | ||
| 1064 | combined = append(combined, data...) | 1139 | combined = append(combined, data...) | ||
| @@ -1088,6 +1163,10 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||||
| 1088 | changed = true | 1163 | changed = true | ||
| 1089 | continue | 1164 | continue | ||
| 1090 | } | 1165 | } | ||
| 1166 | + | if len(tail) == 1 { | |||
| 1167 | + | d.retainPickerESCLocked(ac) | |||
| 1168 | + | break | |||
| 1169 | + | } | |||
| 1091 | if isPickerEscapePrefix(tail) { | 1170 | if isPickerEscapePrefix(tail) { | ||
| 1092 | ac.pickerPending = append(ac.pickerPending[:0], tail...) | 1171 | ac.pickerPending = append(ac.pickerPending[:0], tail...) | ||
| 1093 | break | 1172 | break | ||
| @@ -1118,6 +1197,31 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) { | |||||
| 1118 | } | 1197 | } | ||
| 1119 | } | 1198 | } | ||
| 1120 | 1199 | ||||
| 1200 | + | func (d *Daemon) retainPickerESCLocked(ac *attachedClient) { | |||
| 1201 | + | ac.pickerPending = append(ac.pickerPending[:0], keys.ESC) | |||
| 1202 | + | ac.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) { | |||
| 1203 | + | ac.pickerMu.Lock() | |||
| 1204 | + | if ac.pickerESC.timer != timer || len(ac.pickerPending) != 1 || ac.pickerPending[0] != keys.ESC || ac.picker == nil { | |||
| 1205 | + | ac.pickerMu.Unlock() | |||
| 1206 | + | return | |||
| 1207 | + | } | |||
| 1208 | + | ac.pickerPending = nil | |||
| 1209 | + | ac.pickerESC.timer = nil | |||
| 1210 | + | ac.pickerESC.done = nil | |||
| 1211 | + | ac.picker = nil | |||
| 1212 | + | ac.pickerMu.Unlock() | |||
| 1213 | + | ||||
| 1214 | + | d.unregisterPreview(ac) | |||
| 1215 | + | if sess := ac.currentSession(); sess != nil { | |||
| 1216 | + | d.paint(sess, ac, true) | |||
| 1217 | + | } | |||
| 1218 | + | }) | |||
| 1219 | + | } | |||
| 1220 | + | ||||
| 1221 | + | func (d *Daemon) stopPickerPendingTimerLocked(ac *attachedClient) { | |||
| 1222 | + | ac.pickerESC.stop() | |||
| 1223 | + | } | |||
| 1224 | + | ||||
| 1121 | func routePickerEscape(m *picker.Model, data []byte) (int, bool) { | 1225 | func routePickerEscape(m *picker.Model, data []byte) (int, bool) { | ||
| 1122 | if len(data) >= 3 && data[1] == '[' { | 1226 | if len(data) >= 3 && data[1] == '[' { | ||
| 1123 | switch data[2] { | 1227 | switch data[2] { | ||
| @@ -1205,9 +1309,11 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||||
| 1205 | if previewer == nil { | 1309 | if previewer == nil { | ||
| 1206 | return | 1310 | return | ||
| 1207 | } | 1311 | } | ||
| 1312 | + | cleared := false | |||
| 1208 | previewer.pickerMu.Lock() | 1313 | previewer.pickerMu.Lock() | ||
| 1209 | if previewer.pickerPreview == tb { | 1314 | if previewer.pickerPreview == tb { | ||
| 1210 | previewer.pickerPreview = nil | 1315 | previewer.pickerPreview = nil | ||
| 1316 | + | cleared = true | |||
| 1211 | } | 1317 | } | ||
| 1212 | previewer.pickerMu.Unlock() | 1318 | previewer.pickerMu.Unlock() | ||
| 1213 | tb.mu.Lock() | 1319 | tb.mu.Lock() | ||
| @@ -1215,12 +1321,18 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||||
| 1215 | tb.previewClient = nil | 1321 | tb.previewClient = nil | ||
| 1216 | } | 1322 | } | ||
| 1217 | tb.mu.Unlock() | 1323 | tb.mu.Unlock() | ||
| 1324 | + | if cleared { | |||
| 1325 | + | if sess := previewer.currentSession(); sess != nil { | |||
| 1326 | + | d.paint(sess, previewer, true) | |||
| 1327 | + | } | |||
| 1328 | + | } | |||
| 1218 | } | 1329 | } | ||
| 1219 | 1330 | ||||
| 1220 | func (d *Daemon) closePicker(ac *attachedClient) { | 1331 | func (d *Daemon) closePicker(ac *attachedClient) { | ||
| 1221 | ac.pickerMu.Lock() | 1332 | ac.pickerMu.Lock() | ||
| 1222 | ac.picker = nil | 1333 | ac.picker = nil | ||
| 1223 | ac.pickerPending = nil | 1334 | ac.pickerPending = nil | ||
| 1335 | + | d.stopPickerPendingTimerLocked(ac) | |||
| 1224 | ac.pickerMu.Unlock() | 1336 | ac.pickerMu.Unlock() | ||
| 1225 | d.unregisterPreview(ac) | 1337 | d.unregisterPreview(ac) | ||
| 1226 | } | 1338 | } | ||
M internal/usecase/daemon/daemon_test.go +180 -18
| @@ -114,7 +114,7 @@ func newBlockingPTYWithWrites(t *testing.T, writes chan<- []byte) (*portsmocks.M | |||
| 114 | 114 | func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory { | |
| 115 | 115 | t.Helper() | |
| 116 | 116 | f := portsmocks.NewMockPTYFactory(t) | |
| 117 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Maybe() | |
| 117 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Maybe() | |
| 118 | 118 | return f | |
| 119 | 119 | } | |
| 120 | 120 | ||
| @@ -123,8 +123,8 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory { | |||
| 123 | 123 | f := portsmocks.NewMockPTYFactory(t) | |
| 124 | 124 | var mu sync.Mutex | |
| 125 | 125 | next := 0 | |
| 126 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 127 | - | func(string, []string, []string, domain.Size) (ports.PTY, error) { | |
| 126 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 127 | + | func(string, []string, []string, string, domain.Size) (ports.PTY, error) { | |
| 128 | 128 | mu.Lock() | |
| 129 | 129 | defer mu.Unlock() | |
| 130 | 130 | if next >= len(ptys) { | |
| @@ -176,6 +176,45 @@ func mustHello(intent uint8, name string, sz domain.Size) ports.Frame { | |||
| 176 | 176 | })} | |
| 177 | 177 | } | |
| 178 | 178 | ||
| 179 | + | func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) { | |
| 180 | + | sz := domain.Size{Cols: 80, Rows: 24} | |
| 181 | + | first, releaseFirst := newBlockingPTY(t) | |
| 182 | + | defer releaseFirst() | |
| 183 | + | second, releaseSecond := newBlockingPTY(t) | |
| 184 | + | defer releaseSecond() | |
| 185 | + | f := portsmocks.NewMockPTYFactory(t) | |
| 186 | + | var dirs []string | |
| 187 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 188 | + | func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) { | |
| 189 | + | dirs = append(dirs, dir) | |
| 190 | + | if len(dirs) == 1 { | |
| 191 | + | return first, nil | |
| 192 | + | } | |
| 193 | + | return second, nil | |
| 194 | + | }, | |
| 195 | + | ).Twice() | |
| 196 | + | ||
| 197 | + | d := newTestDaemon(t, f, stubClock{}) | |
| 198 | + | tr := portsmocks.NewMockTransport(t) | |
| 199 | + | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 200 | + | tr.EXPECT().Close().Return(nil).Maybe() | |
| 201 | + | ||
| 202 | + | sess, ac, err := d.route(ports.Hello{ | |
| 203 | + | Version: ports.ProtocolVersion, | |
| 204 | + | Intent: ports.IntentNew, | |
| 205 | + | Name: "work", | |
| 206 | + | Size: sz, | |
| 207 | + | TermEnv: "xterm-256color", | |
| 208 | + | Cwd: "/tmp/work", | |
| 209 | + | }, tr) | |
| 210 | + | require.NoError(t, err) | |
| 211 | + | require.NotNil(t, ac) | |
| 212 | + | require.Equal(t, "/tmp/work", sess.cwd) | |
| 213 | + | ||
| 214 | + | require.NoError(t, d.createTab(sess, sz)) | |
| 215 | + | require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs) | |
| 216 | + | } | |
| 217 | + | ||
| 179 | 218 | func frameInput(data []byte) ports.Frame { | |
| 180 | 219 | return ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})} | |
| 181 | 220 | } | |
| @@ -481,14 +520,14 @@ func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) { | |||
| 481 | 520 | ||
| 482 | 521 | func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |
| 483 | 522 | cases := []struct { | |
| 484 | - | name string | |
| 485 | - | firstInput []byte | |
| 486 | - | secondInput []byte | |
| 487 | - | wantActive bool | |
| 523 | + | name string | |
| 524 | + | input [][]byte | |
| 525 | + | wantCursor int | |
| 488 | 526 | }{ | |
| 489 | - | {name: "split up arrow", firstInput: []byte("\x1b["), secondInput: []byte("A"), wantActive: true}, | |
| 490 | - | {name: "split page down", firstInput: []byte("\x1b[6"), secondInput: []byte("~"), wantActive: true}, | |
| 491 | - | {name: "lone escape exits", firstInput: []byte("\x1b"), wantActive: false}, | |
| 527 | + | {name: "escape then up arrow", input: [][]byte{[]byte("\x1b"), []byte("[A")}, wantCursor: 23}, | |
| 528 | + | {name: "escape then down arrow", input: [][]byte{[]byte("g"), []byte("\x1b"), []byte("[B")}, wantCursor: 1}, | |
| 529 | + | {name: "split up arrow", input: [][]byte{[]byte("\x1b["), []byte("A")}, wantCursor: 23}, | |
| 530 | + | {name: "split page down", input: [][]byte{[]byte("g"), []byte("\x1b[6"), []byte("~")}, wantCursor: 23}, | |
| 492 | 531 | } | |
| 493 | 532 | for _, tc := range cases { | |
| 494 | 533 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -501,14 +540,12 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||
| 501 | 540 | ||
| 502 | 541 | d.enterCopyMode(sess, ac) | |
| 503 | 542 | awaitFrame(t, sends, ports.MsgOutput) | |
| 504 | - | d.handleInput(sess, ac, tc.firstInput) | |
| 505 | - | if tc.secondInput != nil { | |
| 506 | - | d.handleInput(sess, ac, tc.secondInput) | |
| 543 | + | for _, input := range tc.input { | |
| 544 | + | d.handleInput(sess, ac, input) | |
| 507 | 545 | } | |
| 508 | 546 | ||
| 509 | - | if gotActive := ac.copyMode != nil; gotActive != tc.wantActive { | |
| 510 | - | t.Fatalf("copy mode active = %v, want %v", gotActive, tc.wantActive) | |
| 511 | - | } | |
| 547 | + | require.NotNil(t, ac.copyMode) | |
| 548 | + | require.Equal(t, tc.wantCursor, ac.copyMode.Cursor) | |
| 512 | 549 | }) | |
| 513 | 550 | } | |
| 514 | 551 | } | |
| @@ -536,6 +573,57 @@ func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) { | |||
| 536 | 573 | } | |
| 537 | 574 | } | |
| 538 | 575 | ||
| 576 | + | func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) { | |
| 577 | + | p, _ := newBlockingPTY(t) | |
| 578 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 579 | + | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 580 | + | d.clock = clk | |
| 581 | + | sess.tabs[0].scrollback = scopy.NewScrollback(4) | |
| 582 | + | sess.tabs[0].screen.Write([]byte("live")) | |
| 583 | + | ||
| 584 | + | d.enterCopyMode(sess, ac) | |
| 585 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 586 | + | d.handleInput(sess, ac, []byte("\x1b")) | |
| 587 | + | timer := <-clk.timers | |
| 588 | + | ac.copyMu.Lock() | |
| 589 | + | require.NotNil(t, ac.copyMode) | |
| 590 | + | ac.copyMu.Unlock() | |
| 591 | + | timer.ch <- time.Now() | |
| 592 | + | require.Eventually(t, func() bool { | |
| 593 | + | ac.copyMu.Lock() | |
| 594 | + | defer ac.copyMu.Unlock() | |
| 595 | + | return ac.copyMode == nil | |
| 596 | + | }, time.Second, 5*time.Millisecond) | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) { | |
| 600 | + | p, _ := newBlockingPTY(t) | |
| 601 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 602 | + | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 603 | + | d.clock = clk | |
| 604 | + | sess.tabs[0].scrollback = scopy.NewScrollback(4) | |
| 605 | + | sess.tabs[0].screen.Write([]byte("live")) | |
| 606 | + | ||
| 607 | + | d.enterCopyMode(sess, ac) | |
| 608 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 609 | + | d.handleInput(sess, ac, []byte("\x1b")) | |
| 610 | + | timer := <-clk.timers | |
| 611 | + | d.enterCopyMode(sess, ac) | |
| 612 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 613 | + | ||
| 614 | + | timer.ch <- time.Now() | |
| 615 | + | require.Never(t, func() bool { | |
| 616 | + | ac.copyMu.Lock() | |
| 617 | + | defer ac.copyMu.Unlock() | |
| 618 | + | return ac.copyMode == nil | |
| 619 | + | }, 50*time.Millisecond, 5*time.Millisecond) | |
| 620 | + | ac.copyMu.Lock() | |
| 621 | + | require.Nil(t, ac.copyESC.timer) | |
| 622 | + | require.Nil(t, ac.copyESC.done) | |
| 623 | + | require.Empty(t, ac.copyPending) | |
| 624 | + | ac.copyMu.Unlock() | |
| 625 | + | } | |
| 626 | + | ||
| 539 | 627 | func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) { | |
| 540 | 628 | cases := []struct { | |
| 541 | 629 | name string | |
| @@ -676,6 +764,26 @@ func TestHandshakeVersionMismatch(t *testing.T) { | |||
| 676 | 764 | require.Equal(t, 0, sessionCount(d)) | |
| 677 | 765 | } | |
| 678 | 766 | ||
| 767 | + | func TestHandshakeOldHelloLayoutReportsVersionMismatch(t *testing.T) { | |
| 768 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 769 | + | oldLayout := []byte{ | |
| 770 | + | 0x00, byte(ports.ProtocolVersion - 1), | |
| 771 | + | ports.IntentEphemeral, | |
| 772 | + | 0x00, 0x00, | |
| 773 | + | 0x00, 0x50, | |
| 774 | + | 0x00, 0x18, | |
| 775 | + | 0x00, 0x00, | |
| 776 | + | } | |
| 777 | + | tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: oldLayout}) | |
| 778 | + | d.handleConn(tr) | |
| 779 | + | ||
| 780 | + | e := awaitFrame(t, sends, ports.MsgError) | |
| 781 | + | em, err := ports.UnmarshalErrorMsg(e.Payload) | |
| 782 | + | require.NoError(t, err) | |
| 783 | + | require.Equal(t, ports.ErrVersionMismatch, em.Code) | |
| 784 | + | require.Equal(t, 0, sessionCount(d)) | |
| 785 | + | } | |
| 786 | + | ||
| 679 | 787 | func TestHandshakeNameTaken(t *testing.T) { | |
| 680 | 788 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 681 | 789 | // Pre-populate a bare session (no goroutines) to collide with. | |
| @@ -1042,9 +1150,60 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | |||
| 1042 | 1150 | awaitFrame(t, sends, ports.MsgOutput) | |
| 1043 | 1151 | d.handleInput(sess, ac, []byte("\x1bt")) | |
| 1044 | 1152 | awaitFrame(t, sends, ports.MsgOutput) | |
| 1153 | + | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 1154 | + | d.clock = clk | |
| 1045 | 1155 | d.handleInput(sess, ac, []byte("\x1b")) | |
| 1046 | - | require.False(t, ac.pickerActive()) | |
| 1156 | + | timer := <-clk.timers | |
| 1157 | + | require.True(t, ac.pickerActive()) | |
| 1158 | + | timer.ch <- time.Now() | |
| 1159 | + | require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond) | |
| 1160 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 1161 | + | } | |
| 1162 | + | ||
| 1163 | + | func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) { | |
| 1164 | + | cases := []struct { | |
| 1165 | + | name string | |
| 1166 | + | input [][]byte | |
| 1167 | + | wantActive int | |
| 1168 | + | }{ | |
| 1169 | + | {name: "escape then down arrow", input: [][]byte{[]byte("\x1b"), []byte("[B")}, wantActive: 1}, | |
| 1170 | + | {name: "escape then up arrow", input: [][]byte{[]byte("j"), []byte("\x1b"), []byte("[A")}, wantActive: 0}, | |
| 1171 | + | {name: "split down arrow", input: [][]byte{[]byte("\x1b["), []byte("B")}, wantActive: 1}, | |
| 1172 | + | } | |
| 1173 | + | for _, tc := range cases { | |
| 1174 | + | t.Run(tc.name, func(t *testing.T) { | |
| 1175 | + | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |
| 1176 | + | defer func() { | |
| 1177 | + | for _, release := range releases { | |
| 1178 | + | release() | |
| 1179 | + | } | |
| 1180 | + | }() | |
| 1181 | + | ||
| 1182 | + | d.handleInput(sess, ac, []byte("\x1bt")) | |
| 1183 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 1184 | + | for _, input := range tc.input { | |
| 1185 | + | d.handleInput(sess, ac, input) | |
| 1186 | + | } | |
| 1187 | + | require.True(t, ac.pickerActive()) | |
| 1188 | + | d.handleInput(sess, ac, []byte("\r")) | |
| 1189 | + | require.Equal(t, tc.wantActive, activeTabIndex(sess)) | |
| 1190 | + | }) | |
| 1191 | + | } | |
| 1192 | + | } | |
| 1193 | + | ||
| 1194 | + | func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) { | |
| 1195 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 1196 | + | defer releases[0]() | |
| 1197 | + | ||
| 1198 | + | d.handleInput(sess, ac, []byte("\x1bt")) | |
| 1047 | 1199 | awaitFrame(t, sends, ports.MsgOutput) | |
| 1200 | + | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 1201 | + | d.clock = clk | |
| 1202 | + | d.handleInput(sess, ac, []byte("\x1b")) | |
| 1203 | + | timer := <-clk.timers | |
| 1204 | + | require.True(t, ac.pickerActive()) | |
| 1205 | + | timer.ch <- time.Now() | |
| 1206 | + | require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond) | |
| 1048 | 1207 | } | |
| 1049 | 1208 | ||
| 1050 | 1209 | func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |
| @@ -1408,6 +1567,7 @@ func TestSchedulerDebounceCoalesces(t *testing.T) { | |||
| 1408 | 1567 | var outputs atomic.Int32 | |
| 1409 | 1568 | gotOutput := make(chan struct{}, 1) | |
| 1410 | 1569 | tr := portsmocks.NewMockTransport(t) | |
| 1570 | + | tr.EXPECT().Close().Return(nil).Maybe() | |
| 1411 | 1571 | tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error { | |
| 1412 | 1572 | if f.Type == ports.MsgOutput { | |
| 1413 | 1573 | outputs.Add(1) | |
| @@ -1484,6 +1644,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||
| 1484 | 1644 | ||
| 1485 | 1645 | var gotOutput atomic.Bool | |
| 1486 | 1646 | tr := portsmocks.NewMockTransport(t) | |
| 1647 | + | tr.EXPECT().Close().Return(nil).Maybe() | |
| 1487 | 1648 | tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error { | |
| 1488 | 1649 | if f.Type == ports.MsgOutput { | |
| 1489 | 1650 | gotOutput.Store(true) | |
| @@ -1635,7 +1796,7 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) { | |||
| 1635 | 1796 | f := portsmocks.NewMockPTYFactory(t) | |
| 1636 | 1797 | // Exactly one child may ever be spawned; a second Open call (a leaked | |
| 1637 | 1798 | // child) fails the test via mock expectations. | |
| 1638 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once() | |
| 1799 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once() | |
| 1639 | 1800 | d := newTestDaemon(t, f, stubClock{}) | |
| 1640 | 1801 | ||
| 1641 | 1802 | tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24})) | |
| @@ -1814,6 +1975,7 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) | |||
| 1814 | 1975 | var outputs atomic.Int32 | |
| 1815 | 1976 | gotOutput := make(chan struct{}, 1) | |
| 1816 | 1977 | tr := portsmocks.NewMockTransport(t) | |
| 1978 | + | tr.EXPECT().Close().Return(nil).Maybe() | |
| 1817 | 1979 | tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error { | |
| 1818 | 1980 | if f.Type == ports.MsgOutput { | |
| 1819 | 1981 | outputs.Add(1) | |
| @@ -114,7 +114,7 @@ func newBlockingPTYWithWrites(t *testing.T, writes chan<- []byte) (*portsmocks.M | |||||
| 114 | func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory { | 114 | func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory { | ||
| 115 | t.Helper() | 115 | t.Helper() | ||
| 116 | f := portsmocks.NewMockPTYFactory(t) | 116 | f := portsmocks.NewMockPTYFactory(t) | ||
| 117 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Maybe() | 117 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Maybe() |
| 118 | return f | 118 | return f | ||
| 119 | } | 119 | } | ||
| 120 | 120 | ||||
| @@ -123,8 +123,8 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory { | |||||
| 123 | f := portsmocks.NewMockPTYFactory(t) | 123 | f := portsmocks.NewMockPTYFactory(t) | ||
| 124 | var mu sync.Mutex | 124 | var mu sync.Mutex | ||
| 125 | next := 0 | 125 | next := 0 | ||
| 126 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | 126 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( |
| 127 | - | func(string, []string, []string, domain.Size) (ports.PTY, error) { | 127 | + | func(string, []string, []string, string, domain.Size) (ports.PTY, error) { |
| 128 | mu.Lock() | 128 | mu.Lock() | ||
| 129 | defer mu.Unlock() | 129 | defer mu.Unlock() | ||
| 130 | if next >= len(ptys) { | 130 | if next >= len(ptys) { | ||
| @@ -176,6 +176,45 @@ func mustHello(intent uint8, name string, sz domain.Size) ports.Frame { | |||||
| 176 | })} | 176 | })} | ||
| 177 | } | 177 | } | ||
| 178 | 178 | ||||
| 179 | + | func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) { | |||
| 180 | + | sz := domain.Size{Cols: 80, Rows: 24} | |||
| 181 | + | first, releaseFirst := newBlockingPTY(t) | |||
| 182 | + | defer releaseFirst() | |||
| 183 | + | second, releaseSecond := newBlockingPTY(t) | |||
| 184 | + | defer releaseSecond() | |||
| 185 | + | f := portsmocks.NewMockPTYFactory(t) | |||
| 186 | + | var dirs []string | |||
| 187 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 188 | + | func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) { | |||
| 189 | + | dirs = append(dirs, dir) | |||
| 190 | + | if len(dirs) == 1 { | |||
| 191 | + | return first, nil | |||
| 192 | + | } | |||
| 193 | + | return second, nil | |||
| 194 | + | }, | |||
| 195 | + | ).Twice() | |||
| 196 | + | ||||
| 197 | + | d := newTestDaemon(t, f, stubClock{}) | |||
| 198 | + | tr := portsmocks.NewMockTransport(t) | |||
| 199 | + | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |||
| 200 | + | tr.EXPECT().Close().Return(nil).Maybe() | |||
| 201 | + | ||||
| 202 | + | sess, ac, err := d.route(ports.Hello{ | |||
| 203 | + | Version: ports.ProtocolVersion, | |||
| 204 | + | Intent: ports.IntentNew, | |||
| 205 | + | Name: "work", | |||
| 206 | + | Size: sz, | |||
| 207 | + | TermEnv: "xterm-256color", | |||
| 208 | + | Cwd: "/tmp/work", | |||
| 209 | + | }, tr) | |||
| 210 | + | require.NoError(t, err) | |||
| 211 | + | require.NotNil(t, ac) | |||
| 212 | + | require.Equal(t, "/tmp/work", sess.cwd) | |||
| 213 | + | ||||
| 214 | + | require.NoError(t, d.createTab(sess, sz)) | |||
| 215 | + | require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs) | |||
| 216 | + | } | |||
| 217 | + | ||||
| 179 | func frameInput(data []byte) ports.Frame { | 218 | func frameInput(data []byte) ports.Frame { | ||
| 180 | return ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})} | 219 | return ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})} | ||
| 181 | } | 220 | } | ||
| @@ -481,14 +520,14 @@ func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) { | |||||
| 481 | 520 | ||||
| 482 | func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | 521 | func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | ||
| 483 | cases := []struct { | 522 | cases := []struct { | ||
| 484 | - | name string | 523 | + | name string |
| 485 | - | firstInput []byte | 524 | + | input [][]byte |
| 486 | - | secondInput []byte | 525 | + | wantCursor int |
| 487 | - | wantActive bool | |||
| 488 | }{ | 526 | }{ | ||
| 489 | - | {name: "split up arrow", firstInput: []byte("\x1b["), secondInput: []byte("A"), wantActive: true}, | 527 | + | {name: "escape then up arrow", input: [][]byte{[]byte("\x1b"), []byte("[A")}, wantCursor: 23}, |
| 490 | - | {name: "split page down", firstInput: []byte("\x1b[6"), secondInput: []byte("~"), wantActive: true}, | 528 | + | {name: "escape then down arrow", input: [][]byte{[]byte("g"), []byte("\x1b"), []byte("[B")}, wantCursor: 1}, |
| 491 | - | {name: "lone escape exits", firstInput: []byte("\x1b"), wantActive: false}, | 529 | + | {name: "split up arrow", input: [][]byte{[]byte("\x1b["), []byte("A")}, wantCursor: 23}, |
| 530 | + | {name: "split page down", input: [][]byte{[]byte("g"), []byte("\x1b[6"), []byte("~")}, wantCursor: 23}, | |||
| 492 | } | 531 | } | ||
| 493 | for _, tc := range cases { | 532 | for _, tc := range cases { | ||
| 494 | t.Run(tc.name, func(t *testing.T) { | 533 | t.Run(tc.name, func(t *testing.T) { | ||
| @@ -501,14 +540,12 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) { | |||||
| 501 | 540 | ||||
| 502 | d.enterCopyMode(sess, ac) | 541 | d.enterCopyMode(sess, ac) | ||
| 503 | awaitFrame(t, sends, ports.MsgOutput) | 542 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 504 | - | d.handleInput(sess, ac, tc.firstInput) | 543 | + | for _, input := range tc.input { |
| 505 | - | if tc.secondInput != nil { | 544 | + | d.handleInput(sess, ac, input) |
| 506 | - | d.handleInput(sess, ac, tc.secondInput) | |||
| 507 | } | 545 | } | ||
| 508 | 546 | ||||
| 509 | - | if gotActive := ac.copyMode != nil; gotActive != tc.wantActive { | 547 | + | require.NotNil(t, ac.copyMode) |
| 510 | - | t.Fatalf("copy mode active = %v, want %v", gotActive, tc.wantActive) | 548 | + | require.Equal(t, tc.wantCursor, ac.copyMode.Cursor) |
| 511 | - | } | |||
| 512 | }) | 549 | }) | ||
| 513 | } | 550 | } | ||
| 514 | } | 551 | } | ||
| @@ -536,6 +573,57 @@ func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) { | |||||
| 536 | } | 573 | } | ||
| 537 | } | 574 | } | ||
| 538 | 575 | ||||
| 576 | + | func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) { | |||
| 577 | + | p, _ := newBlockingPTY(t) | |||
| 578 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 579 | + | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |||
| 580 | + | d.clock = clk | |||
| 581 | + | sess.tabs[0].scrollback = scopy.NewScrollback(4) | |||
| 582 | + | sess.tabs[0].screen.Write([]byte("live")) | |||
| 583 | + | ||||
| 584 | + | d.enterCopyMode(sess, ac) | |||
| 585 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 586 | + | d.handleInput(sess, ac, []byte("\x1b")) | |||
| 587 | + | timer := <-clk.timers | |||
| 588 | + | ac.copyMu.Lock() | |||
| 589 | + | require.NotNil(t, ac.copyMode) | |||
| 590 | + | ac.copyMu.Unlock() | |||
| 591 | + | timer.ch <- time.Now() | |||
| 592 | + | require.Eventually(t, func() bool { | |||
| 593 | + | ac.copyMu.Lock() | |||
| 594 | + | defer ac.copyMu.Unlock() | |||
| 595 | + | return ac.copyMode == nil | |||
| 596 | + | }, time.Second, 5*time.Millisecond) | |||
| 597 | + | } | |||
| 598 | + | ||||
| 599 | + | func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) { | |||
| 600 | + | p, _ := newBlockingPTY(t) | |||
| 601 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 602 | + | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |||
| 603 | + | d.clock = clk | |||
| 604 | + | sess.tabs[0].scrollback = scopy.NewScrollback(4) | |||
| 605 | + | sess.tabs[0].screen.Write([]byte("live")) | |||
| 606 | + | ||||
| 607 | + | d.enterCopyMode(sess, ac) | |||
| 608 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 609 | + | d.handleInput(sess, ac, []byte("\x1b")) | |||
| 610 | + | timer := <-clk.timers | |||
| 611 | + | d.enterCopyMode(sess, ac) | |||
| 612 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 613 | + | ||||
| 614 | + | timer.ch <- time.Now() | |||
| 615 | + | require.Never(t, func() bool { | |||
| 616 | + | ac.copyMu.Lock() | |||
| 617 | + | defer ac.copyMu.Unlock() | |||
| 618 | + | return ac.copyMode == nil | |||
| 619 | + | }, 50*time.Millisecond, 5*time.Millisecond) | |||
| 620 | + | ac.copyMu.Lock() | |||
| 621 | + | require.Nil(t, ac.copyESC.timer) | |||
| 622 | + | require.Nil(t, ac.copyESC.done) | |||
| 623 | + | require.Empty(t, ac.copyPending) | |||
| 624 | + | ac.copyMu.Unlock() | |||
| 625 | + | } | |||
| 626 | + | ||||
| 539 | func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) { | 627 | func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) { | ||
| 540 | cases := []struct { | 628 | cases := []struct { | ||
| 541 | name string | 629 | name string | ||
| @@ -676,6 +764,26 @@ func TestHandshakeVersionMismatch(t *testing.T) { | |||||
| 676 | require.Equal(t, 0, sessionCount(d)) | 764 | require.Equal(t, 0, sessionCount(d)) | ||
| 677 | } | 765 | } | ||
| 678 | 766 | ||||
| 767 | + | func TestHandshakeOldHelloLayoutReportsVersionMismatch(t *testing.T) { | |||
| 768 | + | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |||
| 769 | + | oldLayout := []byte{ | |||
| 770 | + | 0x00, byte(ports.ProtocolVersion - 1), | |||
| 771 | + | ports.IntentEphemeral, | |||
| 772 | + | 0x00, 0x00, | |||
| 773 | + | 0x00, 0x50, | |||
| 774 | + | 0x00, 0x18, | |||
| 775 | + | 0x00, 0x00, | |||
| 776 | + | } | |||
| 777 | + | tr, sends, _ := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: oldLayout}) | |||
| 778 | + | d.handleConn(tr) | |||
| 779 | + | ||||
| 780 | + | e := awaitFrame(t, sends, ports.MsgError) | |||
| 781 | + | em, err := ports.UnmarshalErrorMsg(e.Payload) | |||
| 782 | + | require.NoError(t, err) | |||
| 783 | + | require.Equal(t, ports.ErrVersionMismatch, em.Code) | |||
| 784 | + | require.Equal(t, 0, sessionCount(d)) | |||
| 785 | + | } | |||
| 786 | + | ||||
| 679 | func TestHandshakeNameTaken(t *testing.T) { | 787 | func TestHandshakeNameTaken(t *testing.T) { | ||
| 680 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 788 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
| 681 | // Pre-populate a bare session (no goroutines) to collide with. | 789 | // Pre-populate a bare session (no goroutines) to collide with. | ||
| @@ -1042,9 +1150,60 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) { | |||||
| 1042 | awaitFrame(t, sends, ports.MsgOutput) | 1150 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 1043 | d.handleInput(sess, ac, []byte("\x1bt")) | 1151 | d.handleInput(sess, ac, []byte("\x1bt")) | ||
| 1044 | awaitFrame(t, sends, ports.MsgOutput) | 1152 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 1153 | + | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |||
| 1154 | + | d.clock = clk | |||
| 1045 | d.handleInput(sess, ac, []byte("\x1b")) | 1155 | d.handleInput(sess, ac, []byte("\x1b")) | ||
| 1046 | - | require.False(t, ac.pickerActive()) | 1156 | + | timer := <-clk.timers |
| 1157 | + | require.True(t, ac.pickerActive()) | |||
| 1158 | + | timer.ch <- time.Now() | |||
| 1159 | + | require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond) | |||
| 1160 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1161 | + | } | |||
| 1162 | + | ||||
| 1163 | + | func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) { | |||
| 1164 | + | cases := []struct { | |||
| 1165 | + | name string | |||
| 1166 | + | input [][]byte | |||
| 1167 | + | wantActive int | |||
| 1168 | + | }{ | |||
| 1169 | + | {name: "escape then down arrow", input: [][]byte{[]byte("\x1b"), []byte("[B")}, wantActive: 1}, | |||
| 1170 | + | {name: "escape then up arrow", input: [][]byte{[]byte("j"), []byte("\x1b"), []byte("[A")}, wantActive: 0}, | |||
| 1171 | + | {name: "split down arrow", input: [][]byte{[]byte("\x1b["), []byte("B")}, wantActive: 1}, | |||
| 1172 | + | } | |||
| 1173 | + | for _, tc := range cases { | |||
| 1174 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 1175 | + | d, sess, ac, sends, releases := newManualTabSession(t, 2) | |||
| 1176 | + | defer func() { | |||
| 1177 | + | for _, release := range releases { | |||
| 1178 | + | release() | |||
| 1179 | + | } | |||
| 1180 | + | }() | |||
| 1181 | + | ||||
| 1182 | + | d.handleInput(sess, ac, []byte("\x1bt")) | |||
| 1183 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1184 | + | for _, input := range tc.input { | |||
| 1185 | + | d.handleInput(sess, ac, input) | |||
| 1186 | + | } | |||
| 1187 | + | require.True(t, ac.pickerActive()) | |||
| 1188 | + | d.handleInput(sess, ac, []byte("\r")) | |||
| 1189 | + | require.Equal(t, tc.wantActive, activeTabIndex(sess)) | |||
| 1190 | + | }) | |||
| 1191 | + | } | |||
| 1192 | + | } | |||
| 1193 | + | ||||
| 1194 | + | func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) { | |||
| 1195 | + | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |||
| 1196 | + | defer releases[0]() | |||
| 1197 | + | ||||
| 1198 | + | d.handleInput(sess, ac, []byte("\x1bt")) | |||
| 1047 | awaitFrame(t, sends, ports.MsgOutput) | 1199 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 1200 | + | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |||
| 1201 | + | d.clock = clk | |||
| 1202 | + | d.handleInput(sess, ac, []byte("\x1b")) | |||
| 1203 | + | timer := <-clk.timers | |||
| 1204 | + | require.True(t, ac.pickerActive()) | |||
| 1205 | + | timer.ch <- time.Now() | |||
| 1206 | + | require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond) | |||
| 1048 | } | 1207 | } | ||
| 1049 | 1208 | ||||
| 1050 | func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | 1209 | func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | ||
| @@ -1408,6 +1567,7 @@ func TestSchedulerDebounceCoalesces(t *testing.T) { | |||||
| 1408 | var outputs atomic.Int32 | 1567 | var outputs atomic.Int32 | ||
| 1409 | gotOutput := make(chan struct{}, 1) | 1568 | gotOutput := make(chan struct{}, 1) | ||
| 1410 | tr := portsmocks.NewMockTransport(t) | 1569 | tr := portsmocks.NewMockTransport(t) | ||
| 1570 | + | tr.EXPECT().Close().Return(nil).Maybe() | |||
| 1411 | tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error { | 1571 | tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error { | ||
| 1412 | if f.Type == ports.MsgOutput { | 1572 | if f.Type == ports.MsgOutput { | ||
| 1413 | outputs.Add(1) | 1573 | outputs.Add(1) | ||
| @@ -1484,6 +1644,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||||
| 1484 | 1644 | ||||
| 1485 | var gotOutput atomic.Bool | 1645 | var gotOutput atomic.Bool | ||
| 1486 | tr := portsmocks.NewMockTransport(t) | 1646 | tr := portsmocks.NewMockTransport(t) | ||
| 1647 | + | tr.EXPECT().Close().Return(nil).Maybe() | |||
| 1487 | tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error { | 1648 | tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error { | ||
| 1488 | if f.Type == ports.MsgOutput { | 1649 | if f.Type == ports.MsgOutput { | ||
| 1489 | gotOutput.Store(true) | 1650 | gotOutput.Store(true) | ||
| @@ -1635,7 +1796,7 @@ func TestHelloRacingShutdownIsRejected(t *testing.T) { | |||||
| 1635 | f := portsmocks.NewMockPTYFactory(t) | 1796 | f := portsmocks.NewMockPTYFactory(t) | ||
| 1636 | // Exactly one child may ever be spawned; a second Open call (a leaked | 1797 | // Exactly one child may ever be spawned; a second Open call (a leaked | ||
| 1637 | // child) fails the test via mock expectations. | 1798 | // child) fails the test via mock expectations. | ||
| 1638 | - | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once() | 1799 | + | f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Once() |
| 1639 | d := newTestDaemon(t, f, stubClock{}) | 1800 | d := newTestDaemon(t, f, stubClock{}) | ||
| 1640 | 1801 | ||||
| 1641 | tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24})) | 1802 | tr1, sends1, release1 := newConn(t, mustHello(ports.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24})) | ||
| @@ -1814,6 +1975,7 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) | |||||
| 1814 | var outputs atomic.Int32 | 1975 | var outputs atomic.Int32 | ||
| 1815 | gotOutput := make(chan struct{}, 1) | 1976 | gotOutput := make(chan struct{}, 1) | ||
| 1816 | tr := portsmocks.NewMockTransport(t) | 1977 | tr := portsmocks.NewMockTransport(t) | ||
| 1978 | + | tr.EXPECT().Close().Return(nil).Maybe() | |||
| 1817 | tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error { | 1979 | tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error { | ||
| 1818 | if f.Type == ports.MsgOutput { | 1980 | if f.Type == ports.MsgOutput { | ||
| 1819 | outputs.Add(1) | 1981 | outputs.Add(1) | ||
M internal/usecase/ui/modal.go +2 -41
| @@ -117,7 +117,7 @@ func DrawText(f renderer.Frame, x, y, clipX int, text string, style renderer.Sty | |||
| 117 | 117 | clipX = clamp(clipX, 0, f.Width) | |
| 118 | 118 | pos := x | |
| 119 | 119 | for _, r := range text { | |
| 120 | - | w := runeWidth(r) | |
| 120 | + | w := renderer.RuneWidth(r) | |
| 121 | 121 | if w == 0 { | |
| 122 | 122 | continue | |
| 123 | 123 | } | |
| @@ -165,46 +165,7 @@ func clamp(n, low, high int) int { | |||
| 165 | 165 | func textWidth(text string) int { | |
| 166 | 166 | width := 0 | |
| 167 | 167 | for _, r := range text { | |
| 168 | - | width += runeWidth(r) | |
| 168 | + | width += renderer.RuneWidth(r) | |
| 169 | 169 | } | |
| 170 | 170 | return width | |
| 171 | 171 | } | |
| 172 | - | ||
| 173 | - | func runeWidth(r rune) int { | |
| 174 | - | switch { | |
| 175 | - | case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F): | |
| 176 | - | return 0 | |
| 177 | - | case r >= 0x1100 && r <= 0x115F: | |
| 178 | - | return 2 | |
| 179 | - | case r == 0x2329 || r == 0x232A: | |
| 180 | - | return 2 | |
| 181 | - | case r >= 0x2E80 && r <= 0xA4CF: | |
| 182 | - | return 2 | |
| 183 | - | case r >= 0xAC00 && r <= 0xD7FF: | |
| 184 | - | return 2 | |
| 185 | - | case r >= 0xF900 && r <= 0xFAFF: | |
| 186 | - | return 2 | |
| 187 | - | case r >= 0xFE10 && r <= 0xFE19: | |
| 188 | - | return 2 | |
| 189 | - | case r >= 0xFE30 && r <= 0xFE6F: | |
| 190 | - | return 2 | |
| 191 | - | case r >= 0xFF01 && r <= 0xFF60: | |
| 192 | - | return 2 | |
| 193 | - | case r >= 0xFFE0 && r <= 0xFFE6: | |
| 194 | - | return 2 | |
| 195 | - | case r >= 0x1B000 && r <= 0x1B12F: | |
| 196 | - | return 2 | |
| 197 | - | case r >= 0x1F000 && r <= 0x1F64F: | |
| 198 | - | return 2 | |
| 199 | - | case r >= 0x1F680 && r <= 0x1F6FF: | |
| 200 | - | return 2 | |
| 201 | - | case r >= 0x1F900 && r <= 0x1F9FF: | |
| 202 | - | return 2 | |
| 203 | - | case r >= 0x1FA70 && r <= 0x1FAFF: | |
| 204 | - | return 2 | |
| 205 | - | case r >= 0x20000 && r <= 0x3FFFF: | |
| 206 | - | return 2 | |
| 207 | - | default: | |
| 208 | - | return 1 | |
| 209 | - | } | |
| 210 | - | } | |
| @@ -117,7 +117,7 @@ func DrawText(f renderer.Frame, x, y, clipX int, text string, style renderer.Sty | |||||
| 117 | clipX = clamp(clipX, 0, f.Width) | 117 | clipX = clamp(clipX, 0, f.Width) | ||
| 118 | pos := x | 118 | pos := x | ||
| 119 | for _, r := range text { | 119 | for _, r := range text { | ||
| 120 | - | w := runeWidth(r) | 120 | + | w := renderer.RuneWidth(r) |
| 121 | if w == 0 { | 121 | if w == 0 { | ||
| 122 | continue | 122 | continue | ||
| 123 | } | 123 | } | ||
| @@ -165,46 +165,7 @@ func clamp(n, low, high int) int { | |||||
| 165 | func textWidth(text string) int { | 165 | func textWidth(text string) int { | ||
| 166 | width := 0 | 166 | width := 0 | ||
| 167 | for _, r := range text { | 167 | for _, r := range text { | ||
| 168 | - | width += runeWidth(r) | 168 | + | width += renderer.RuneWidth(r) |
| 169 | } | 169 | } | ||
| 170 | return width | 170 | return width | ||
| 171 | } | 171 | } | ||
| 172 | - | ||||
| 173 | - | func runeWidth(r rune) int { | |||
| 174 | - | switch { | |||
| 175 | - | case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F): | |||
| 176 | - | return 0 | |||
| 177 | - | case r >= 0x1100 && r <= 0x115F: | |||
| 178 | - | return 2 | |||
| 179 | - | case r == 0x2329 || r == 0x232A: | |||
| 180 | - | return 2 | |||
| 181 | - | case r >= 0x2E80 && r <= 0xA4CF: | |||
| 182 | - | return 2 | |||
| 183 | - | case r >= 0xAC00 && r <= 0xD7FF: | |||
| 184 | - | return 2 | |||
| 185 | - | case r >= 0xF900 && r <= 0xFAFF: | |||
| 186 | - | return 2 | |||
| 187 | - | case r >= 0xFE10 && r <= 0xFE19: | |||
| 188 | - | return 2 | |||
| 189 | - | case r >= 0xFE30 && r <= 0xFE6F: | |||
| 190 | - | return 2 | |||
| 191 | - | case r >= 0xFF01 && r <= 0xFF60: | |||
| 192 | - | return 2 | |||
| 193 | - | case r >= 0xFFE0 && r <= 0xFFE6: | |||
| 194 | - | return 2 | |||
| 195 | - | case r >= 0x1B000 && r <= 0x1B12F: | |||
| 196 | - | return 2 | |||
| 197 | - | case r >= 0x1F000 && r <= 0x1F64F: | |||
| 198 | - | return 2 | |||
| 199 | - | case r >= 0x1F680 && r <= 0x1F6FF: | |||
| 200 | - | return 2 | |||
| 201 | - | case r >= 0x1F900 && r <= 0x1F9FF: | |||
| 202 | - | return 2 | |||
| 203 | - | case r >= 0x1FA70 && r <= 0x1FAFF: | |||
| 204 | - | return 2 | |||
| 205 | - | case r >= 0x20000 && r <= 0x3FFFF: | |||
| 206 | - | return 2 | |||
| 207 | - | default: | |||
| 208 | - | return 1 | |||
| 209 | - | } | |||
| 210 | - | } | |||
A pkg/renderer/rune_width.go +120 -0
| @@ -0,0 +1,120 @@ | |||
| 1 | + | package renderer | |
| 2 | + | ||
| 3 | + | import "unicode" | |
| 4 | + | ||
| 5 | + | // RuneWidth returns the terminal cell width of a rune. | |
| 6 | + | // | |
| 7 | + | // Returns 0 for combining marks, zero-width characters, and control codes. | |
| 8 | + | // Returns 2 for CJK, fullwidth, and wide emoji characters. | |
| 9 | + | // Returns 1 for all other printable characters. | |
| 10 | + | // | |
| 11 | + | // Wide runes (width 2) occupy two grid cells: a left cell holding the rune and | |
| 12 | + | // a right continuation cell. Combining marks (Mn/Me) and zero-width joiner | |
| 13 | + | // sequences are out of scope: combining marks report width 0 (current | |
| 14 | + | // behavior), ZWJ sequences are not coalesced (each rune is measured on its own). | |
| 15 | + | func RuneWidth(r rune) int { | |
| 16 | + | switch { | |
| 17 | + | case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F): | |
| 18 | + | return 0 | |
| 19 | + | // Soft hyphen, combining grapheme joiner, Arabic sign, etc. | |
| 20 | + | case r == 0x00AD || r == 0x034F || r == 0x061C: | |
| 21 | + | return 0 | |
| 22 | + | // Hangul Jungseong O/E, Hangul Jungseong Yu | |
| 23 | + | case r == 0x115F || r == 0x1160: | |
| 24 | + | return 0 | |
| 25 | + | // Balinese musical symbols | |
| 26 | + | case r == 0x17B4 || r == 0x17B5: | |
| 27 | + | return 0 | |
| 28 | + | // Mongolian variation selectors, free variation selector | |
| 29 | + | case r == 0x180B || r == 0x180E || r == 0x200B || r == 0x200C || r == 0x200D || r == 0x200E || r == 0x200F: | |
| 30 | + | return 0 | |
| 31 | + | // Line/paragraph separator, bidirectional overrides | |
| 32 | + | case r >= 0x2028 && r <= 0x202E: | |
| 33 | + | return 0 | |
| 34 | + | // Word joiner, invisible operators, Arabic/Syriac number marks, etc. | |
| 35 | + | case r >= 0x2060 && r <= 0x206F: | |
| 36 | + | return 0 | |
| 37 | + | case r == 0xFEFF || r == 0xFFF9 || r == 0xFFFA || r == 0xFFFB: | |
| 38 | + | return 0 | |
| 39 | + | // Tags | |
| 40 | + | case r >= 0xE0001 && r <= 0xE007F: | |
| 41 | + | return 0 | |
| 42 | + | // Combining diacritical marks (Unicode categories Mn, Me) | |
| 43 | + | case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r): | |
| 44 | + | return 0 | |
| 45 | + | // --- Wide characters (width 2) --- | |
| 46 | + | // Hangul Jamo | |
| 47 | + | case r >= 0x1100 && r <= 0x115F: | |
| 48 | + | return 2 | |
| 49 | + | // Left/right-pointing angle bracket (for compatibility) | |
| 50 | + | case r == 0x2329 || r == 0x232A: | |
| 51 | + | return 2 | |
| 52 | + | // CJK Radicals Supplement, Kangxi Radicals, Ideographic Description | |
| 53 | + | // CJK Symbols, Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo | |
| 54 | + | // Kanbun, CJK Strokes, Enclosed CJK Letters | |
| 55 | + | case r >= 0x2E80 && r <= 0x303E: | |
| 56 | + | return 2 | |
| 57 | + | // Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo, Kanbun | |
| 58 | + | case r >= 0x3040 && r <= 0x33FF: | |
| 59 | + | return 2 | |
| 60 | + | // CJK Unified Ideographs Extension A | |
| 61 | + | case r >= 0x3400 && r <= 0x4DBF: | |
| 62 | + | return 2 | |
| 63 | + | // CJK Unified Ideographs, Yi Syllables, Yi Radicals | |
| 64 | + | case r >= 0x4E00 && r <= 0xA4CF: | |
| 65 | + | return 2 | |
| 66 | + | // Hangul Jamo Extended-A | |
| 67 | + | case r >= 0xA960 && r <= 0xA97C: | |
| 68 | + | return 2 | |
| 69 | + | // Hangul Syllables | |
| 70 | + | case r >= 0xAC00 && r <= 0xD7AF: | |
| 71 | + | return 2 | |
| 72 | + | // Hangul Jamo Extended-B | |
| 73 | + | case r >= 0xD7B0 && r <= 0xD7FF: | |
| 74 | + | return 2 | |
| 75 | + | // CJK Compatibility Ideographs | |
| 76 | + | case r >= 0xF900 && r <= 0xFAFF: | |
| 77 | + | return 2 | |
| 78 | + | // Vertical Forms | |
| 79 | + | case r >= 0xFE10 && r <= 0xFE19: | |
| 80 | + | return 2 | |
| 81 | + | // CJK Compatibility Forms | |
| 82 | + | case r >= 0xFE30 && r <= 0xFE6F: | |
| 83 | + | return 2 | |
| 84 | + | // Fullwidth Forms (variation selectors, fullwidth punctuation, etc.) | |
| 85 | + | case r >= 0xFF01 && r <= 0xFF60: | |
| 86 | + | return 2 | |
| 87 | + | // Fullwidth Signs (cent, pound, yen, won, etc.) | |
| 88 | + | case r >= 0xFFE0 && r <= 0xFFE6: | |
| 89 | + | return 2 | |
| 90 | + | // Kana Supplement | |
| 91 | + | case r >= 0x1B000 && r <= 0x1B0FF: | |
| 92 | + | return 2 | |
| 93 | + | // Kana Extended-A | |
| 94 | + | case r >= 0x1B100 && r <= 0x1B12F: | |
| 95 | + | return 2 | |
| 96 | + | // Mahjong Tiles, Domino Tiles, Playing Cards | |
| 97 | + | case r >= 0x1F000 && r <= 0x1F09F: | |
| 98 | + | return 2 | |
| 99 | + | // Miscellaneous Symbols and Pictographs, Emoticons, Ornamental Dingbats | |
| 100 | + | case r >= 0x1F0A0 && r <= 0x1F64F: | |
| 101 | + | return 2 | |
| 102 | + | // Transport and Map Symbols | |
| 103 | + | case r >= 0x1F680 && r <= 0x1F6FF: | |
| 104 | + | return 2 | |
| 105 | + | // Supplemental Symbols and Pictographs | |
| 106 | + | case r >= 0x1F900 && r <= 0x1F9FF: | |
| 107 | + | return 2 | |
| 108 | + | // Symbols and Pictographs Extended-A (emoji added in recent Unicode) | |
| 109 | + | case r >= 0x1FA70 && r <= 0x1FAFF: | |
| 110 | + | return 2 | |
| 111 | + | // CJK Unified Ideographs Extension B and beyond | |
| 112 | + | case r >= 0x20000 && r <= 0x2FFFF: | |
| 113 | + | return 2 | |
| 114 | + | // CJK Unified Ideographs Extension G and beyond | |
| 115 | + | case r >= 0x30000 && r <= 0x3FFFF: | |
| 116 | + | return 2 | |
| 117 | + | default: | |
| 118 | + | return 1 | |
| 119 | + | } | |
| 120 | + | } | |
| @@ -0,0 +1,120 @@ | |||||
| 1 | + | package renderer | |||
| 2 | + | ||||
| 3 | + | import "unicode" | |||
| 4 | + | ||||
| 5 | + | // RuneWidth returns the terminal cell width of a rune. | |||
| 6 | + | // | |||
| 7 | + | // Returns 0 for combining marks, zero-width characters, and control codes. | |||
| 8 | + | // Returns 2 for CJK, fullwidth, and wide emoji characters. | |||
| 9 | + | // Returns 1 for all other printable characters. | |||
| 10 | + | // | |||
| 11 | + | // Wide runes (width 2) occupy two grid cells: a left cell holding the rune and | |||
| 12 | + | // a right continuation cell. Combining marks (Mn/Me) and zero-width joiner | |||
| 13 | + | // sequences are out of scope: combining marks report width 0 (current | |||
| 14 | + | // behavior), ZWJ sequences are not coalesced (each rune is measured on its own). | |||
| 15 | + | func RuneWidth(r rune) int { | |||
| 16 | + | switch { | |||
| 17 | + | case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F): | |||
| 18 | + | return 0 | |||
| 19 | + | // Soft hyphen, combining grapheme joiner, Arabic sign, etc. | |||
| 20 | + | case r == 0x00AD || r == 0x034F || r == 0x061C: | |||
| 21 | + | return 0 | |||
| 22 | + | // Hangul Jungseong O/E, Hangul Jungseong Yu | |||
| 23 | + | case r == 0x115F || r == 0x1160: | |||
| 24 | + | return 0 | |||
| 25 | + | // Balinese musical symbols | |||
| 26 | + | case r == 0x17B4 || r == 0x17B5: | |||
| 27 | + | return 0 | |||
| 28 | + | // Mongolian variation selectors, free variation selector | |||
| 29 | + | case r == 0x180B || r == 0x180E || r == 0x200B || r == 0x200C || r == 0x200D || r == 0x200E || r == 0x200F: | |||
| 30 | + | return 0 | |||
| 31 | + | // Line/paragraph separator, bidirectional overrides | |||
| 32 | + | case r >= 0x2028 && r <= 0x202E: | |||
| 33 | + | return 0 | |||
| 34 | + | // Word joiner, invisible operators, Arabic/Syriac number marks, etc. | |||
| 35 | + | case r >= 0x2060 && r <= 0x206F: | |||
| 36 | + | return 0 | |||
| 37 | + | case r == 0xFEFF || r == 0xFFF9 || r == 0xFFFA || r == 0xFFFB: | |||
| 38 | + | return 0 | |||
| 39 | + | // Tags | |||
| 40 | + | case r >= 0xE0001 && r <= 0xE007F: | |||
| 41 | + | return 0 | |||
| 42 | + | // Combining diacritical marks (Unicode categories Mn, Me) | |||
| 43 | + | case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r): | |||
| 44 | + | return 0 | |||
| 45 | + | // --- Wide characters (width 2) --- | |||
| 46 | + | // Hangul Jamo | |||
| 47 | + | case r >= 0x1100 && r <= 0x115F: | |||
| 48 | + | return 2 | |||
| 49 | + | // Left/right-pointing angle bracket (for compatibility) | |||
| 50 | + | case r == 0x2329 || r == 0x232A: | |||
| 51 | + | return 2 | |||
| 52 | + | // CJK Radicals Supplement, Kangxi Radicals, Ideographic Description | |||
| 53 | + | // CJK Symbols, Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo | |||
| 54 | + | // Kanbun, CJK Strokes, Enclosed CJK Letters | |||
| 55 | + | case r >= 0x2E80 && r <= 0x303E: | |||
| 56 | + | return 2 | |||
| 57 | + | // Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo, Kanbun | |||
| 58 | + | case r >= 0x3040 && r <= 0x33FF: | |||
| 59 | + | return 2 | |||
| 60 | + | // CJK Unified Ideographs Extension A | |||
| 61 | + | case r >= 0x3400 && r <= 0x4DBF: | |||
| 62 | + | return 2 | |||
| 63 | + | // CJK Unified Ideographs, Yi Syllables, Yi Radicals | |||
| 64 | + | case r >= 0x4E00 && r <= 0xA4CF: | |||
| 65 | + | return 2 | |||
| 66 | + | // Hangul Jamo Extended-A | |||
| 67 | + | case r >= 0xA960 && r <= 0xA97C: | |||
| 68 | + | return 2 | |||
| 69 | + | // Hangul Syllables | |||
| 70 | + | case r >= 0xAC00 && r <= 0xD7AF: | |||
| 71 | + | return 2 | |||
| 72 | + | // Hangul Jamo Extended-B | |||
| 73 | + | case r >= 0xD7B0 && r <= 0xD7FF: | |||
| 74 | + | return 2 | |||
| 75 | + | // CJK Compatibility Ideographs | |||
| 76 | + | case r >= 0xF900 && r <= 0xFAFF: | |||
| 77 | + | return 2 | |||
| 78 | + | // Vertical Forms | |||
| 79 | + | case r >= 0xFE10 && r <= 0xFE19: | |||
| 80 | + | return 2 | |||
| 81 | + | // CJK Compatibility Forms | |||
| 82 | + | case r >= 0xFE30 && r <= 0xFE6F: | |||
| 83 | + | return 2 | |||
| 84 | + | // Fullwidth Forms (variation selectors, fullwidth punctuation, etc.) | |||
| 85 | + | case r >= 0xFF01 && r <= 0xFF60: | |||
| 86 | + | return 2 | |||
| 87 | + | // Fullwidth Signs (cent, pound, yen, won, etc.) | |||
| 88 | + | case r >= 0xFFE0 && r <= 0xFFE6: | |||
| 89 | + | return 2 | |||
| 90 | + | // Kana Supplement | |||
| 91 | + | case r >= 0x1B000 && r <= 0x1B0FF: | |||
| 92 | + | return 2 | |||
| 93 | + | // Kana Extended-A | |||
| 94 | + | case r >= 0x1B100 && r <= 0x1B12F: | |||
| 95 | + | return 2 | |||
| 96 | + | // Mahjong Tiles, Domino Tiles, Playing Cards | |||
| 97 | + | case r >= 0x1F000 && r <= 0x1F09F: | |||
| 98 | + | return 2 | |||
| 99 | + | // Miscellaneous Symbols and Pictographs, Emoticons, Ornamental Dingbats | |||
| 100 | + | case r >= 0x1F0A0 && r <= 0x1F64F: | |||
| 101 | + | return 2 | |||
| 102 | + | // Transport and Map Symbols | |||
| 103 | + | case r >= 0x1F680 && r <= 0x1F6FF: | |||
| 104 | + | return 2 | |||
| 105 | + | // Supplemental Symbols and Pictographs | |||
| 106 | + | case r >= 0x1F900 && r <= 0x1F9FF: | |||
| 107 | + | return 2 | |||
| 108 | + | // Symbols and Pictographs Extended-A (emoji added in recent Unicode) | |||
| 109 | + | case r >= 0x1FA70 && r <= 0x1FAFF: | |||
| 110 | + | return 2 | |||
| 111 | + | // CJK Unified Ideographs Extension B and beyond | |||
| 112 | + | case r >= 0x20000 && r <= 0x2FFFF: | |||
| 113 | + | return 2 | |||
| 114 | + | // CJK Unified Ideographs Extension G and beyond | |||
| 115 | + | case r >= 0x30000 && r <= 0x3FFFF: | |||
| 116 | + | return 2 | |||
| 117 | + | default: | |||
| 118 | + | return 1 | |||
| 119 | + | } | |||
| 120 | + | } | |||
M pkg/vt/screen.go +3 -121
| @@ -3,7 +3,6 @@ package vt | |||
| 3 | 3 | import ( | |
| 4 | 4 | "strconv" | |
| 5 | 5 | "strings" | |
| 6 | - | "unicode" | |
| 7 | 6 | "unicode/utf8" | |
| 8 | 7 | ||
| 9 | 8 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -173,125 +172,8 @@ func (s *Screen) putRune(r rune) { | |||
| 173 | 172 | } | |
| 174 | 173 | } | |
| 175 | 174 | ||
| 176 | - | // runeWidth returns the terminal cell width of a rune. | |
| 177 | - | // | |
| 178 | - | // Returns 0 for combining marks, zero-width characters, and control codes. | |
| 179 | - | // Returns 2 for CJK, fullwidth, and wide emoji characters. | |
| 180 | - | // Returns 1 for all other printable characters. | |
| 181 | - | // | |
| 182 | - | // Wide runes (width 2) occupy two grid cells: a left cell holding the rune and | |
| 183 | - | // a right continuation cell. Combining marks (Mn/Me) and zero-width joiner | |
| 184 | - | // sequences are out of scope: combining marks report width 0 (current | |
| 185 | - | // behavior), ZWJ sequences are not coalesced (each rune is measured on its own). | |
| 186 | - | func runeWidth(r rune) int { | |
| 187 | - | switch { | |
| 188 | - | case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F): | |
| 189 | - | return 0 | |
| 190 | - | // Soft hyphen, combining grapheme joiner, Arabic sign, etc. | |
| 191 | - | case r == 0x00AD || r == 0x034F || r == 0x061C: | |
| 192 | - | return 0 | |
| 193 | - | // Hangul Jungseong O/E, Hangul Jungseong Yu | |
| 194 | - | case r == 0x115F || r == 0x1160: | |
| 195 | - | return 0 | |
| 196 | - | // Balinese musical symbols | |
| 197 | - | case r == 0x17B4 || r == 0x17B5: | |
| 198 | - | return 0 | |
| 199 | - | // Mongolian variation selectors, free variation selector | |
| 200 | - | case r == 0x180B || r == 0x180E || r == 0x200B || r == 0x200C || r == 0x200D || r == 0x200E || r == 0x200F: | |
| 201 | - | return 0 | |
| 202 | - | // Line/paragraph separator, bidirectional overrides | |
| 203 | - | case r >= 0x2028 && r <= 0x202E: | |
| 204 | - | return 0 | |
| 205 | - | // Word joiner, invisible operators, Arabic/Syriac number marks, etc. | |
| 206 | - | case r >= 0x2060 && r <= 0x206F: | |
| 207 | - | return 0 | |
| 208 | - | case r == 0xFEFF || r == 0xFFF9 || r == 0xFFFA || r == 0xFFFB: | |
| 209 | - | return 0 | |
| 210 | - | // Tags | |
| 211 | - | case r >= 0xE0001 && r <= 0xE007F: | |
| 212 | - | return 0 | |
| 213 | - | // Combining diacritical marks (Unicode categories Mn, Me) | |
| 214 | - | case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r): | |
| 215 | - | return 0 | |
| 216 | - | // --- Wide characters (width 2) --- | |
| 217 | - | // Hangul Jamo | |
| 218 | - | case r >= 0x1100 && r <= 0x115F: | |
| 219 | - | return 2 | |
| 220 | - | // Left/right-pointing angle bracket (for compatibility) | |
| 221 | - | case r == 0x2329 || r == 0x232A: | |
| 222 | - | return 2 | |
| 223 | - | // CJK Radicals Supplement, Kangxi Radicals, Ideographic Description | |
| 224 | - | // CJK Symbols, Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo | |
| 225 | - | // Kanbun, CJK Strokes, Enclosed CJK Letters | |
| 226 | - | case r >= 0x2E80 && r <= 0x303E: | |
| 227 | - | return 2 | |
| 228 | - | // Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo, Kanbun | |
| 229 | - | case r >= 0x3040 && r <= 0x33FF: | |
| 230 | - | return 2 | |
| 231 | - | // CJK Unified Ideographs Extension A | |
| 232 | - | case r >= 0x3400 && r <= 0x4DBF: | |
| 233 | - | return 2 | |
| 234 | - | // CJK Unified Ideographs, Yi Syllables, Yi Radicals | |
| 235 | - | case r >= 0x4E00 && r <= 0xA4CF: | |
| 236 | - | return 2 | |
| 237 | - | // Hangul Jamo Extended-A | |
| 238 | - | case r >= 0xA960 && r <= 0xA97C: | |
| 239 | - | return 2 | |
| 240 | - | // Hangul Syllables | |
| 241 | - | case r >= 0xAC00 && r <= 0xD7AF: | |
| 242 | - | return 2 | |
| 243 | - | // Hangul Jamo Extended-B | |
| 244 | - | case r >= 0xD7B0 && r <= 0xD7FF: | |
| 245 | - | return 2 | |
| 246 | - | // CJK Compatibility Ideographs | |
| 247 | - | case r >= 0xF900 && r <= 0xFAFF: | |
| 248 | - | return 2 | |
| 249 | - | // Vertical Forms | |
| 250 | - | case r >= 0xFE10 && r <= 0xFE19: | |
| 251 | - | return 2 | |
| 252 | - | // CJK Compatibility Forms | |
| 253 | - | case r >= 0xFE30 && r <= 0xFE6F: | |
| 254 | - | return 2 | |
| 255 | - | // Fullwidth Forms (variation selectors, fullwidth punctuation, etc.) | |
| 256 | - | case r >= 0xFF01 && r <= 0xFF60: | |
| 257 | - | return 2 | |
| 258 | - | // Fullwidth Signs (cent, pound, yen, won, etc.) | |
| 259 | - | case r >= 0xFFE0 && r <= 0xFFE6: | |
| 260 | - | return 2 | |
| 261 | - | // Kana Supplement | |
| 262 | - | case r >= 0x1B000 && r <= 0x1B0FF: | |
| 263 | - | return 2 | |
| 264 | - | // Kana Extended-A | |
| 265 | - | case r >= 0x1B100 && r <= 0x1B12F: | |
| 266 | - | return 2 | |
| 267 | - | // Mahjong Tiles, Domino Tiles, Playing Cards | |
| 268 | - | case r >= 0x1F000 && r <= 0x1F09F: | |
| 269 | - | return 2 | |
| 270 | - | // Miscellaneous Symbols and Pictographs, Emoticons, Ornamental Dingbats | |
| 271 | - | case r >= 0x1F0A0 && r <= 0x1F64F: | |
| 272 | - | return 2 | |
| 273 | - | // Transport and Map Symbols | |
| 274 | - | case r >= 0x1F680 && r <= 0x1F6FF: | |
| 275 | - | return 2 | |
| 276 | - | // Supplemental Symbols and Pictographs | |
| 277 | - | case r >= 0x1F900 && r <= 0x1F9FF: | |
| 278 | - | return 2 | |
| 279 | - | // Symbols and Pictographs Extended-A (emoji added in recent Unicode) | |
| 280 | - | case r >= 0x1FA70 && r <= 0x1FAFF: | |
| 281 | - | return 2 | |
| 282 | - | // CJK Unified Ideographs Extension B and beyond | |
| 283 | - | case r >= 0x20000 && r <= 0x2FFFF: | |
| 284 | - | return 2 | |
| 285 | - | // CJK Unified Ideographs Extension G and beyond | |
| 286 | - | case r >= 0x30000 && r <= 0x3FFFF: | |
| 287 | - | return 2 | |
| 288 | - | default: | |
| 289 | - | return 1 | |
| 290 | - | } | |
| 291 | - | } | |
| 292 | - | ||
| 293 | 175 | func (s *Screen) putPrintable(r rune) { | |
| 294 | - | w := runeWidth(r) | |
| 176 | + | w := renderer.RuneWidth(r) | |
| 295 | 177 | // Skip combining marks and zero-width characters. | |
| 296 | 178 | if w == 0 { | |
| 297 | 179 | return | |
| @@ -423,12 +305,12 @@ func (s *Screen) repairRow(y int) { | |||
| 423 | 305 | continue | |
| 424 | 306 | } | |
| 425 | 307 | left := s.Frame.At(x-1, y) | |
| 426 | - | if left.Continuation || runeWidth(left.Rune) != 2 { | |
| 308 | + | if left.Continuation || renderer.RuneWidth(left.Rune) != 2 { | |
| 427 | 309 | s.Frame.Set(x, y, renderer.BlankCell()) | |
| 428 | 310 | } | |
| 429 | 311 | continue | |
| 430 | 312 | } | |
| 431 | - | if runeWidth(c.Rune) == 2 { | |
| 313 | + | if renderer.RuneWidth(c.Rune) == 2 { | |
| 432 | 314 | if x+1 >= w || !s.Frame.At(x+1, y).Continuation { | |
| 433 | 315 | s.Frame.Set(x, y, renderer.BlankCell()) | |
| 434 | 316 | } | |
| @@ -3,7 +3,6 @@ package vt | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "strconv" | 4 | "strconv" | ||
| 5 | "strings" | 5 | "strings" | ||
| 6 | - | "unicode" | |||
| 7 | "unicode/utf8" | 6 | "unicode/utf8" | ||
| 8 | 7 | ||||
| 9 | "github.com/bnema/vev/pkg/renderer" | 8 | "github.com/bnema/vev/pkg/renderer" | ||
| @@ -173,125 +172,8 @@ func (s *Screen) putRune(r rune) { | |||||
| 173 | } | 172 | } | ||
| 174 | } | 173 | } | ||
| 175 | 174 | ||||
| 176 | - | // runeWidth returns the terminal cell width of a rune. | |||
| 177 | - | // | |||
| 178 | - | // Returns 0 for combining marks, zero-width characters, and control codes. | |||
| 179 | - | // Returns 2 for CJK, fullwidth, and wide emoji characters. | |||
| 180 | - | // Returns 1 for all other printable characters. | |||
| 181 | - | // | |||
| 182 | - | // Wide runes (width 2) occupy two grid cells: a left cell holding the rune and | |||
| 183 | - | // a right continuation cell. Combining marks (Mn/Me) and zero-width joiner | |||
| 184 | - | // sequences are out of scope: combining marks report width 0 (current | |||
| 185 | - | // behavior), ZWJ sequences are not coalesced (each rune is measured on its own). | |||
| 186 | - | func runeWidth(r rune) int { | |||
| 187 | - | switch { | |||
| 188 | - | case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F): | |||
| 189 | - | return 0 | |||
| 190 | - | // Soft hyphen, combining grapheme joiner, Arabic sign, etc. | |||
| 191 | - | case r == 0x00AD || r == 0x034F || r == 0x061C: | |||
| 192 | - | return 0 | |||
| 193 | - | // Hangul Jungseong O/E, Hangul Jungseong Yu | |||
| 194 | - | case r == 0x115F || r == 0x1160: | |||
| 195 | - | return 0 | |||
| 196 | - | // Balinese musical symbols | |||
| 197 | - | case r == 0x17B4 || r == 0x17B5: | |||
| 198 | - | return 0 | |||
| 199 | - | // Mongolian variation selectors, free variation selector | |||
| 200 | - | case r == 0x180B || r == 0x180E || r == 0x200B || r == 0x200C || r == 0x200D || r == 0x200E || r == 0x200F: | |||
| 201 | - | return 0 | |||
| 202 | - | // Line/paragraph separator, bidirectional overrides | |||
| 203 | - | case r >= 0x2028 && r <= 0x202E: | |||
| 204 | - | return 0 | |||
| 205 | - | // Word joiner, invisible operators, Arabic/Syriac number marks, etc. | |||
| 206 | - | case r >= 0x2060 && r <= 0x206F: | |||
| 207 | - | return 0 | |||
| 208 | - | case r == 0xFEFF || r == 0xFFF9 || r == 0xFFFA || r == 0xFFFB: | |||
| 209 | - | return 0 | |||
| 210 | - | // Tags | |||
| 211 | - | case r >= 0xE0001 && r <= 0xE007F: | |||
| 212 | - | return 0 | |||
| 213 | - | // Combining diacritical marks (Unicode categories Mn, Me) | |||
| 214 | - | case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r): | |||
| 215 | - | return 0 | |||
| 216 | - | // --- Wide characters (width 2) --- | |||
| 217 | - | // Hangul Jamo | |||
| 218 | - | case r >= 0x1100 && r <= 0x115F: | |||
| 219 | - | return 2 | |||
| 220 | - | // Left/right-pointing angle bracket (for compatibility) | |||
| 221 | - | case r == 0x2329 || r == 0x232A: | |||
| 222 | - | return 2 | |||
| 223 | - | // CJK Radicals Supplement, Kangxi Radicals, Ideographic Description | |||
| 224 | - | // CJK Symbols, Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo | |||
| 225 | - | // Kanbun, CJK Strokes, Enclosed CJK Letters | |||
| 226 | - | case r >= 0x2E80 && r <= 0x303E: | |||
| 227 | - | return 2 | |||
| 228 | - | // Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo, Kanbun | |||
| 229 | - | case r >= 0x3040 && r <= 0x33FF: | |||
| 230 | - | return 2 | |||
| 231 | - | // CJK Unified Ideographs Extension A | |||
| 232 | - | case r >= 0x3400 && r <= 0x4DBF: | |||
| 233 | - | return 2 | |||
| 234 | - | // CJK Unified Ideographs, Yi Syllables, Yi Radicals | |||
| 235 | - | case r >= 0x4E00 && r <= 0xA4CF: | |||
| 236 | - | return 2 | |||
| 237 | - | // Hangul Jamo Extended-A | |||
| 238 | - | case r >= 0xA960 && r <= 0xA97C: | |||
| 239 | - | return 2 | |||
| 240 | - | // Hangul Syllables | |||
| 241 | - | case r >= 0xAC00 && r <= 0xD7AF: | |||
| 242 | - | return 2 | |||
| 243 | - | // Hangul Jamo Extended-B | |||
| 244 | - | case r >= 0xD7B0 && r <= 0xD7FF: | |||
| 245 | - | return 2 | |||
| 246 | - | // CJK Compatibility Ideographs | |||
| 247 | - | case r >= 0xF900 && r <= 0xFAFF: | |||
| 248 | - | return 2 | |||
| 249 | - | // Vertical Forms | |||
| 250 | - | case r >= 0xFE10 && r <= 0xFE19: | |||
| 251 | - | return 2 | |||
| 252 | - | // CJK Compatibility Forms | |||
| 253 | - | case r >= 0xFE30 && r <= 0xFE6F: | |||
| 254 | - | return 2 | |||
| 255 | - | // Fullwidth Forms (variation selectors, fullwidth punctuation, etc.) | |||
| 256 | - | case r >= 0xFF01 && r <= 0xFF60: | |||
| 257 | - | return 2 | |||
| 258 | - | // Fullwidth Signs (cent, pound, yen, won, etc.) | |||
| 259 | - | case r >= 0xFFE0 && r <= 0xFFE6: | |||
| 260 | - | return 2 | |||
| 261 | - | // Kana Supplement | |||
| 262 | - | case r >= 0x1B000 && r <= 0x1B0FF: | |||
| 263 | - | return 2 | |||
| 264 | - | // Kana Extended-A | |||
| 265 | - | case r >= 0x1B100 && r <= 0x1B12F: | |||
| 266 | - | return 2 | |||
| 267 | - | // Mahjong Tiles, Domino Tiles, Playing Cards | |||
| 268 | - | case r >= 0x1F000 && r <= 0x1F09F: | |||
| 269 | - | return 2 | |||
| 270 | - | // Miscellaneous Symbols and Pictographs, Emoticons, Ornamental Dingbats | |||
| 271 | - | case r >= 0x1F0A0 && r <= 0x1F64F: | |||
| 272 | - | return 2 | |||
| 273 | - | // Transport and Map Symbols | |||
| 274 | - | case r >= 0x1F680 && r <= 0x1F6FF: | |||
| 275 | - | return 2 | |||
| 276 | - | // Supplemental Symbols and Pictographs | |||
| 277 | - | case r >= 0x1F900 && r <= 0x1F9FF: | |||
| 278 | - | return 2 | |||
| 279 | - | // Symbols and Pictographs Extended-A (emoji added in recent Unicode) | |||
| 280 | - | case r >= 0x1FA70 && r <= 0x1FAFF: | |||
| 281 | - | return 2 | |||
| 282 | - | // CJK Unified Ideographs Extension B and beyond | |||
| 283 | - | case r >= 0x20000 && r <= 0x2FFFF: | |||
| 284 | - | return 2 | |||
| 285 | - | // CJK Unified Ideographs Extension G and beyond | |||
| 286 | - | case r >= 0x30000 && r <= 0x3FFFF: | |||
| 287 | - | return 2 | |||
| 288 | - | default: | |||
| 289 | - | return 1 | |||
| 290 | - | } | |||
| 291 | - | } | |||
| 292 | - | ||||
| 293 | func (s *Screen) putPrintable(r rune) { | 175 | func (s *Screen) putPrintable(r rune) { | ||
| 294 | - | w := runeWidth(r) | 176 | + | w := renderer.RuneWidth(r) |
| 295 | // Skip combining marks and zero-width characters. | 177 | // Skip combining marks and zero-width characters. | ||
| 296 | if w == 0 { | 178 | if w == 0 { | ||
| 297 | return | 179 | return | ||
| @@ -423,12 +305,12 @@ func (s *Screen) repairRow(y int) { | |||||
| 423 | continue | 305 | continue | ||
| 424 | } | 306 | } | ||
| 425 | left := s.Frame.At(x-1, y) | 307 | left := s.Frame.At(x-1, y) | ||
| 426 | - | if left.Continuation || runeWidth(left.Rune) != 2 { | 308 | + | if left.Continuation || renderer.RuneWidth(left.Rune) != 2 { |
| 427 | s.Frame.Set(x, y, renderer.BlankCell()) | 309 | s.Frame.Set(x, y, renderer.BlankCell()) | ||
| 428 | } | 310 | } | ||
| 429 | continue | 311 | continue | ||
| 430 | } | 312 | } | ||
| 431 | - | if runeWidth(c.Rune) == 2 { | 313 | + | if renderer.RuneWidth(c.Rune) == 2 { |
| 432 | if x+1 >= w || !s.Frame.At(x+1, y).Continuation { | 314 | if x+1 >= w || !s.Frame.At(x+1, y).Continuation { | ||
| 433 | s.Frame.Set(x, y, renderer.BlankCell()) | 315 | s.Frame.Set(x, y, renderer.BlankCell()) | ||
| 434 | } | 316 | } | ||
M pkg/vt/screen_test.go +2 -2
| @@ -1692,9 +1692,9 @@ func TestRuneWidth(t *testing.T) { | |||
| 1692 | 1692 | ||
| 1693 | 1693 | for _, tt := range tests { | |
| 1694 | 1694 | t.Run(tt.name, func(t *testing.T) { | |
| 1695 | - | got := runeWidth(tt.r) | |
| 1695 | + | got := renderer.RuneWidth(tt.r) | |
| 1696 | 1696 | if got != tt.width { | |
| 1697 | - | t.Errorf("runeWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width) | |
| 1697 | + | t.Errorf("renderer.RuneWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width) | |
| 1698 | 1698 | } | |
| 1699 | 1699 | }) | |
| 1700 | 1700 | } | |
| @@ -1692,9 +1692,9 @@ func TestRuneWidth(t *testing.T) { | |||||
| 1692 | 1692 | ||||
| 1693 | for _, tt := range tests { | 1693 | for _, tt := range tests { | ||
| 1694 | t.Run(tt.name, func(t *testing.T) { | 1694 | t.Run(tt.name, func(t *testing.T) { | ||
| 1695 | - | got := runeWidth(tt.r) | 1695 | + | got := renderer.RuneWidth(tt.r) |
| 1696 | if got != tt.width { | 1696 | if got != tt.width { | ||
| 1697 | - | t.Errorf("runeWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width) | 1697 | + | t.Errorf("renderer.RuneWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width) |
| 1698 | } | 1698 | } | ||
| 1699 | }) | 1699 | }) | ||
| 1700 | } | 1700 | } | ||