detached acf9d96

feat(term): add darwin backend to pkg/rawterm

commit
acf9d96bc1f25cabf89a61a5a33ff33de8858db8
author
brice · · 2026-07-16 17:46 UTC
parent
eba0b22
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +157 -0
A pkg/rawterm/term_darwin.go +50 -0
@@ -0,0 +1,50 @@
1+//go:build darwin
2+
3+package rawterm
4+
5+import (
6+ "bytes"
7+ "fmt"
8+ "os"
9+ "syscall"
10+ "unsafe"
11+)
12+
13+const (
14+ // TIOCGETA and TIOCSETA are Darwin's termios ioctl requests. They differ
15+ // from the Linux TCGETS and TCSETS requests despite operating on the same
16+ // syscall.Termios type.
17+ reqGetTermios = syscall.TIOCGETA
18+ reqSetTermios = syscall.TIOCSETA
19+)
20+
21+// PreparePty grants and unlocks the slave associated with masterFD, obtains
22+// its name from the kernel, and opens it. Darwin requires this exact sequence:
23+// TIOCPTYGRANT before TIOCPTYUNLK before TIOCPTYGNAME.
24+func PreparePty(masterFD int) (*os.File, error) {
25+ if err := ioctl(masterFD, syscall.TIOCPTYGRANT, nil); err != nil {
26+ return nil, fmt.Errorf("rawterm: grant pty: %w", err)
27+ }
28+ if err := ioctl(masterFD, syscall.TIOCPTYUNLK, nil); err != nil {
29+ return nil, fmt.Errorf("rawterm: unlock pty: %w", err)
30+ }
31+
32+ var slaveName [128]byte
33+ if err := ioctl(masterFD, syscall.TIOCPTYGNAME, unsafe.Pointer(&slaveName[0])); err != nil {
34+ return nil, fmt.Errorf("rawterm: get pty name: %w", err)
35+ }
36+ nul := bytes.IndexByte(slaveName[:], 0)
37+ if nul < 0 {
38+ return nil, fmt.Errorf("rawterm: get pty name: unterminated slave name")
39+ }
40+ if nul == 0 {
41+ return nil, fmt.Errorf("rawterm: get pty name: empty slave name")
42+ }
43+
44+ name := string(slaveName[:nul])
45+ slave, err := os.OpenFile(name, os.O_RDWR|syscall.O_NOCTTY, 0)
46+ if err != nil {
47+ return nil, fmt.Errorf("rawterm: open %s: %w", name, err)
48+ }
49+ return slave, nil
50+}
@@ -0,0 +1,50 @@
1+//go:build darwin
2+
3+package rawterm
4+
5+import (
6+ "bytes"
7+ "fmt"
8+ "os"
9+ "syscall"
10+ "unsafe"
11+)
12+
13+const (
14+ // TIOCGETA and TIOCSETA are Darwin's termios ioctl requests. They differ
15+ // from the Linux TCGETS and TCSETS requests despite operating on the same
16+ // syscall.Termios type.
17+ reqGetTermios = syscall.TIOCGETA
18+ reqSetTermios = syscall.TIOCSETA
19+)
20+
21+// PreparePty grants and unlocks the slave associated with masterFD, obtains
22+// its name from the kernel, and opens it. Darwin requires this exact sequence:
23+// TIOCPTYGRANT before TIOCPTYUNLK before TIOCPTYGNAME.
24+func PreparePty(masterFD int) (*os.File, error) {
25+ if err := ioctl(masterFD, syscall.TIOCPTYGRANT, nil); err != nil {
26+ return nil, fmt.Errorf("rawterm: grant pty: %w", err)
27+ }
28+ if err := ioctl(masterFD, syscall.TIOCPTYUNLK, nil); err != nil {
29+ return nil, fmt.Errorf("rawterm: unlock pty: %w", err)
30+ }
31+
32+ var slaveName [128]byte
33+ if err := ioctl(masterFD, syscall.TIOCPTYGNAME, unsafe.Pointer(&slaveName[0])); err != nil {
34+ return nil, fmt.Errorf("rawterm: get pty name: %w", err)
35+ }
36+ nul := bytes.IndexByte(slaveName[:], 0)
37+ if nul < 0 {
38+ return nil, fmt.Errorf("rawterm: get pty name: unterminated slave name")
39+ }
40+ if nul == 0 {
41+ return nil, fmt.Errorf("rawterm: get pty name: empty slave name")
42+ }
43+
44+ name := string(slaveName[:nul])
45+ slave, err := os.OpenFile(name, os.O_RDWR|syscall.O_NOCTTY, 0)
46+ if err != nil {
47+ return nil, fmt.Errorf("rawterm: open %s: %w", name, err)
48+ }
49+ return slave, nil
50+}
A pkg/rawterm/term_darwin_test.go +107 -0
@@ -0,0 +1,107 @@
1+//go:build darwin
2+
3+package rawterm
4+
5+import (
6+ "os"
7+ "syscall"
8+ "testing"
9+ "unsafe"
10+)
11+
12+// openPTYPair opens a real Darwin PTY master/slave pair through the package
13+// under test, exercising the grant, unlock, name, and open protocol.
14+func openPTYPair(t *testing.T) (master, slave *os.File) {
15+ t.Helper()
16+
17+ masterFD, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
18+ if err != nil {
19+ t.Skipf("open /dev/ptmx: %v", err)
20+ }
21+ master = os.NewFile(uintptr(masterFD), "pty-master")
22+
23+ slave, err = PreparePty(masterFD)
24+ if err != nil {
25+ _ = master.Close()
26+ t.Fatalf("PreparePty: %v", err)
27+ }
28+
29+ return master, slave
30+}
31+
32+func TestPreparePtyOpensSlave(t *testing.T) {
33+ master, slave := openPTYPair(t)
34+ defer func() { _ = master.Close() }()
35+ defer func() { _ = slave.Close() }()
36+
37+ if !IsTerminal(int(slave.Fd())) {
38+ t.Error("PreparePty slave is not a terminal")
39+ }
40+}
41+
42+func TestMakeRawRestore(t *testing.T) {
43+ master, slave := openPTYPair(t)
44+ defer func() { _ = master.Close() }()
45+ defer func() { _ = slave.Close() }()
46+
47+ fd := int(slave.Fd())
48+ old, err := MakeRaw(fd)
49+ if err != nil {
50+ t.Fatalf("MakeRaw: %v", err)
51+ }
52+ if old == nil {
53+ t.Fatal("MakeRaw returned nil state")
54+ }
55+
56+ var raw syscall.Termios
57+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&raw)); err != nil {
58+ t.Fatalf("get termios after MakeRaw: %v", err)
59+ }
60+ if raw.Lflag&(syscall.ECHO|syscall.ICANON) != 0 {
61+ t.Errorf("Lflag after MakeRaw = %#x, ECHO and ICANON must be clear", raw.Lflag)
62+ }
63+ if raw.Cc[syscall.VMIN] != 1 || raw.Cc[syscall.VTIME] != 0 {
64+ t.Errorf("Cc[VMIN, VTIME] = [%d, %d], want [1, 0]", raw.Cc[syscall.VMIN], raw.Cc[syscall.VTIME])
65+ }
66+
67+ if err := Restore(fd, old); err != nil {
68+ t.Fatalf("Restore: %v", err)
69+ }
70+ var restored syscall.Termios
71+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&restored)); err != nil {
72+ t.Fatalf("get termios after Restore: %v", err)
73+ }
74+ if restored != old.termios {
75+ t.Error("Restore did not restore the original termios settings")
76+ }
77+}
78+
79+func TestSetWinsizeGetSizeRoundTrip(t *testing.T) {
80+ master, slave := openPTYPair(t)
81+ defer func() { _ = master.Close() }()
82+ defer func() { _ = slave.Close() }()
83+
84+ tests := []struct {
85+ name string
86+ cols uint16
87+ rows uint16
88+ }{
89+ {name: "typical", cols: 80, rows: 24},
90+ {name: "wide", cols: 220, rows: 60},
91+ {name: "minimal", cols: 1, rows: 1},
92+ }
93+ for _, tt := range tests {
94+ t.Run(tt.name, func(t *testing.T) {
95+ if err := SetWinsize(int(slave.Fd()), tt.cols, tt.rows); err != nil {
96+ t.Fatalf("SetWinsize: %v", err)
97+ }
98+ cols, rows, err := GetSize(int(slave.Fd()))
99+ if err != nil {
100+ t.Fatalf("GetSize: %v", err)
101+ }
102+ if cols != int(tt.cols) || rows != int(tt.rows) {
103+ t.Errorf("GetSize = (%d, %d), want (%d, %d)", cols, rows, tt.cols, tt.rows)
104+ }
105+ })
106+ }
107+}
@@ -0,0 +1,107 @@
1+//go:build darwin
2+
3+package rawterm
4+
5+import (
6+ "os"
7+ "syscall"
8+ "testing"
9+ "unsafe"
10+)
11+
12+// openPTYPair opens a real Darwin PTY master/slave pair through the package
13+// under test, exercising the grant, unlock, name, and open protocol.
14+func openPTYPair(t *testing.T) (master, slave *os.File) {
15+ t.Helper()
16+
17+ masterFD, err := syscall.Open("/dev/ptmx", syscall.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
18+ if err != nil {
19+ t.Skipf("open /dev/ptmx: %v", err)
20+ }
21+ master = os.NewFile(uintptr(masterFD), "pty-master")
22+
23+ slave, err = PreparePty(masterFD)
24+ if err != nil {
25+ _ = master.Close()
26+ t.Fatalf("PreparePty: %v", err)
27+ }
28+
29+ return master, slave
30+}
31+
32+func TestPreparePtyOpensSlave(t *testing.T) {
33+ master, slave := openPTYPair(t)
34+ defer func() { _ = master.Close() }()
35+ defer func() { _ = slave.Close() }()
36+
37+ if !IsTerminal(int(slave.Fd())) {
38+ t.Error("PreparePty slave is not a terminal")
39+ }
40+}
41+
42+func TestMakeRawRestore(t *testing.T) {
43+ master, slave := openPTYPair(t)
44+ defer func() { _ = master.Close() }()
45+ defer func() { _ = slave.Close() }()
46+
47+ fd := int(slave.Fd())
48+ old, err := MakeRaw(fd)
49+ if err != nil {
50+ t.Fatalf("MakeRaw: %v", err)
51+ }
52+ if old == nil {
53+ t.Fatal("MakeRaw returned nil state")
54+ }
55+
56+ var raw syscall.Termios
57+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&raw)); err != nil {
58+ t.Fatalf("get termios after MakeRaw: %v", err)
59+ }
60+ if raw.Lflag&(syscall.ECHO|syscall.ICANON) != 0 {
61+ t.Errorf("Lflag after MakeRaw = %#x, ECHO and ICANON must be clear", raw.Lflag)
62+ }
63+ if raw.Cc[syscall.VMIN] != 1 || raw.Cc[syscall.VTIME] != 0 {
64+ t.Errorf("Cc[VMIN, VTIME] = [%d, %d], want [1, 0]", raw.Cc[syscall.VMIN], raw.Cc[syscall.VTIME])
65+ }
66+
67+ if err := Restore(fd, old); err != nil {
68+ t.Fatalf("Restore: %v", err)
69+ }
70+ var restored syscall.Termios
71+ if err := ioctl(fd, reqGetTermios, unsafe.Pointer(&restored)); err != nil {
72+ t.Fatalf("get termios after Restore: %v", err)
73+ }
74+ if restored != old.termios {
75+ t.Error("Restore did not restore the original termios settings")
76+ }
77+}
78+
79+func TestSetWinsizeGetSizeRoundTrip(t *testing.T) {
80+ master, slave := openPTYPair(t)
81+ defer func() { _ = master.Close() }()
82+ defer func() { _ = slave.Close() }()
83+
84+ tests := []struct {
85+ name string
86+ cols uint16
87+ rows uint16
88+ }{
89+ {name: "typical", cols: 80, rows: 24},
90+ {name: "wide", cols: 220, rows: 60},
91+ {name: "minimal", cols: 1, rows: 1},
92+ }
93+ for _, tt := range tests {
94+ t.Run(tt.name, func(t *testing.T) {
95+ if err := SetWinsize(int(slave.Fd()), tt.cols, tt.rows); err != nil {
96+ t.Fatalf("SetWinsize: %v", err)
97+ }
98+ cols, rows, err := GetSize(int(slave.Fd()))
99+ if err != nil {
100+ t.Fatalf("GetSize: %v", err)
101+ }
102+ if cols != int(tt.cols) || rows != int(tt.rows) {
103+ t.Errorf("GetSize = (%d, %d), want (%d, %d)", cols, rows, tt.cols, tt.rows)
104+ }
105+ })
106+ }
107+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close