refactor: standardize Go layout with internal terminalstate

Audit findings: cmd/webterm and webterm package were acceptable, but terminalstate
was an internal implementation detail exposed as a public package.

Changes:
- Move go/terminalstate -> go/internal/terminalstate
- Update imports to github.com/rcarmo/webterm-go-port/internal/terminalstate
- Keep package name terminalstate unchanged

Resulting package layout:
- github.com/rcarmo/webterm-go-port/cmd/webterm
- github.com/rcarmo/webterm-go-port/internal/terminalstate
- github.com/rcarmo/webterm-go-port/webterm

Validation:
- go test ./...
- go test -race ./...
- go coverage check remains 80.9%
- make check

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
GitHub Copilot
2026-02-14 17:13:49 +00:00
parent f597f8f99d
commit 065de286fb
9 changed files with 7 additions and 7 deletions
+153
View File
@@ -0,0 +1,153 @@
package terminalstate
import (
"fmt"
"strconv"
"strings"
"sync"
"github.com/rcarmo/go-te/pkg/te"
)
var ansi16Names = [...]string{
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"white",
"brightblack",
"brightred",
"brightgreen",
"brightyellow",
"brightblue",
"brightmagenta",
"brightcyan",
"brightwhite",
}
type Cell struct {
Data string `json:"data"`
FG string `json:"fg"`
BG string `json:"bg"`
Bold bool `json:"bold"`
Italics bool `json:"italics"`
Underscore bool `json:"underscore"`
Reverse bool `json:"reverse"`
}
type Snapshot struct {
Width int `json:"width"`
Height int `json:"height"`
Buffer [][]Cell `json:"buffer"`
HasChanges bool `json:"has_changes"`
}
type Tracker struct {
mu sync.Mutex
screen *te.DiffScreen
stream *te.ByteStream
changeCounter uint64
lastSnapshotCounter uint64
}
func NewTracker(width, height int) *Tracker {
screen := te.NewDiffScreen(width, height)
return &Tracker{
screen: screen,
stream: te.NewByteStream(screen, false),
}
}
func (t *Tracker) Feed(data []byte) error {
if len(data) == 0 {
return nil
}
t.mu.Lock()
defer t.mu.Unlock()
if err := t.stream.Feed(data); err != nil {
return err
}
if len(t.screen.Dirty) > 0 {
t.changeCounter++
// Clear dirty set so subsequent feeds detect new changes
for k := range t.screen.Dirty {
delete(t.screen.Dirty, k)
}
}
return nil
}
func (t *Tracker) Resize(width, height int) {
t.mu.Lock()
defer t.mu.Unlock()
if width == t.screen.Columns && height == t.screen.Lines {
return
}
t.screen.Resize(height, width)
t.changeCounter++
}
func (t *Tracker) Snapshot() Snapshot {
t.mu.Lock()
defer t.mu.Unlock()
snapshot := Snapshot{
Width: t.screen.Columns,
Height: t.screen.Lines,
HasChanges: t.changeCounter > t.lastSnapshotCounter,
Buffer: make([][]Cell, t.screen.Lines),
}
t.lastSnapshotCounter = t.changeCounter
for row := 0; row < t.screen.Lines; row++ {
line := make([]Cell, t.screen.Columns)
for col := 0; col < t.screen.Columns; col++ {
raw := t.screen.Buffer[row][col]
data := raw.Data
if data == "" {
data = " "
}
line[col] = Cell{
Data: data,
FG: colorToString(raw.Attr.Fg),
BG: colorToString(raw.Attr.Bg),
Bold: raw.Attr.Bold,
Italics: raw.Attr.Italics,
Underscore: raw.Attr.Underline,
Reverse: raw.Attr.Reverse,
}
}
snapshot.Buffer[row] = line
}
return snapshot
}
func colorToString(color te.Color) string {
if color.Name != "" {
name := strings.ToLower(strings.TrimPrefix(color.Name, "#"))
if len(name) == 6 {
if _, err := strconv.ParseUint(name, 16, 32); err == nil {
return name
}
}
return name
}
switch color.Mode {
case te.ColorDefault:
return "default"
case te.ColorANSI16:
if int(color.Index) < len(ansi16Names) {
return ansi16Names[color.Index]
}
return "default"
case te.ColorANSI256:
return fmt.Sprintf("%d", color.Index)
case te.ColorTrueColor:
return "default"
default:
return "default"
}
}
+121
View File
@@ -0,0 +1,121 @@
package terminalstate
import (
"testing"
"github.com/rcarmo/go-te/pkg/te"
)
func TestTrackerSnapshotChangeTracking(t *testing.T) {
tracker := NewTracker(10, 3)
if err := tracker.Feed([]byte("hi")); err != nil {
t.Fatalf("Feed() error = %v", err)
}
snapshot := tracker.Snapshot()
if !snapshot.HasChanges {
t.Fatalf("expected first snapshot to report changes")
}
if got := snapshot.Buffer[0][0].Data; got != "h" {
t.Fatalf("expected first cell to be h, got %q", got)
}
if got := snapshot.Buffer[0][1].Data; got != "i" {
t.Fatalf("expected second cell to be i, got %q", got)
}
again := tracker.Snapshot()
if again.HasChanges {
t.Fatalf("expected second snapshot without new input to report no changes")
}
}
func TestTrackerAnsiStyles(t *testing.T) {
tracker := NewTracker(10, 3)
if err := tracker.Feed([]byte("\x1b[31;1mA\x1b[0m")); err != nil {
t.Fatalf("Feed() error = %v", err)
}
snapshot := tracker.Snapshot()
cell := snapshot.Buffer[0][0]
if !cell.Bold {
t.Fatalf("expected bold attribute to be true")
}
if cell.FG != "red" {
t.Fatalf("expected red foreground, got %q", cell.FG)
}
}
func TestTrackerResize(t *testing.T) {
tracker := NewTracker(10, 3)
tracker.Resize(20, 4)
snapshot := tracker.Snapshot()
if snapshot.Width != 20 || snapshot.Height != 4 {
t.Fatalf("unexpected dimensions: got %dx%d", snapshot.Width, snapshot.Height)
}
if !snapshot.HasChanges {
t.Fatalf("expected resize to mark snapshot as changed")
}
}
func TestColorToStringModes(t *testing.T) {
if got := colorToString(te.Color{Mode: te.ColorDefault}); got != "default" {
t.Fatalf("default color mismatch: %q", got)
}
if got := colorToString(te.Color{Mode: te.ColorANSI16, Index: 1}); got != "red" {
t.Fatalf("ansi16 color mismatch: %q", got)
}
if got := colorToString(te.Color{Mode: te.ColorANSI256, Index: 196}); got != "196" {
t.Fatalf("ansi256 color mismatch: %q", got)
}
if got := colorToString(te.Color{Name: "#AABBCC"}); got != "aabbcc" {
t.Fatalf("named hex color mismatch: %q", got)
}
if got := colorToString(te.Color{Name: "Blue"}); got != "blue" {
t.Fatalf("named color mismatch: %q", got)
}
}
func FuzzTrackerFeed(f *testing.F) {
f.Add([]byte("hello world"))
f.Add([]byte("\x1b[31;1mRed Bold\x1b[0m"))
f.Add([]byte("\x1b[2J\x1b[H"))
f.Add([]byte("\x1b[10;20H\x1b[K"))
f.Add([]byte("\r\n\r\n\r\n"))
f.Add([]byte{0x00, 0x01, 0x02, 0x1b, 0x5b, 0x41})
f.Add([]byte("\x1b[?1049h\x1b[2J"))
f.Add([]byte("\x1b[38;5;196mcolor\x1b[0m"))
f.Fuzz(func(t *testing.T, data []byte) {
tracker := NewTracker(80, 24)
// Feed must not panic
_ = tracker.Feed(data)
// Snapshot must always return valid dimensions
snap := tracker.Snapshot()
if snap.Width != 80 || snap.Height != 24 {
t.Errorf("unexpected dimensions after feed: %dx%d", snap.Width, snap.Height)
}
if len(snap.Buffer) != 24 {
t.Errorf("buffer row count mismatch: got %d", len(snap.Buffer))
}
for i, row := range snap.Buffer {
if len(row) != 80 {
t.Errorf("row %d col count mismatch: got %d", i, len(row))
}
}
})
}
func FuzzTrackerFeedIncremental(f *testing.F) {
f.Add([]byte("\x1b[31m"), []byte("hello\x1b[0m"))
f.Add([]byte("abc"), []byte("def"))
f.Add([]byte("\x1b["), []byte("1;2H"))
f.Fuzz(func(t *testing.T, chunk1, chunk2 []byte) {
tracker := NewTracker(40, 10)
_ = tracker.Feed(chunk1)
_ = tracker.Feed(chunk2)
snap := tracker.Snapshot()
if snap.Width != 40 || snap.Height != 10 {
t.Errorf("unexpected dimensions: %dx%d", snap.Width, snap.Height)
}
})
}