diff --git a/README.md b/README.md index ea68654..1a0c729 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ The goals, in order: you get a heads-up; if the file is deleted, the tab is flagged once. - **Toggleable, draggable sidebar** — show/hide the file tree from the menu, or drag the splitter to resize it. +- **Terminal in a tab** — open your `$SHELL` in a real tab (`Esc \`` or + **Open terminal in new tab** from the `≡` menu) and run tests or git + without leaving the editor. Rendered by a built-in VT emulator, so it + works over SSH and inside `tmux` with no passthrough config. - **Clipboard over SSH** — OSC 52, including a `tmux` passthrough so copy works from inside a tmux session on a remote host. - **Format on save** — opt-in per-project via `.spiceedit/format.json` @@ -191,6 +195,7 @@ within half a second tap one of the letters below. | `Esc /` | Toggle line comment | | `Esc f` | Find in file | | `Esc p` | Find file in project | +| `Esc \`` | Open terminal tab | A lone `Esc` is harmless — if you don't follow it with a bound key within the window, your next keystroke goes to the editor as normal, @@ -250,6 +255,45 @@ fuzzy file finder over every non-ignored file in the project: inside the editor. - Only files are listed — no directories, no symlinked duplicates. +## Terminal tabs + +`Esc \`` (or **Open terminal in new tab** from the `≡` menu) opens your +`$SHELL` in a new tab, so you can run tests, `git`, or anything else +without dropping the editor or reaching for a second tmux pane. + +The terminal starts in the folder you're currently working in (the +selected file's directory, falling back to the project root), so +relative commands land where you expect. + +It's a real terminal, not a log pane: SpiceEdit embeds a VT emulator and +paints the shell's screen into the tab, so `Ctrl+C`, colours, and +full-screen programs all behave. Because the editor owns the emulation, +none of it leaks into your host terminal — it works over SSH and inside +`tmux` with no passthrough configuration. + +A few deliberate details: + +- **`Esc` belongs to the editor.** It's the only key the terminal never + receives, because double-tapping it is how you get back to the `≡` + menu. Everything else — including `Ctrl+C`, `Ctrl+D`, `Ctrl+Z`, and + arrow-key history — goes to the shell. +- **The `Esc`-leader shortcuts stand down inside a terminal.** Elsewhere + `Esc s` saves and `Esc q` quits, but a shell prompt is the one place + you press `Esc` by reflex, and swallowing the next key to run an editor + action would be both surprising and destructive. Double-tap `Esc` for + the menu instead — every action is still there. +- **Terminal tabs never look "unsaved."** They have no file, so Save, + Find, and the git gutter skip them, and quitting won't prompt about + them. +- **Closing the tab closes the shell**, and quitting the editor closes + every terminal it opened. Backgrounded jobs are hung up with the shell + rather than orphaned. +- **The status bar** shows `terminal · shell running`, or the exit + status once the shell has exited. + +Terminal tabs are unix-only (macOS and Linux). On Windows the menu row +is greyed out, since Windows has no PTY the editor can drive this way. + ## Custom actions (open remote files on your laptop) [![Watch the walkthrough](https://img.youtube.com/vi/vDWZWEmIiZ8/maxresdefault.jpg)](https://www.youtube.com/watch?v=vDWZWEmIiZ8) diff --git a/go.mod b/go.mod index 522eea7..0b8c8b7 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,9 @@ go 1.24.0 require ( github.com/alecthomas/chroma/v2 v2.24.0 + github.com/creack/pty v1.1.24 github.com/gdamore/tcell/v2 v2.13.9 + github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 ) diff --git a/go.sum b/go.sum index 6b0e163..de18a9a 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/alecthomas/chroma/v2 v2.24.0 h1:zrg+k0tAaVbM8whaT2hR5DOUqAdopsDaH998E github.com/alecthomas/chroma/v2 v2.24.0/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= @@ -14,6 +16,8 @@ github.com/gdamore/tcell/v2 v2.13.9 h1:uI5l3DYPcFvHINKlGft+en23evOKL+dwtD21QR8ej github.com/gdamore/tcell/v2 v2.13.9/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY= +github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/internal/app/app.go b/internal/app/app.go index c1fe605..5ac54d9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -22,7 +22,9 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" + "sync" "time" "github.com/gdamore/tcell/v2" @@ -116,6 +118,18 @@ type treeRefreshEvent struct { // When satisfies the tcell.Event interface. func (e *treeRefreshEvent) When() time.Time { return e.when } +// termOutputEvent is posted by a terminal tab's PTY reader goroutine when +// the child shell produces output. It carries no payload — the emulator +// state was already updated under its own lock; this exists purely to +// wake the main loop so it redraws. Following the project's rule that +// background goroutines never mutate UI state directly. +type termOutputEvent struct { + when time.Time +} + +// When satisfies the tcell.Event interface. +func (e *termOutputEvent) When() time.Time { return e.when } + // customActionDoneEvent is posted by runCustomAction when its background // shell-out finishes. Carries the label and any error so the main loop // can flash a sensible status message — running scp / ssh inline would @@ -216,6 +230,7 @@ func builtinMenuGroups() [][]menuItemDef { // View toggle { {shortcut: "Esc t", action: (*App).menuToggleSidebar, enabled: alwaysTrue, labelFor: (*App).sidebarToggleLabel, visible: (*App).hasTree}, + {label: "Open terminal in new tab", shortcut: "Esc `", action: (*App).menuOpenTerminal, enabled: (*App).canOpenTerminal}, }, // Quit { @@ -656,7 +671,7 @@ func (a *App) refreshGitStatus() { // refreshGitLineChanges refreshes gutter markers for every open text tab. func (a *App) refreshGitLineChanges() { for _, tab := range a.tabs { - if tab == nil || tab.Path == "" || tab.IsImage() { + if tab == nil || tab.Path == "" || !tab.IsTextual() { continue } tab.GitLines = loadGitLineChanges(a.rootDir, tab.Path) @@ -698,11 +713,33 @@ func (a *App) stopTreeRefresh() { func (a *App) Close() { a.stopTreeRefresh() a.stopAutoScroll() + a.closeAllTerminals() if a.screen != nil { a.screen.Fini() } } +// closeAllTerminals kills every child shell the session started. Called +// from Close so quitting the editor doesn't orphan them. +// +// The closes run concurrently because each one may wait up to +// terminalCloseGrace for its shell to hang up its jobs; doing that +// serially would freeze the UI for grace × N on the way out. +func (a *App) closeAllTerminals() { + var wg sync.WaitGroup + for _, t := range a.tabs { + if !t.IsTerminal() { + continue + } + wg.Add(1) + go func(tab *editor.Tab) { + defer wg.Done() + tab.CloseTerminal() + }(t) + } + wg.Wait() +} + // Run is the editor's main event loop. It blocks on PollEvent, dispatches // each event, redraws, and exits when a.quit is set. func (a *App) Run() error { @@ -736,6 +773,10 @@ func (a *App) handleEvent(ev tcell.Event) { a.handleAutoScroll() case *treeRefreshEvent: a.refreshTreeNow() + case *termOutputEvent: + // Nothing to do — the terminal's emulator state is already + // current. Falling through to the loop's unconditional redraw + // is the whole point of the event. case *customActionDoneEvent: a.handleCustomActionDone(e) case *formatDoneEvent: @@ -1047,7 +1088,15 @@ func (a *App) handleKey(ev *tcell.EventKey) { // key is bound in the leader table, fire the action and consume the // keystroke. Unbound keys fall through to normal handling so a stray // Esc doesn't swallow the next character the user types. - if !a.lastEscape.IsZero() && time.Since(a.lastEscape) < doubleEscMs { + // + // Terminal tabs opt out of the single-Esc leader entirely. Esc is a + // key shell users press constantly (vi keybindings, cancelling a + // completion, plain habit), and swallowing the *next* rune to run an + // editor action is both surprising and destructive: "Esc" then "q" + // would quit the editor — hanging up every running shell — instead of + // typing "q" at the prompt. Double-Esc still opens the action menu, + // so every action remains reachable. + if !a.lastEscape.IsZero() && time.Since(a.lastEscape) < doubleEscMs && !a.activeTabIsTerminal() { if ev.Key() == tcell.KeyRune { if action := leaderActionFor(ev.Rune()); action != nil { a.lastEscape = time.Time{} @@ -1086,6 +1135,18 @@ func (a *App) handleKey(ev *tcell.EventKey) { if tab == nil { return } + // Terminal tabs forward almost every keystroke to the child shell, + // including the Ctrl keys the editor otherwise refuses to bind — + // there they mean "signal the foreground process", not an editor + // action. Esc never reaches here (it's consumed above for the menu + // and leader table), which is the one sequence a shell user has to + // reach via Esc-Esc → menu instead. + if tab.IsTerminal() { + if b := editor.TerminalKeyBytes(ev); b != nil { + tab.Term.Write(b) + } + return + } // Image-preview tabs are read-only — no cursor, no editing, no // caret movement. Drop every key here so the user can mash arrow // keys without anything mysterious happening behind the splash. @@ -1426,11 +1487,11 @@ func (a *App) syncActiveTreeFile() { } // editorPress handles the initial mouse press inside the editor — placing -// the caret, optionally selecting a word on double-click. Image tabs -// have no caret, so the press is dropped. +// the caret, optionally selecting a word on double-click. Non-text tabs +// (image previews, terminals) have no caret, so the press is dropped. func (a *App) editorPress(x, y int) { tab := a.activeTabPtr() - if tab == nil || tab.IsImage() { + if tab == nil || !tab.IsTextual() { return } ex, ey, ew, eh := a.editorRect() @@ -1478,7 +1539,7 @@ func (a *App) openGitHunkAt(tab *editor.Tab, localX, localY int) bool { // drop the drag entirely. func (a *App) editorDrag(x, y int) { tab := a.activeTabPtr() - if tab == nil || tab.IsImage() { + if tab == nil || !tab.IsTextual() { return } ex, ey, ew, eh := a.editorRect() @@ -1799,6 +1860,9 @@ func (a *App) closeTab(idx int) { if idx < 0 || idx >= len(a.tabs) { return } + // Terminal tabs own a child shell — tear it down with the tab so we + // don't leak a running process for the rest of the session. + a.tabs[idx].CloseTerminal() a.tabs = append(a.tabs[:idx], a.tabs[idx+1:]...) if a.activeTab >= len(a.tabs) { a.activeTab = len(a.tabs) - 1 @@ -1942,7 +2006,7 @@ func (a *App) hasTab() bool { return a.activeTabPtr() != nil } // preview. Used by Save and Save & Close. func (a *App) hasSavableTab() bool { t := a.activeTabPtr() - return t != nil && t.Path != "" && !t.IsImage() + return t != nil && t.Path != "" && t.IsTextual() } // hasFileTab reports whether the active tab is backed by a real file @@ -1963,7 +2027,7 @@ func (a *App) hasSelection() bool { // known single-line comment marker. func (a *App) hasCommentableTab() bool { t := a.activeTabPtr() - if t == nil || t.IsImage() { + if t == nil || !t.IsTextual() { return false } _, ok := editor.LineCommentPrefix(t.Path) @@ -2161,7 +2225,7 @@ func (a *App) menuPaste() { func (a *App) menuToggleLineComment() { a.closeMenu() tab := a.activeTabPtr() - if tab == nil || tab.IsImage() { + if tab == nil || !tab.IsTextual() { return } changed, ok := tab.ToggleLineComment() @@ -2211,6 +2275,74 @@ func (a *App) sidebarToggleLabel() string { return "Show file explorer" } +// activeTabIsTerminal reports whether the focused tab hosts a shell. +// Used to keep the Esc-leader table from stealing keystrokes that belong +// to the terminal. +func (a *App) activeTabIsTerminal() bool { + t := a.activeTabPtr() + return t != nil && t.IsTerminal() +} + +// canOpenTerminal reports whether a terminal tab can be opened. PTYs are +// a unix affair — creack/pty compiles on Windows but every call returns +// ErrUnsupported — so the row is greyed out there rather than offering an +// action that can only fail. +func (a *App) canOpenTerminal() bool { + return runtime.GOOS != "windows" +} + +// terminalCwd picks the working directory a new terminal starts in: the +// folder the user is "in" according to the tree (activeFolder, which +// tracks the selected file's directory), falling back to the project +// root. This is the behaviour that makes `go test ./...` land where the +// user expects instead of at a root they navigated away from. +func (a *App) terminalCwd() string { + if a.activeFolder != "" { + if info, err := os.Stat(a.activeFolder); err == nil && info.IsDir() { + return a.activeFolder + } + } + return a.rootDir +} + +// menuOpenTerminal opens a new tab running the user's shell and focuses +// it. The shell is started at terminalCwd() and sized to the current +// editor pane; the first Render corrects the size if the pane geometry +// differs from our estimate. +// +// Output arrives on a background goroutine which posts termOutputEvent +// to wake the main loop — the goroutine never touches UI state itself. +func (a *App) menuOpenTerminal() { + a.closeMenu() + if !a.canOpenTerminal() { + a.flash("Terminal tabs aren't supported on this platform") + return + } + + w, h := a.editorSize() + scr := a.screen + notify := func() { + // PostEvent can block if the queue is full and the main loop is + // busy; the non-blocking variant would drop redraws. A blocking + // post is correct here because the reader goroutine has nothing + // else to do, and it can't deadlock — the main loop drains the + // queue continuously. + _ = scr.PostEvent(&termOutputEvent{when: time.Now()}) + } + + tab, err := editor.NewTerminalTab(a.terminalCwd(), w, h, notify) + if err != nil { + a.openInfo("Couldn't open terminal", []string{err.Error()}) + return + } + a.tabs = append(a.tabs, tab) + a.activeTab = len(a.tabs) - 1 + // A terminal has no file, so this clears the tree's highlight rather + // than leaving the previously active file looking selected. + a.syncActiveTreeFile() + a.flash("Terminal opened — Esc Esc for the menu") +} + // menuQuit exits the editor. When any tab has unsaved changes, opens the // dirty-close modal so the user can pick Save (save all then quit), // Discard (quit anyway), or Cancel. With no dirty tabs we exit straight @@ -2396,6 +2528,12 @@ func (a *App) drawTabBar() { name := tab.DisplayName() glyph := icons.For(name, false, false) gfg := icons.ColorFor(name, false, fg) + // Terminal tabs aren't files — give them the shell glyph + // instead of the generic "unknown file" one. + if tab.IsTerminal() { + glyph = icons.Terminal + gfg = fg + } gst := tcell.StyleDefault.Background(bg).Foreground(gfg) if active { gst = gst.Bold(true) @@ -2517,7 +2655,16 @@ func (a *App) drawStatusBar() { if time.Now().Before(a.statusUntil) && a.statusMsg != "" { left = " " + a.statusMsg } else if tab := a.activeTabPtr(); tab != nil { - if tab.IsImage() && tab.Image != nil { + if tab.IsTerminal() { + // Terminals have no line/col to report. Show the shell's + // state instead, so an exited shell doesn't look like a + // frozen editor. + if exited, msg := tab.Term.Exited(); exited { + left = " terminal · " + msg + } else { + left = " terminal · shell running" + } + } else if tab.IsImage() && tab.Image != nil { b := tab.Image.Bounds() left = fmt.Sprintf(" %s · %d×%d · %s", strings.ToUpper(tab.ImageFmt), b.Dx(), b.Dy(), filepath.Base(tab.Path)) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index d4059d3..36b78e4 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1663,13 +1663,13 @@ func TestMenuLayout_NoCustomActions(t *testing.T) { a.customActions = nil items, dividers, h := a.menuLayout() - if h != 31 { - t.Errorf("modalHeight = %d, want 31", h) + if h != 32 { + t.Errorf("modalHeight = %d, want 32", h) } - if got := len(items); got != 21 { - t.Errorf("item count = %d, want 21 built-ins", got) + if got := len(items); got != 22 { + t.Errorf("item count = %d, want 22 built-ins", got) } - wantDiv := []int{2, 6, 10, 13, 21, 26, 28} + wantDiv := []int{2, 6, 10, 13, 21, 26, 29} if len(dividers) != len(wantDiv) { t.Fatalf("dividers = %v, want %v", dividers, wantDiv) } @@ -1830,8 +1830,8 @@ func TestMenuLayout_WithCustomActions(t *testing.T) { } items, _, h := a.menuLayout() - if h != 34 { // 31 + 2 items + 1 divider - t.Errorf("modalHeight = %d, want 34", h) + if h != 35 { // 32 + 2 items + 1 divider + t.Errorf("modalHeight = %d, want 35", h) } // Custom actions should be the second-to-last and third-to-last // rows, with Quit as the final row. diff --git a/internal/app/find.go b/internal/app/find.go index b5a8864..8fec2b2 100644 --- a/internal/app/find.go +++ b/internal/app/find.go @@ -33,7 +33,7 @@ const findBarHeight = 1 // search. func (a *App) openFind() { tab := a.activeTabPtr() - if tab == nil || tab.IsImage() { + if tab == nil || !tab.IsTextual() { return } a.closeAllModals() // a modal would otherwise eat our keystrokes @@ -97,7 +97,7 @@ func (a *App) menuFind() { // gray out the menu row on image tabs / no-tab states. func (a *App) hasFindable() bool { t := a.activeTabPtr() - return t != nil && !t.IsImage() + return t != nil && t.IsTextual() } // findBarRect returns the on-screen rectangle of the find bar. Always diff --git a/internal/app/leader.go b/internal/app/leader.go index dba3770..312700c 100644 --- a/internal/app/leader.go +++ b/internal/app/leader.go @@ -47,6 +47,7 @@ func leaderBindings() []leaderBinding { {'/', (*App).menuToggleLineComment}, {'f', (*App).openFind}, {'p', (*App).openFinder}, + {'`', (*App).menuOpenTerminal}, } } diff --git a/internal/app/terminal_test.go b/internal/app/terminal_test.go new file mode 100644 index 0000000..a39b8cf --- /dev/null +++ b/internal/app/terminal_test.go @@ -0,0 +1,409 @@ +// ============================================================================= +// File: internal/app/terminal_test.go +// Author: Spicer Matthews +// Created: 2026-05-02 +// Copyright: 2026 Cloudmanic, LLC. All rights reserved. +// ============================================================================= + +package app + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/gdamore/tcell/v2" + + "github.com/cloudmanic/spice-edit/internal/editor" +) + +// skipWithoutPTY guards the tests that spawn a real shell. +func skipWithoutPTY(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("terminal tabs are unsupported on Windows") + } +} + +// TestMenuOpenTerminal_AppendsAndFocusesTab verifies the menu action adds +// a terminal tab and makes it active, which is what "open terminal in a +// new tab" means to the user. +func TestMenuOpenTerminal_AppendsAndFocusesTab(t *testing.T) { + skipWithoutPTY(t) + a := newTestApp(t, t.TempDir()) + t.Cleanup(a.closeAllTerminals) + + a.menuOpenTerminal() + + if len(a.tabs) != 1 { + t.Fatalf("tab count = %d, want 1", len(a.tabs)) + } + if a.activeTab != 0 { + t.Errorf("activeTab = %d, want 0", a.activeTab) + } + tab := a.activeTabPtr() + if tab == nil || !tab.IsTerminal() { + t.Fatal("active tab is not a terminal tab") + } + if a.menuOpen { + t.Error("menu should be closed after the action runs") + } +} + +// TestMenuOpenTerminal_OpensAlongsideFileTabs verifies a terminal doesn't +// replace or disturb existing file tabs — it's an additional tab. +func TestMenuOpenTerminal_OpensAlongsideFileTabs(t *testing.T) { + skipWithoutPTY(t) + dir := t.TempDir() + target := filepath.Join(dir, "main.go") + if err := os.WriteFile(target, []byte("package main\n"), 0644); err != nil { + t.Fatalf("seed: %v", err) + } + a := newTestApp(t, dir) + t.Cleanup(a.closeAllTerminals) + + a.openFile(target) + a.menuOpenTerminal() + + if len(a.tabs) != 2 { + t.Fatalf("tab count = %d, want 2", len(a.tabs)) + } + if !a.tabs[0].IsTerminal() && a.tabs[0].Path != target { + t.Errorf("first tab should still be the file tab, got %q", a.tabs[0].Path) + } + if !a.tabs[1].IsTerminal() { + t.Error("second tab should be the terminal") + } +} + +// TestMenuOpenTerminal_StartsInActiveFolder pins the cwd choice: the +// shell should start where the user is working, not always at the +// project root, so relative commands land where they expect. +func TestMenuOpenTerminal_StartsInActiveFolder(t *testing.T) { + skipWithoutPTY(t) + root := t.TempDir() + sub := filepath.Join(root, "sub") + if err := os.Mkdir(sub, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + a := newTestApp(t, root) + a.setActiveFolder(sub) + + if got := a.terminalCwd(); got != sub { + t.Errorf("terminalCwd() = %q, want %q", got, sub) + } + + // A stale / deleted active folder must fall back to the root rather + // than handing the shell a directory that no longer exists. + a.setActiveFolder(filepath.Join(root, "does-not-exist")) + if got := a.terminalCwd(); got != a.rootDir { + t.Errorf("terminalCwd() = %q, want root %q", got, a.rootDir) + } +} + +// TestTerminalTab_KeysGoToShell is the integration check that keystrokes +// routed through the app's normal key handler reach the child shell and +// come back as output. +func TestTerminalTab_KeysGoToShell(t *testing.T) { + skipWithoutPTY(t) + a := newTestApp(t, t.TempDir()) + t.Cleanup(a.closeAllTerminals) + a.menuOpenTerminal() + + for _, r := range "echo spice_marker" { + a.handleKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) + } + a.handleKey(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if strings.Contains(editorPaneText(a), "spice_marker") { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("shell never echoed the marker; pane was:\n%s", editorPaneText(a)) +} + +// editorPaneText draws the app and reads the editor pane back out of the +// simulation screen as plain text, so terminal assertions go through the +// exact render path the user sees. +func editorPaneText(a *App) string { + a.draw() + a.screen.Show() + ex, ey, ew, eh := a.editorRect() + var sb strings.Builder + for y := ey; y < ey+eh; y++ { + for x := ex; x < ex+ew; x++ { + ch, _, _, _ := a.screen.GetContent(x, y) + if ch == 0 { + ch = ' ' + } + sb.WriteRune(ch) + } + sb.WriteByte('\n') + } + return sb.String() +} + +// TestTerminalTab_TypingDoesNotDirtyTheTab guards the quit flow: if +// typing into a terminal marked the tab dirty, quitting would pop the +// unsaved-changes modal for a shell that has nothing to save. +func TestTerminalTab_TypingDoesNotDirtyTheTab(t *testing.T) { + skipWithoutPTY(t) + a := newTestApp(t, t.TempDir()) + t.Cleanup(a.closeAllTerminals) + a.menuOpenTerminal() + + for _, r := range "some text" { + a.handleKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) + } + a.handleKey(tcell.NewEventKey(tcell.KeyBackspace2, 0, tcell.ModNone)) + + tab := a.activeTabPtr() + if tab.Dirty { + t.Error("terminal tab became dirty from typing") + } + if got := tab.Buffer.String(); got != "" { + t.Errorf("terminal tab buffer = %q, want empty", got) + } +} + +// TestTerminalTab_EscStillOpensMenu is the key contract that keeps the +// editor usable from inside a shell: Esc must never be swallowed by the +// terminal, or the user would have no way back to the action menu. +func TestTerminalTab_EscStillOpensMenu(t *testing.T) { + skipWithoutPTY(t) + a := newTestApp(t, t.TempDir()) + t.Cleanup(a.closeAllTerminals) + a.menuOpenTerminal() + + a.handleKey(tcell.NewEventKey(tcell.KeyEsc, 0, tcell.ModNone)) + a.handleKey(tcell.NewEventKey(tcell.KeyEsc, 0, tcell.ModNone)) + + if !a.menuOpen { + t.Fatal("double-Esc did not open the action menu from a terminal tab") + } +} + +// TestTerminalTab_LeaderBindingOpensTerminal verifies the Esc-` leader +// key reaches menuOpenTerminal, matching the shortcut advertised in the +// menu row. +func TestTerminalTab_LeaderBindingOpensTerminal(t *testing.T) { + skipWithoutPTY(t) + a := newTestApp(t, t.TempDir()) + t.Cleanup(a.closeAllTerminals) + + action := leaderActionFor('`') + if action == nil { + t.Fatal("Esc-` is not bound in the leader table") + } + action(a) + + if len(a.tabs) != 1 || !a.tabs[0].IsTerminal() { + t.Fatal("Esc-` did not open a terminal tab") + } +} + +// TestCloseTab_ShutsDownTheShell verifies closing the tab tears the child +// process down. Without this the editor would leak a shell per terminal +// tab for the rest of the session. +func TestCloseTab_ShutsDownTheShell(t *testing.T) { + skipWithoutPTY(t) + a := newTestApp(t, t.TempDir()) + a.menuOpenTerminal() + + tab := a.activeTabPtr() + term := tab.Term + proc := term.Process() + if proc == nil { + t.Fatal("terminal has no child process") + } + + a.closeTab(0) + + if len(a.tabs) != 0 { + t.Fatalf("tab count = %d, want 0", len(a.tabs)) + } + // Once the PTY is closed and the process killed, the reader goroutine + // reaps it and flips Exited. + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if exited, _ := term.Exited(); exited { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Error("child shell was still running after the tab was closed") +} + +// TestMenuLayout_TerminalRowPresent pins the menu row itself — label, +// shortcut, and that it's reachable and enabled on this platform. +func TestMenuLayout_TerminalRowPresent(t *testing.T) { + a := newTestApp(t, t.TempDir()) + + item := menuItemByLabel(t, a, "Open terminal in new tab") + if item.action == nil { + t.Fatal("terminal menu row has no action") + } + if item.shortcut != "Esc `" { + t.Errorf("shortcut = %q, want %q", item.shortcut, "Esc `") + } + if runtime.GOOS != "windows" && !item.enabled(a) { + t.Error("terminal row should be enabled on a PTY-capable platform") + } +} + +// TestStatusBar_ShowsTerminalState verifies the status bar reports shell +// state rather than trying to print a line/column for a terminal, which +// has neither. +func TestStatusBar_ShowsTerminalState(t *testing.T) { + skipWithoutPTY(t) + a := newTestApp(t, t.TempDir()) + t.Cleanup(a.closeAllTerminals) + a.menuOpenTerminal() + + // Clear the "Terminal opened" flash so the tab-derived text renders. + a.statusMsg = "" + a.statusUntil = time.Time{} + + a.drawStatusBar() + a.screen.Show() + + if got := statusBarText(a); !strings.Contains(got, "terminal") { + t.Errorf("status bar = %q, want it to mention the terminal", got) + } +} + +// statusBarText reads the rendered status bar row back out of the +// simulation screen. +func statusBarText(a *App) string { + _, sy, sw, _ := a.statusRect() + var sb strings.Builder + for cx := 0; cx < sw; cx++ { + ch, _, _, _ := a.screen.GetContent(cx, sy) + sb.WriteRune(ch) + } + return strings.TrimSpace(sb.String()) +} + +// TestDrawWithTerminalTab_DoesNotPanic exercises the full draw pipeline +// with a terminal as the active tab, including the degenerate tiny-window +// path where the editor rect can collapse. +func TestDrawWithTerminalTab_DoesNotPanic(t *testing.T) { + skipWithoutPTY(t) + a := newTestApp(t, t.TempDir()) + t.Cleanup(a.closeAllTerminals) + a.menuOpenTerminal() + + a.draw() + a.screen.Show() + + // Shrink to the smallest sane size and redraw — the terminal must + // clamp rather than index outside its grid. + a.width, a.height = minWidth, minHeight + a.draw() + a.screen.Show() +} + +// TestTerminalTab_EscLeaderDoesNotStealShellKeys guards a nasty footgun. +// Esc arms the leader table, so at a shell prompt "Esc" then "q" used to +// run menuQuit — killing the editor and every running shell — instead of +// typing "q". Shell users press Esc constantly (vi keybindings, cancelling +// a completion), so the leader table has to stand down for terminal tabs. +func TestTerminalTab_EscLeaderDoesNotStealShellKeys(t *testing.T) { + skipWithoutPTY(t) + a := newTestApp(t, t.TempDir()) + t.Cleanup(a.closeAllTerminals) + a.menuOpenTerminal() + + // Esc, then 'q' well within the leader window. + a.handleKey(tcell.NewEventKey(tcell.KeyEsc, 0, tcell.ModNone)) + a.handleKey(tcell.NewEventKey(tcell.KeyRune, 'q', tcell.ModNone)) + + if a.quit { + t.Fatal("Esc-q quit the editor from a terminal tab; the leader table must not fire there") + } + if a.dirtyOpen { + t.Fatal("Esc-q opened the quit-confirmation modal from a terminal tab") + } + + // The same sequence in a text tab must still work, so this fix + // doesn't silently disable the leader table everywhere. + a.closeTab(0) + if got := len(a.tabs); got != 0 { + t.Fatalf("tab count = %d, want 0", got) + } + a.handleKey(tcell.NewEventKey(tcell.KeyEsc, 0, tcell.ModNone)) + a.handleKey(tcell.NewEventKey(tcell.KeyRune, 'q', tcell.ModNone)) + if !a.quit { + t.Error("Esc-q no longer quits from a non-terminal context") + } +} + +// TestTerminalTab_DoubleEscStillOpensMenuAfterLeaderOptOut makes sure the +// leader opt-out didn't cost terminal tabs their route back to the menu — +// that's the only way to reach editor actions from inside a shell. +func TestTerminalTab_DoubleEscStillOpensMenuAfterLeaderOptOut(t *testing.T) { + skipWithoutPTY(t) + a := newTestApp(t, t.TempDir()) + t.Cleanup(a.closeAllTerminals) + a.menuOpenTerminal() + + a.handleKey(tcell.NewEventKey(tcell.KeyEsc, 0, tcell.ModNone)) + a.handleKey(tcell.NewEventKey(tcell.KeyEsc, 0, tcell.ModNone)) + if !a.menuOpen { + t.Fatal("double-Esc no longer opens the menu from a terminal tab") + } + + // And the menu's own rune shortcuts must still work once it's open. + a.handleKey(tcell.NewEventKey(tcell.KeyEsc, 0, tcell.ModNone)) + if a.menuOpen { + t.Error("Esc did not close the open menu") + } +} + +// TestClose_ReapsTerminalsOnQuit verifies the app-level teardown path: +// quitting the editor must hang up every shell it started, including +// their background jobs. Exercises App.Close rather than a single tab so +// the concurrent closeAllTerminals path is covered too. +func TestClose_ReapsTerminalsOnQuit(t *testing.T) { + skipWithoutPTY(t) + a := newTestApp(t, t.TempDir()) + + a.menuOpenTerminal() + a.menuOpenTerminal() + if len(a.tabs) != 2 { + t.Fatalf("tab count = %d, want 2", len(a.tabs)) + } + + terms := []*editor.Terminal{a.tabs[0].Term, a.tabs[1].Term} + + // Closing must not take grace × N — the shells are hung up in + // parallel, so a serial implementation would stall quit. + start := time.Now() + a.closeAllTerminals() + elapsed := time.Since(start) + + for i, term := range terms { + deadline := time.Now().Add(5 * time.Second) + reaped := false + for time.Now().Before(deadline) { + if exited, _ := term.Exited(); exited { + reaped = true + break + } + time.Sleep(10 * time.Millisecond) + } + if !reaped { + t.Errorf("terminal %d still running after closeAllTerminals", i) + } + } + + if elapsed > 3*time.Second { + t.Errorf("closeAllTerminals took %v; shells should be hung up concurrently", elapsed) + } +} diff --git a/internal/editor/comment.go b/internal/editor/comment.go index ae21088..fde1ac5 100644 --- a/internal/editor/comment.go +++ b/internal/editor/comment.go @@ -97,7 +97,7 @@ func LineCommentPrefix(path string) (string, bool) { // ToggleLineComment comments or uncomments the selected lines. It returns // ok=false when the active file type has no known line-comment marker. func (t *Tab) ToggleLineComment() (changed bool, ok bool) { - if t == nil || t.IsImage() || t.Buffer == nil { + if t == nil || !t.IsTextual() || t.Buffer == nil { return false, false } prefix, ok := LineCommentPrefix(t.Path) diff --git a/internal/editor/tab.go b/internal/editor/tab.go index eadfc19..37f1934 100644 --- a/internal/editor/tab.go +++ b/internal/editor/tab.go @@ -111,6 +111,11 @@ type Tab struct { Image image.Image // populated when Mode == imageMode ImageFmt string // "png" / "jpeg" / "gif" — for the status bar + // Term is the live child shell, populated when Mode == terminalMode. + // See terminal.go — the tab owns it so closing the tab (or the app) + // tears the shell down with it. + Term *Terminal + // Find state — populated when the user opens the find bar and // types a query. The UI layer (App) owns the bar geometry and // keystroke routing; the tab owns the query, the resolved match @@ -200,7 +205,11 @@ func (t *Tab) IsImage() bool { } // DisplayName returns the basename of Path, or "untitled" for unsaved tabs. +// Terminal tabs have no path, so they label themselves "terminal". func (t *Tab) DisplayName() string { + if t.IsTerminal() { + return "terminal" + } if t.Path == "" { return "untitled" } @@ -210,9 +219,12 @@ func (t *Tab) DisplayName() string { // Save writes the buffer to disk and clears Dirty. It is an error to call // Save on an untitled tab — callers should prompt for a path first. Mtime // is refreshed so the disk-reconcile loop doesn't immediately think the -// file we just wrote was changed by someone else. Image tabs return an -// error since the editor only knows how to read those, not re-encode them. +// file we just wrote was changed by someone else. Non-text tabs (image +// previews, terminals) have nothing to write and return an error. func (t *Tab) Save() error { + if t.IsTerminal() { + return fmt.Errorf("terminal tabs have nothing to save") + } if t.IsImage() { return fmt.Errorf("image tabs are read-only") } @@ -241,6 +253,9 @@ func (t *Tab) Save() error { // invalidated. Image tabs decode the file again instead of replacing // the text buffer. func (t *Tab) Reload() error { + if t.IsTerminal() { + return fmt.Errorf("terminal tabs have nothing to reload") + } if t.Path == "" { return fmt.Errorf("no path set for tab") } @@ -300,7 +315,7 @@ func (t *Tab) SelectionText() string { // DeleteSelection removes the selected range and collapses the cursor to the // start of the selection. A no-op when nothing is selected. func (t *Tab) DeleteSelection() { - if t.IsImage() || !t.HasSelection() { + if !t.IsTextual() || !t.HasSelection() { return } // Selection deletes are always their own undo step — they can wipe @@ -321,7 +336,7 @@ func (t *Tab) DeleteSelection() { // structural undo step — pasted text or "\n" presses shouldn't merge // with the surrounding typing burst. No-op on image tabs. func (t *Tab) InsertString(s string) { - if t.IsImage() { + if !t.IsTextual() { return } if t.HasSelection() { @@ -344,7 +359,7 @@ func (t *Tab) InsertString(s string) { // into a single undo step rather than one entry per keystroke. No-op // on image tabs. func (t *Tab) InsertRune(r rune) { - if t.IsImage() { + if !t.IsTextual() { return } if t.HasSelection() { @@ -365,7 +380,7 @@ func (t *Tab) InsertRune(r rune) { // Coalesces with adjacent backspaces inside the undo window. No-op on // image tabs. func (t *Tab) Backspace() { - if t.IsImage() { + if !t.IsTextual() { return } if t.HasSelection() { @@ -394,7 +409,7 @@ func (t *Tab) Backspace() { // Coalesces with adjacent forward-deletes inside the undo window. No-op // on image tabs. func (t *Tab) Delete() { - if t.IsImage() { + if !t.IsTextual() { return } if t.HasSelection() { @@ -539,8 +554,13 @@ func (t *Tab) EnsureVisible(viewW, viewH int) { // Render draws the editor's content (line numbers, code with syntax // highlighting, selection, cursor) into the rectangle (x, y, w, h). -// Image tabs delegate to renderImage instead of drawing text. +// Image tabs delegate to renderImage and terminal tabs to renderTerminal +// instead of drawing text. func (t *Tab) Render(scr tcell.Screen, th theme.Theme, x, y, w, h int) { + if t.IsTerminal() { + t.renderTerminal(scr, th, x, y, w, h) + return + } if t.IsImage() { t.renderImage(scr, th, x, y, w, h) return diff --git a/internal/editor/terminal.go b/internal/editor/terminal.go new file mode 100644 index 0000000..386d0d7 --- /dev/null +++ b/internal/editor/terminal.go @@ -0,0 +1,573 @@ +// ============================================================================= +// File: internal/editor/terminal.go +// Author: Spicer Matthews +// Created: 2026-05-02 +// Copyright: 2026 Cloudmanic, LLC. All rights reserved. +// ============================================================================= + +// terminal.go gives Tab a third mode: a live shell running on a pseudo +// terminal, rendered inside the editor pane like any other tab. The +// motivation is the project's core workflow — you're already SSH'd into a +// box inside tmux; needing a second pane just to run `go test` breaks the +// "one window, mouse-first" feel the editor is going for. +// +// Design, and why: +// +// - We spawn the user's $SHELL on a PTY (creack/pty) and feed its output +// into a virtual terminal emulator (hinshun/vt10x) which maintains a +// cell grid. Render then blits that grid into the tcell screen. We are +// NOT passing the child's escape codes through to the host terminal — +// that would fight the editor for cursor position and scroll region. +// Owning a real emulator is what lets the shell live in a sub-rectangle +// of our layout. +// +// - Both dependencies are pure Go with no CGO, which the project +// requires. On Windows creack/pty compiles but returns +// pty.ErrUnsupported at runtime, so NewTerminalTab surfaces a clean +// error there instead of failing the build. +// +// - The PTY read loop runs in a goroutine, but it does NOT touch UI +// state. It writes into vt10x (which is internally mutex-guarded) and +// then notifies the app via a callback so the app can post a tcell +// event and redraw on the main loop. This follows the existing +// "custom tcell events for goroutine → main-loop messaging" pattern. + +package editor + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strings" + "sync" + "time" + + "github.com/creack/pty" + "github.com/gdamore/tcell/v2" + "github.com/hinshun/vt10x" + + "github.com/cloudmanic/spice-edit/internal/theme" +) + +// terminalMode is the value Tab.Mode takes when the tab hosts a shell +// rather than a file. Defined here, next to the behaviour it unlocks, +// mirroring how imageMode lives in image.go. +const terminalMode = "terminal" + +// termMinCols / termMinRows are the floor we report to the child process. +// A zero or negative winsize makes many shells (and most full-screen TUIs) +// misbehave, and Render can legitimately be handed a 0-width rect while +// the layout is settling or the sidebar is mid-drag. +const ( + termMinCols = 2 + termMinRows = 1 +) + +// terminalCloseGrace is how long Close waits after SIGHUP for the shell +// to hang up its jobs and exit on its own before escalating to SIGKILL. +// Long enough for bash/zsh to run their exit path, short enough that +// quitting the editor still feels instant. +const terminalCloseGrace = 300 * time.Millisecond + +// vt10x keeps its glyph attribute bits unexported, so we mirror them here. +// These are the bit positions from vt10x's state.go (attrReverse first, +// then underline, bold, gfx, italic, blink) and are part of the on-wire +// meaning of Glyph.Mode, so they're stable. +const ( + termAttrReverse = 1 << iota + termAttrUnderline + termAttrBold + termAttrGfx + termAttrItalic + termAttrBlink +) + +// Terminal owns one child shell: the PTY master, the process handle, the +// vt10x emulator holding the screen grid, and the lifecycle flags the UI +// reads. It is created and owned by a Tab in terminalMode. +// +// Concurrency: the emulator has its own lock (Lock/Unlock) and is safe to +// write from the reader goroutine while the main loop renders from it. +// The plain fields below are guarded by mu because the reader goroutine +// sets exited/exitMsg when the shell dies. +type Terminal struct { + vt vt10x.Terminal + ptmx *os.File + cmd *exec.Cmd + + mu sync.Mutex + exited bool + exitMsg string + + // cols / rows track the size we last told the child about, so + // Resize can skip redundant ioctls on every single redraw. + cols, rows int + + // notify is called (from the reader goroutine) whenever new output + // has been parsed, so the app can wake its event loop and redraw. + // It must be safe to call from a non-main goroutine — the app + // passes a closure that only does screen.PostEvent. + notify func() + + // closeOnce guards Close so a double close (user closes the tab of + // an already-exited shell) can't panic on a second file close. + closeOnce sync.Once +} + +// shellCommand picks the shell to launch. $SHELL is the user's explicit +// choice and wins; otherwise fall back to sh, which exists on every unix +// the editor targets. We deliberately start it as an interactive login-ish +// shell ("-i") so the user's aliases and prompt show up — a bare +// non-interactive sh gives a jarring, promptless black box. +func shellCommand() (string, []string) { + sh := os.Getenv("SHELL") + if sh == "" { + sh = "/bin/sh" + } + return sh, []string{"-i"} +} + +// NewTerminalTab starts a shell on a PTY rooted at dir and returns a Tab +// that renders it. cols / rows are the initial viewport; they get +// corrected on the first Render once the real editor rect is known. +// +// notify is invoked from the PTY reader goroutine each time output +// arrives; the caller should use it to post a custom tcell event (never +// to mutate UI state directly). +// +// The returned Tab has an empty Buffer allocated so the mass of existing +// code that pokes at t.Buffer doesn't need a nil check, exactly like +// image tabs. +func NewTerminalTab(dir string, cols, rows int, notify func()) (*Tab, error) { + if runtime.GOOS == "windows" { + // creack/pty compiles on Windows but every entry point returns + // ErrUnsupported. Say so plainly rather than letting the user + // stare at an empty tab. + return nil, fmt.Errorf("terminal tabs are not supported on Windows") + } + if cols < termMinCols { + cols = termMinCols + } + if rows < termMinRows { + rows = termMinRows + } + + name, args := shellCommand() + cmd := exec.Command(name, args...) + cmd.Dir = dir + // TERM: vt10x implements a vt100-family emulator, so advertise + // xterm-256color to get colour without the child assuming + // capabilities (sixel, kitty graphics) we can't honour. + // + // We also strip any inherited COLUMNS / LINES: those would override + // the winsize we just set and leave the child laying out to the host + // terminal's width instead of our pane's. + cmd.Env = append(filteredEnv(), "TERM=xterm-256color") + + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{ + Cols: uint16(cols), + Rows: uint16(rows), + }) + if err != nil { + return nil, fmt.Errorf("start terminal: %w", err) + } + + term := &Terminal{ + vt: vt10x.New(vt10x.WithWriter(ptmx), vt10x.WithSize(cols, rows)), + ptmx: ptmx, + cmd: cmd, + cols: cols, + rows: rows, + notify: notify, + } + go term.readLoop() + + t := &Tab{ + Buffer: NewBuffer(""), + Mode: terminalMode, + Term: term, + } + // Give undo/revert a snapshot to look at so CanRevert and friends + // answer "nothing to revert" instead of reading a zero value. + t.initUndo() + return t, nil +} + +// filteredEnv returns the parent environment minus the variables that +// would confuse a child laid out for our pane: COLUMNS / LINES describe +// the *host* terminal, and a stale TERM would be overridden anyway. +func filteredEnv() []string { + src := os.Environ() + out := make([]string, 0, len(src)) + for _, kv := range src { + switch { + case strings.HasPrefix(kv, "COLUMNS="), + strings.HasPrefix(kv, "LINES="), + strings.HasPrefix(kv, "TERM="): + continue + } + out = append(out, kv) + } + return out +} + +// readLoop pumps PTY output into the emulator until the shell exits or +// the PTY is closed. It never touches UI state — it parses into vt10x +// (which locks internally) and then pings notify so the main loop +// redraws. On exit it records the child's status for the status bar. +func (tm *Terminal) readLoop() { + buf := make([]byte, 32*1024) + for { + n, err := tm.ptmx.Read(buf) + if n > 0 { + // vt10x.Write locks the state for the duration of the + // parse, so this is safe against a concurrent Render. + _, _ = tm.vt.Write(buf[:n]) + if tm.notify != nil { + tm.notify() + } + } + if err != nil { + // Read fails with EIO on Linux when the child exits and + // the slave side closes — that's the normal path, not an + // error worth showing. Wait for the real exit status. + break + } + } + + msg := "shell exited" + if err := tm.cmd.Wait(); err != nil { + msg = fmt.Sprintf("shell exited: %v", err) + } + tm.mu.Lock() + tm.exited = true + tm.exitMsg = msg + tm.mu.Unlock() + if tm.notify != nil { + tm.notify() + } +} + +// Exited reports whether the child shell has terminated, along with a +// short human-readable status for the status bar. +func (tm *Terminal) Exited() (bool, string) { + tm.mu.Lock() + defer tm.mu.Unlock() + return tm.exited, tm.exitMsg +} + +// Process returns the child shell's process handle, or nil if it never +// started. Exposed so callers (and tests) can check on the child without +// reaching into the command. +func (tm *Terminal) Process() *os.Process { + if tm.cmd == nil { + return nil + } + return tm.cmd.Process +} + +// Write forwards user input to the child shell. It's a no-op once the +// shell has exited so stray keystrokes on a dead terminal don't raise +// write errors the user can't act on. +func (tm *Terminal) Write(p []byte) { + if exited, _ := tm.Exited(); exited { + return + } + _, _ = tm.ptmx.Write(p) +} + +// Resize tells both the emulator and the child process about a new +// viewport size. Skipped when nothing changed, since Render calls this +// on every frame and a TIOCSWINSZ ioctl per redraw would spam SIGWINCH +// at the shell (which redraws its prompt every time it gets one). +func (tm *Terminal) Resize(cols, rows int) { + if cols < termMinCols { + cols = termMinCols + } + if rows < termMinRows { + rows = termMinRows + } + tm.mu.Lock() + unchanged := tm.cols == cols && tm.rows == rows + tm.mu.Unlock() + if unchanged { + return + } + + tm.vt.Resize(cols, rows) + if err := pty.Setsize(tm.ptmx, &pty.Winsize{Cols: uint16(cols), Rows: uint16(rows)}); err != nil { + // Leave tm.cols/rows alone so the next Render retries. Recording + // the new size here would make the "unchanged" fast path above + // skip every future attempt, leaving the child wedged at a stale + // winsize for the rest of the session. + return + } + tm.mu.Lock() + tm.cols, tm.rows = cols, rows + tm.mu.Unlock() +} + +// Close tears the terminal down. Getting this right matters more than it +// looks: the naive "close the PTY and SIGKILL the shell" leaks every +// backgrounded job the user started, because a SIGKILLed bash never runs +// its exit path and so never hangs up its own children. +// +// So we do what a terminal emulator does when its window closes: +// +// 1. Close the PTY master. The child's next read/write gets EIO / SIGHUP. +// 2. Send SIGHUP to the child's *process group* — pty.StartWithSize sets +// Setsid, making the shell a session leader whose PGID equals its PID, +// so -pid reaches the shell and every job it spawned. SIGHUP is the +// signal shells actually handle by hanging up their jobs. +// 3. Give it a short grace period to die on its own. +// 4. If it's still there, SIGKILL the group as a last resort. +// +// Safe to call twice (closing an already-exited tab, then again at app +// shutdown). +func (tm *Terminal) Close() { + tm.closeOnce.Do(func() { + if tm.ptmx != nil { + _ = tm.ptmx.Close() + } + proc := tm.Process() + if proc == nil { + return + } + // If readLoop already reaped the child, its PID is free for the + // kernel to reuse — signalling it now could hit an unrelated + // process group. There's nothing left to clean up anyway. + if exited, _ := tm.Exited(); exited { + return + } + + tm.hangupGroup(proc.Pid) + + // Poll rather than Wait — readLoop owns cmd.Wait() and calling + // it from two goroutines is undefined. + deadline := time.Now().Add(terminalCloseGrace) + for time.Now().Before(deadline) { + if exited, _ := tm.Exited(); exited { + return + } + time.Sleep(5 * time.Millisecond) + } + + if exited, _ := tm.Exited(); !exited { + tm.killGroup(proc.Pid) + } + }) +} + +// IsTerminal reports whether the tab hosts a shell rather than a file. +// Callers use this (like IsImage) to skip file-oriented behaviour +// without knowing about Mode strings. +func (t *Tab) IsTerminal() bool { + return t.Mode == terminalMode +} + +// IsTextual reports whether the tab is an ordinary editable text buffer. +// Both alternate modes (image preview, terminal) answer false. +// +// This exists because nearly every guard in the app means "is this a +// normal text tab", not "is this specifically not an image" — before +// terminal tabs there was only one alternate mode, so `!IsImage()` was +// an accidentally-correct spelling of it. New non-text modes should be +// added here rather than bolting another negation onto every call site. +func (t *Tab) IsTextual() bool { + return t.Mode == "" +} + +// CloseTerminal shuts down the tab's child shell if it has one. Called +// when the tab is closed and when the app exits, so the shell doesn't +// outlive the editor. +func (t *Tab) CloseTerminal() { + if t.Term != nil { + t.Term.Close() + } +} + +// renderTerminal blits the emulator's cell grid into the editor pane and +// places the hardware cursor where the shell put it. The grid is sized to +// the pane by Resize first, so this is a straight cell-for-cell copy — +// no scrolling or clamping of our own, because the shell (and any +// full-screen program inside it) owns that entirely. +func (t *Tab) renderTerminal(scr tcell.Screen, th theme.Theme, x, y, w, h int) { + if w <= 0 || h <= 0 { + return + } + tm := t.Term + if tm == nil { + return + } + + tm.Resize(w, h) + + tm.vt.Lock() + defer tm.vt.Unlock() + + // Iterate the intersection of the pane and the emulator's real grid + // rather than trusting them to agree. vt10x.Cell panics on an + // out-of-range index, and the two sizes are tracked in different + // places (our tm.cols/rows vs. the emulator's own), so deriving the + // bound from the grid itself is what keeps a future desync from + // turning into a crash mid-render. + gridCols, gridRows := tm.vt.Size() + rows := min(h, gridRows) + cols := min(w, gridCols) + + // Any pane cells beyond the grid get the editor background so a + // transient size mismatch reads as empty space, not stale pixels. + blank := tcell.StyleDefault.Background(th.BG) + for row := 0; row < h; row++ { + for col := 0; col < w; col++ { + if row < rows && col < cols { + g := tm.vt.Cell(col, row) + ch := g.Char + if ch == 0 { + ch = ' ' + } + scr.SetContent(x+col, y+row, ch, nil, glyphStyle(g, th)) + continue + } + scr.SetContent(x+col, y+row, ' ', nil, blank) + } + } + + cur := tm.vt.Cursor() + if tm.vt.CursorVisible() && cur.X >= 0 && cur.X < cols && cur.Y >= 0 && cur.Y < rows { + scr.ShowCursor(x+cur.X, y+cur.Y) + } else { + scr.HideCursor() + } +} + +// glyphStyle converts a vt10x glyph's colours and attributes into a tcell +// style, mapping the emulator's "default" colours onto the editor theme so +// an unstyled shell blends into the surrounding UI instead of rendering on +// pure black. +func glyphStyle(g vt10x.Glyph, th theme.Theme) tcell.Style { + fg := termColor(g.FG, th) + bg := termColor(g.BG, th) + + st := tcell.StyleDefault.Foreground(fg).Background(bg) + // Deliberately no st.Reverse(): vt10x already swapped FG/BG into the + // stored cell (see setChar in its state.go) while *also* leaving the + // reverse bit set in Mode. Honouring the bit here would swap a second + // time and cancel the effect out, making every reverse-video construct + // — less's status line, git add -p, fzf selections, vim's visual + // selection — render as plain text. + if g.Mode&termAttrUnderline != 0 { + st = st.Underline(true) + } + if g.Mode&termAttrBold != 0 { + st = st.Bold(true) + } + if g.Mode&termAttrItalic != 0 { + st = st.Italic(true) + } + return st +} + +// termColor maps a vt10x colour to a tcell colour. vt10x encodes the 16 +// ANSI colours and the 256-colour palette as small integers, truecolor as +// a packed 0xRRGGBB, and its three "default" colours as sentinels above +// 1<<24. +// +// The sentinels are resolved by *meaning*, not by which slot they were +// found in: DefaultFG always becomes the theme's text colour and +// DefaultBG always the theme's background. That distinction is what makes +// reverse video work. vt10x implements reverse by swapping a cell's FG and +// BG, so a reversed default cell arrives with FG=DefaultBG and +// BG=DefaultFG — mapping each sentinel to a positional fallback would +// collapse both back to the normal pair and silently undo the swap. +func termColor(c vt10x.Color, th theme.Theme) tcell.Color { + switch c { + case vt10x.DefaultFG: + return th.Text + case vt10x.DefaultBG: + return th.BG + case vt10x.DefaultCursor: + return th.Text + } + if c < 256 { + // Palette index — tcell's first 256 colours are the same + // xterm palette vt10x is indexing into. + return tcell.PaletteColor(int(c)) + } + if c < 1<<24 { + return tcell.NewRGBColor(int32(c>>16&0xff), int32(c>>8&0xff), int32(c&0xff)) + } + return th.Text +} + +// TerminalKeyBytes translates a tcell key event into the byte sequence a +// PTY-attached shell expects. Returns nil when the key carries no meaning +// for a terminal, so the caller can drop it. +// +// The escape sequences are the standard xterm ones; vt10x's own parser and +// every shell/readline implementation agree on these. Note Esc itself is +// intentionally NOT translated here — the app reserves Esc for its action +// menu, so the terminal gets it only via the explicit Esc-leader path. +func TerminalKeyBytes(ev *tcell.EventKey) []byte { + switch ev.Key() { + case tcell.KeyRune: + r := ev.Rune() + // Alt+ is sent as ESC-prefixed, which is how xterm + // encodes Meta and how readline expects Alt-b / Alt-f. + if ev.Modifiers()&tcell.ModAlt != 0 { + return append([]byte{0x1b}, []byte(string(r))...) + } + return []byte(string(r)) + case tcell.KeyEnter: + return []byte{'\r'} + case tcell.KeyTab: + return []byte{'\t'} + case tcell.KeyBackspace, tcell.KeyBackspace2: + // DEL (0x7f), not BS — this is what readline and every modern + // shell treat as "erase previous character". + return []byte{0x7f} + case tcell.KeyUp: + return []byte("\x1b[A") + case tcell.KeyDown: + return []byte("\x1b[B") + case tcell.KeyRight: + return []byte("\x1b[C") + case tcell.KeyLeft: + return []byte("\x1b[D") + case tcell.KeyHome: + return []byte("\x1b[H") + case tcell.KeyEnd: + return []byte("\x1b[F") + case tcell.KeyPgUp: + return []byte("\x1b[5~") + case tcell.KeyPgDn: + return []byte("\x1b[6~") + case tcell.KeyDelete: + return []byte("\x1b[3~") + case tcell.KeyInsert: + return []byte("\x1b[2~") + } + + // Control keys reach us in one of two encodings, and we have to + // honour both: + // + // • Real terminal input (input.go) posts KeyCtrlSpace+, so + // Ctrl-C arrives as KeyCtrlC == 67, not as byte 3. + // • NewEventKey and the simulation screen post the raw control + // byte as the Key, so Ctrl-C arrives as KeyETX == 3. + // + // This is the one place the editor *does* want Ctrl keys: they're + // going to the shell as signals, not to an editor action. + k := ev.Key() + switch { + case k >= tcell.KeyCtrlSpace && k <= tcell.KeyCtrlUnderscore: + return []byte{byte(k - tcell.KeyCtrlSpace)} + case k > 0 && k <= 0x1f && k != tcell.KeyEsc: + // Raw control byte. Esc is excluded on purpose — the editor + // reserves it for the action menu and the leader table, so + // forwarding it here would make Esc ambiguous. + return []byte{byte(k)} + } + return nil +} diff --git a/internal/editor/terminal_test.go b/internal/editor/terminal_test.go new file mode 100644 index 0000000..68c8df5 --- /dev/null +++ b/internal/editor/terminal_test.go @@ -0,0 +1,509 @@ +// ============================================================================= +// File: internal/editor/terminal_test.go +// Author: Spicer Matthews +// Created: 2026-05-02 +// Copyright: 2026 Cloudmanic, LLC. All rights reserved. +// ============================================================================= + +package editor + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/hinshun/vt10x" + + "github.com/cloudmanic/spice-edit/internal/theme" +) + +// newTestTerminal starts a real shell on a PTY for tests that need one, +// skipping on platforms where PTYs aren't available. The tab is closed +// via t.Cleanup so a failing assertion can't leak a shell process. +func newTestTerminal(t *testing.T, cols, rows int, notify func()) *Tab { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("terminal tabs are unsupported on Windows") + } + tab, err := NewTerminalTab(t.TempDir(), cols, rows, notify) + if err != nil { + t.Fatalf("NewTerminalTab: %v", err) + } + t.Cleanup(tab.CloseTerminal) + return tab +} + +// waitFor polls cond until it holds or the deadline passes. Terminal +// output arrives asynchronously from the PTY reader goroutine, so tests +// can't assert immediately after writing — but they also mustn't sleep a +// fixed duration and hope. +func waitFor(t *testing.T, timeout time.Duration, cond func() bool) bool { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return true + } + time.Sleep(10 * time.Millisecond) + } + return cond() +} + +// TestNewTerminalTab_ModePredicates verifies a terminal tab reports the +// right mode predicates, since every guard in the app pivots on these. +func TestNewTerminalTab_ModePredicates(t *testing.T) { + tab := newTestTerminal(t, 40, 10, nil) + + if !tab.IsTerminal() { + t.Error("IsTerminal() = false, want true") + } + if tab.IsImage() { + t.Error("IsImage() = true, want false") + } + if tab.IsTextual() { + t.Error("IsTextual() = true, want false for a terminal tab") + } + if tab.Term == nil { + t.Fatal("Term is nil") + } + if tab.Buffer == nil { + t.Error("Buffer should be allocated so buffer-poking code needn't nil-check") + } +} + +// TestTerminalTab_DisplayNameAndNoPath pins the tab-bar label and the +// fact that a terminal has no file path — the latter is what keeps it +// out of Save / Rename / git-status code paths. +func TestTerminalTab_DisplayNameAndNoPath(t *testing.T) { + tab := newTestTerminal(t, 40, 10, nil) + + if got := tab.DisplayName(); got != "terminal" { + t.Errorf("DisplayName() = %q, want %q", got, "terminal") + } + if tab.Path != "" { + t.Errorf("Path = %q, want empty", tab.Path) + } +} + +// TestTerminalTab_MutatorsAreNoOps verifies the text-editing entry points +// refuse to touch a terminal tab. A stray InsertRune here would corrupt +// the unused buffer and, worse, mark the tab dirty and block quit. +func TestTerminalTab_MutatorsAreNoOps(t *testing.T) { + tab := newTestTerminal(t, 40, 10, nil) + + tab.InsertRune('x') + tab.InsertString("hello") + tab.Backspace() + tab.Delete() + tab.DeleteSelection() + + if got := tab.Buffer.String(); got != "" { + t.Errorf("buffer = %q, want empty after mutator calls", got) + } + if tab.Dirty { + t.Error("Dirty = true; a terminal tab must never look unsaved") + } + if changed, ok := tab.ToggleLineComment(); changed || ok { + t.Errorf("ToggleLineComment() = (%v, %v), want (false, false)", changed, ok) + } +} + +// TestTerminalTab_SaveAndReloadError verifies the file-oriented +// operations report a clear error rather than silently doing nothing or +// panicking on the empty Path. +func TestTerminalTab_SaveAndReloadError(t *testing.T) { + tab := newTestTerminal(t, 40, 10, nil) + + if err := tab.Save(); err == nil { + t.Error("Save() = nil, want an error for a terminal tab") + } + if err := tab.Reload(); err == nil { + t.Error("Reload() = nil, want an error for a terminal tab") + } +} + +// TestTerminal_EchoRoundTrip is the end-to-end check: write a command to +// the shell and assert it shows up in the emulator's grid. This is what +// proves the PTY, the reader goroutine, and the vt10x parse path are all +// actually wired together. +func TestTerminal_EchoRoundTrip(t *testing.T) { + notified := make(chan struct{}, 64) + tab := newTestTerminal(t, 60, 12, func() { + select { + case notified <- struct{}{}: + default: + } + }) + + tab.Term.Write([]byte("echo spice_marker\r")) + + found := waitFor(t, 5*time.Second, func() bool { + return strings.Contains(terminalText(tab, 60, 12), "spice_marker") + }) + if !found { + t.Fatalf("shell output never contained the marker; grid was:\n%s", + terminalText(tab, 60, 12)) + } + + select { + case <-notified: + default: + t.Error("notify callback was never invoked for shell output") + } +} + +// terminalText dumps the emulator grid as plain text so assertions can +// search it without caring about styling or exact cursor placement. +func terminalText(tab *Tab, cols, rows int) string { + var sb strings.Builder + tab.Term.vt.Lock() + defer tab.Term.vt.Unlock() + for y := 0; y < rows; y++ { + for x := 0; x < cols; x++ { + ch := tab.Term.vt.Cell(x, y).Char + if ch == 0 { + ch = ' ' + } + sb.WriteRune(ch) + } + sb.WriteByte('\n') + } + return sb.String() +} + +// TestTerminal_ResizeIsIdempotent verifies a repeat Resize to the same +// dimensions is a no-op. Render calls Resize every frame, and resizing +// for real each time would fire SIGWINCH at the shell continuously. +func TestTerminal_ResizeIsIdempotent(t *testing.T) { + tab := newTestTerminal(t, 40, 10, nil) + + tab.Term.Resize(50, 20) + cols, rows := tab.Term.vt.Size() + if cols != 50 || rows != 20 { + t.Fatalf("emulator size = %dx%d, want 50x20", cols, rows) + } + + // Second identical call must leave the recorded size untouched. + tab.Term.Resize(50, 20) + tab.Term.mu.Lock() + gotCols, gotRows := tab.Term.cols, tab.Term.rows + tab.Term.mu.Unlock() + if gotCols != 50 || gotRows != 20 { + t.Errorf("tracked size = %dx%d, want 50x20", gotCols, gotRows) + } +} + +// TestTerminal_ResizeClampsToMinimum guards the degenerate rects the app +// hands us mid-layout: a zero or negative winsize makes shells and +// full-screen TUIs misbehave. +func TestTerminal_ResizeClampsToMinimum(t *testing.T) { + tab := newTestTerminal(t, 40, 10, nil) + + tab.Term.Resize(0, 0) + cols, rows := tab.Term.vt.Size() + if cols < termMinCols || rows < termMinRows { + t.Errorf("size = %dx%d, want at least %dx%d", cols, rows, termMinCols, termMinRows) + } +} + +// TestTerminal_CloseIsIdempotent verifies a double close (tab closed +// after the shell already exited, then again at app shutdown) doesn't +// panic on a second file close or process kill. +func TestTerminal_CloseIsIdempotent(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("terminal tabs are unsupported on Windows") + } + tab, err := NewTerminalTab(t.TempDir(), 40, 10, nil) + if err != nil { + t.Fatalf("NewTerminalTab: %v", err) + } + tab.CloseTerminal() + tab.CloseTerminal() // must not panic +} + +// TestTerminal_CloseReapsBackgroundJobs is a regression test for a real +// leak: closing the terminal used to SIGKILL the shell outright, which +// meant bash never ran its exit path and never hung up its own jobs — so +// every `foo &` the user started outlived the editor. Close now SIGHUPs +// the process group first, which is what makes the shell clean up. +func TestTerminal_CloseReapsBackgroundJobs(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("terminal tabs are unsupported on Windows") + } + // A distinctive sleep duration so we can find this exact process + // without matching other tests' or the machine's sleeps. + const marker = "4243" + + tab, err := NewTerminalTab(t.TempDir(), 60, 12, nil) + if err != nil { + t.Fatalf("NewTerminalTab: %v", err) + } + t.Cleanup(tab.CloseTerminal) + + tab.Term.Write([]byte("sleep " + marker + " &\r")) + + // Wait for the job to actually exist before closing, otherwise we'd + // be asserting on a race we already won. + if !waitFor(t, 5*time.Second, func() bool { return sleepJobAlive(marker) }) { + t.Skip("shell never started the background job; can't test teardown") + } + + tab.CloseTerminal() + + if !waitFor(t, 5*time.Second, func() bool { return !sleepJobAlive(marker) }) { + // Don't leave the orphan behind for the next test run. + exec.Command("pkill", "-f", "sleep "+marker).Run() + t.Fatal("background job survived CloseTerminal — the shell was killed without hanging up its jobs") + } +} + +// sleepJobAlive reports whether a `sleep ` process is running, by +// reading /proc rather than shelling out to pgrep so the check itself +// can't match its own command line. +func sleepJobAlive(marker string) bool { + entries, err := os.ReadDir("/proc") + if err != nil { + return false + } + for _, e := range entries { + if _, err := strconv.Atoi(e.Name()); err != nil { + continue // not a pid directory + } + raw, err := os.ReadFile(filepath.Join("/proc", e.Name(), "cmdline")) + if err != nil { + continue + } + args := strings.Split(strings.TrimRight(string(raw), "\x00"), "\x00") + if len(args) == 2 && filepath.Base(args[0]) == "sleep" && args[1] == marker { + return true + } + } + return false +} + +// TestTerminal_ExitedAfterShellExits verifies the reader goroutine +// records the child's exit so the status bar can say so instead of +// looking like a frozen editor. +func TestTerminal_ExitedAfterShellExits(t *testing.T) { + tab := newTestTerminal(t, 40, 10, nil) + + tab.Term.Write([]byte("exit\r")) + + if !waitFor(t, 5*time.Second, func() bool { + exited, _ := tab.Term.Exited() + return exited + }) { + t.Fatal("terminal never reported the shell as exited") + } + + exited, msg := tab.Term.Exited() + if !exited || msg == "" { + t.Errorf("Exited() = (%v, %q), want (true, non-empty)", exited, msg) + } + + // Writing to a dead shell must be a silent no-op, not a panic or a + // surfaced error the user can't act on. + tab.Term.Write([]byte("echo after-exit\r")) +} + +// TestRenderTerminal_DrawsGridAndCursor renders a terminal tab into a +// simulation screen and asserts the emulator contents land in the right +// cells, offset by the pane origin. +func TestRenderTerminal_DrawsGridAndCursor(t *testing.T) { + scr := tcell.NewSimulationScreen("UTF-8") + if err := scr.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + defer scr.Fini() + scr.SetSize(60, 20) + + tab := newTestTerminal(t, 40, 8, nil) + + // Feed the emulator directly so the assertion doesn't depend on the + // user's shell prompt or startup files. + tab.Term.vt.Write([]byte("AB")) + + const originX, originY = 5, 3 + tab.Render(scr, theme.Default(), originX, originY, 40, 8) + scr.Show() + + if got, _, _, _ := scr.GetContent(originX, originY); got != 'A' { + t.Errorf("cell at pane origin = %q, want 'A'", got) + } + if got, _, _, _ := scr.GetContent(originX+1, originY); got != 'B' { + t.Errorf("cell at origin+1 = %q, want 'B'", got) + } +} + +// TestRenderTerminal_IgnoresZeroSizedRects guards the pathological rects +// the app can produce during a tiny window or right after a resize. +func TestRenderTerminal_IgnoresZeroSizedRects(t *testing.T) { + scr := tcell.NewSimulationScreen("UTF-8") + if err := scr.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + defer scr.Fini() + scr.SetSize(20, 10) + + tab := newTestTerminal(t, 20, 5, nil) + + tab.Render(scr, theme.Default(), 0, 0, 0, 0) + tab.Render(scr, theme.Default(), 0, 0, -4, -2) +} + +// TestTerminalKeyBytes covers the key-to-PTY translation table. These +// sequences are what every shell and readline implementation expects, so +// a regression here silently breaks arrow-key history or Ctrl-C. +func TestTerminalKeyBytes(t *testing.T) { + cases := []struct { + name string + ev *tcell.EventKey + want string + }{ + {"rune", tcell.NewEventKey(tcell.KeyRune, 'a', tcell.ModNone), "a"}, + {"enter sends CR", tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone), "\r"}, + {"tab", tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone), "\t"}, + {"backspace sends DEL", tcell.NewEventKey(tcell.KeyBackspace2, 0, tcell.ModNone), "\x7f"}, + {"up", tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone), "\x1b[A"}, + {"down", tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone), "\x1b[B"}, + {"right", tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone), "\x1b[C"}, + {"left", tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone), "\x1b[D"}, + {"home", tcell.NewEventKey(tcell.KeyHome, 0, tcell.ModNone), "\x1b[H"}, + {"end", tcell.NewEventKey(tcell.KeyEnd, 0, tcell.ModNone), "\x1b[F"}, + {"delete", tcell.NewEventKey(tcell.KeyDelete, 0, tcell.ModNone), "\x1b[3~"}, + {"pgup", tcell.NewEventKey(tcell.KeyPgUp, 0, tcell.ModNone), "\x1b[5~"}, + {"pgdn", tcell.NewEventKey(tcell.KeyPgDn, 0, tcell.ModNone), "\x1b[6~"}, + // Real terminal input encodes control keys as KeyCtrlSpace+byte. + {"ctrl-c (tty encoding)", tcell.NewEventKey(tcell.KeyCtrlC, 'c', tcell.ModCtrl), "\x03"}, + {"ctrl-d (tty encoding)", tcell.NewEventKey(tcell.KeyCtrlD, 'd', tcell.ModCtrl), "\x04"}, + {"ctrl-z (tty encoding)", tcell.NewEventKey(tcell.KeyCtrlZ, 'z', tcell.ModCtrl), "\x1a"}, + // NewEventKey / the simulation screen post the raw control byte. + {"ctrl-c (raw byte encoding)", tcell.NewEventKey(tcell.KeyETX, 0, tcell.ModCtrl), "\x03"}, + {"ctrl-d (raw byte encoding)", tcell.NewEventKey(tcell.KeyEOT, 0, tcell.ModCtrl), "\x04"}, + {"alt-rune is ESC prefixed", tcell.NewEventKey(tcell.KeyRune, 'b', tcell.ModAlt), "\x1bb"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := string(TerminalKeyBytes(tc.ev)); got != tc.want { + t.Errorf("TerminalKeyBytes() = %q, want %q", got, tc.want) + } + }) + } +} + +// TestTerminalKeyBytes_EscapeIsNotForwarded pins the deliberate +// exception: Esc belongs to the editor's action menu, so the terminal +// key encoder must not claim it. +func TestTerminalKeyBytes_EscapeIsNotForwarded(t *testing.T) { + if got := TerminalKeyBytes(tcell.NewEventKey(tcell.KeyEsc, 0, tcell.ModNone)); got != nil { + t.Errorf("TerminalKeyBytes(Esc) = %q, want nil", got) + } +} + +// TestTermColor verifies the emulator-to-tcell colour mapping, including +// the "default" sentinels that must fall back to the editor theme so an +// unstyled shell blends into the surrounding UI. +func TestTermColor(t *testing.T) { + th := theme.Default() + + // The sentinels resolve by meaning, not by slot — this is what keeps + // reverse video (which swaps FG and BG) from collapsing back to the + // normal colour pair. + if got := termColor(vt10x.DefaultFG, th); got != th.Text { + t.Errorf("DefaultFG mapped to %v, want theme Text %v", got, th.Text) + } + if got := termColor(vt10x.DefaultBG, th); got != th.BG { + t.Errorf("DefaultBG mapped to %v, want theme BG %v", got, th.BG) + } + if got, want := termColor(1, th), tcell.PaletteColor(1); got != want { + t.Errorf("palette colour 1 mapped to %v, want %v", got, want) + } + // Truecolor is packed as 0xRRGGBB. + if got, want := termColor(0x0080ff, th), tcell.NewRGBColor(0, 0x80, 0xff); got != want { + t.Errorf("truecolor mapped to %v, want %v", got, want) + } +} + +// TestGlyphStyle_ReverseIsNotDoubleApplied pins a subtle rendering bug. +// vt10x bakes reverse video into the stored cell (it swaps FG/BG in +// setChar) while ALSO leaving the reverse bit set in Glyph.Mode. If +// glyphStyle honoured that bit, tcell would swap a second time and the +// highlight would vanish — silently breaking less's status line, git +// add -p, fzf selections, and vim's visual selection. +func TestGlyphStyle_ReverseIsNotDoubleApplied(t *testing.T) { + th := theme.Default() + vt := vt10x.New(vt10x.WithSize(20, 3)) + + // SGR 7 = reverse video. + if _, err := vt.Write([]byte("\x1b[7mR")); err != nil { + t.Fatalf("write: %v", err) + } + vt.Lock() + g := vt.Cell(0, 0) + vt.Unlock() + + if g.Mode&termAttrReverse == 0 { + t.Skip("vt10x no longer reports the reverse bit; mapping assumption changed") + } + + fg, bg, attrs := glyphStyle(g, th).Decompose() + if attrs&tcell.AttrReverse != 0 { + t.Error("style sets AttrReverse; vt10x already swapped the colours, so this double-swaps and cancels the highlight") + } + // The swap vt10x performed must survive into the rendered style: + // foreground should now be the theme background and vice versa. + if fg != th.BG || bg != th.Text { + t.Errorf("reverse cell rendered fg=%v bg=%v, want fg=%v bg=%v (colours swapped)", fg, bg, th.BG, th.Text) + } +} + +// TestClose_DoesNotSignalAfterReap guards against signalling a PID the +// kernel may have recycled. Once readLoop has reaped the child, its PID +// is fair game for reuse, so Close must not fire SIGHUP/SIGKILL at it. +func TestClose_DoesNotSignalAfterReap(t *testing.T) { + tab := newTestTerminal(t, 40, 10, nil) + + tab.Term.Write([]byte("exit\r")) + if !waitFor(t, 5*time.Second, func() bool { + exited, _ := tab.Term.Exited() + return exited + }) { + t.Fatal("shell never exited") + } + + // Must return promptly and without signalling anything. If it tried, + // it would also burn the full terminalCloseGrace polling for an exit + // that already happened. + start := time.Now() + tab.CloseTerminal() + if elapsed := time.Since(start); elapsed >= terminalCloseGrace { + t.Errorf("Close took %v on an already-exited shell; it should short-circuit", elapsed) + } +} + +// TestRenderTerminal_SurvivesGridSmallerThanPane renders with a pane +// larger than the emulator's grid. Indexing vt10x out of range panics, +// so the render must derive its bounds from the grid, not the pane. +func TestRenderTerminal_SurvivesGridSmallerThanPane(t *testing.T) { + scr := tcell.NewSimulationScreen("UTF-8") + if err := scr.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + defer scr.Fini() + scr.SetSize(80, 30) + + tab := newTestTerminal(t, 20, 5, nil) + + // Shrink the emulator behind the renderer's back, then draw into a + // much larger pane. Without grid-derived bounds this panics. + tab.Term.vt.Resize(4, 2) + tab.renderTerminal(scr, theme.Default(), 0, 0, 60, 20) + scr.Show() +} diff --git a/internal/editor/terminal_unix.go b/internal/editor/terminal_unix.go new file mode 100644 index 0000000..ea0b79e --- /dev/null +++ b/internal/editor/terminal_unix.go @@ -0,0 +1,47 @@ +// ============================================================================= +// File: internal/editor/terminal_unix.go +// Author: Spicer Matthews +// Created: 2026-05-02 +// Copyright: 2026 Cloudmanic, LLC. All rights reserved. +// ============================================================================= + +//go:build !windows + +// terminal_unix.go holds the process-group signalling Close needs. It's +// split out because syscall.Kill doesn't exist on Windows, and terminal +// tabs are a unix-only feature anyway — see terminal_windows.go for the +// stubs that keep the cross-compile green. + +package editor + +import "syscall" + +// hangupGroup sends SIGHUP to the process group led by pid. +// +// The group, not the bare process, is the important part. pty.StartWithSize +// sets Setsid, so the shell is a session leader whose process-group ID +// equals its PID — signalling -pid therefore reaches the shell *and* every +// job it started. SIGHUP is what a terminal emulator sends when its window +// closes, and it's the signal shells actually handle by hanging up their +// own children. SIGKILLing the shell directly would skip that entirely and +// orphan every background job. +func (tm *Terminal) hangupGroup(pid int) { + signalGroup(pid, syscall.SIGHUP) +} + +// killGroup SIGKILLs the process group led by pid. Close only reaches for +// this after SIGHUP had a grace period to work. +func (tm *Terminal) killGroup(pid int) { + signalGroup(pid, syscall.SIGKILL) +} + +// signalGroup sends sig to the process group led by pid. +// +// There is deliberately no "fall back to the bare pid" branch. kill(-pid) +// failing with ESRCH means the group is already gone, and retrying the +// bare PID is exactly the case where that number may have been recycled +// onto somebody else's process — the caller already guarantees the child +// hasn't been reaped, so a failure here is genuinely nothing to act on. +func signalGroup(pid int, sig syscall.Signal) { + _ = syscall.Kill(-pid, sig) +} diff --git a/internal/editor/terminal_windows.go b/internal/editor/terminal_windows.go new file mode 100644 index 0000000..c686fcf --- /dev/null +++ b/internal/editor/terminal_windows.go @@ -0,0 +1,22 @@ +// ============================================================================= +// File: internal/editor/terminal_windows.go +// Author: Spicer Matthews +// Created: 2026-05-02 +// Copyright: 2026 Cloudmanic, LLC. All rights reserved. +// ============================================================================= + +//go:build windows + +// terminal_windows.go stubs out the unix process-group signalling so the +// package still builds for the windows/amd64 release target. Nothing here +// is ever reached: NewTerminalTab refuses to start on Windows (creack/pty +// returns ErrUnsupported there), so no Terminal is ever constructed and +// Close is never called. + +package editor + +// hangupGroup is a no-op on Windows — there are no terminal tabs to close. +func (tm *Terminal) hangupGroup(int) {} + +// killGroup is a no-op on Windows — there are no terminal tabs to close. +func (tm *Terminal) killGroup(int) {} diff --git a/internal/icons/icons.go b/internal/icons/icons.go index 3f90db4..f6813d4 100644 --- a/internal/icons/icons.go +++ b/internal/icons/icons.go @@ -179,6 +179,7 @@ const ( FolderClosed = "" // - generic closed folder (nf-fa-folder) FolderOpen = "" // - generic open folder (nf-fa-folder_open) FileDefault = "" // - generic file (nf-fa-file) + Terminal = "" //  - terminal / shell (nf-fa-terminal) ) // extIcons maps lowercase file extensions (with leading dot) to their