From f8a94e9c7cf47e2bebb45ed8cb1d1937374a232c Mon Sep 17 00:00:00 2001 From: jaivial <114494844+jaivial@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:02:38 +0000 Subject: [PATCH] Add terminal tabs: run a shell in a tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opens $SHELL in a real tab via "Open terminal in new tab" in the ≡ menu (or Esc-`), so tests and git are one keystroke away instead of a second tmux pane. The shell runs on a PTY (creack/pty) and is rendered by an embedded VT emulator (hinshun/vt10x) into the editor pane. We own the emulation rather than passing escape codes through to the host terminal — that's what lets the shell live in a sub-rectangle without fighting the editor for cursor position and scroll region, and it means no tmux passthrough config is needed. Both deps are pure Go, so the single static no-CGO binary is preserved. Notes on the less obvious decisions: - Adds Tab.IsTextual() and moves the existing !IsImage() guards onto it. Nearly every one of those guards meant "is this a normal text tab"; with only one alternate mode that was accidentally the same thing. Terminal tabs would otherwise have slipped through save, find, the git gutter, disk reconciliation, and the dirty-quit prompt. - The PTY reader goroutine never touches UI state. It parses into the emulator (internally locked) and posts termOutputEvent so the main loop redraws, matching the existing autoScroll/treeRefresh pattern. - Close() hangs up the child's process *group* with SIGHUP before escalating to SIGKILL. SIGKILLing the shell directly means bash never runs its exit path and every backgrounded job is orphaned. It also returns early if the child was already reaped, so a recycled PID can never be signalled. - The Esc-leader table stands down inside a terminal. A shell prompt is where users hit Esc by reflex, and swallowing the next rune to run an editor action is destructive: Esc then q would quit the editor and hang up every running shell. Double-Esc still opens the menu, so nothing becomes unreachable. - glyphStyle deliberately ignores the reverse-video bit: vt10x already swaps FG/BG into the stored cell while leaving the bit set, so honouring it double-swaps and cancels the highlight. Relatedly, termColor resolves the default-colour sentinels by meaning rather than by slot, or a reversed default cell collapses back to the normal pair. Terminal tabs are unix-only; the menu row is disabled on Windows, where creack/pty returns ErrUnsupported. Verified on linux/darwin/windows for amd64/arm64. --- README.md | 44 +++ go.mod | 2 + go.sum | 4 + internal/app/app.go | 167 +++++++- internal/app/app_test.go | 14 +- internal/app/find.go | 4 +- internal/app/leader.go | 1 + internal/app/terminal_test.go | 409 ++++++++++++++++++++ internal/editor/comment.go | 2 +- internal/editor/tab.go | 36 +- internal/editor/terminal.go | 573 ++++++++++++++++++++++++++++ internal/editor/terminal_test.go | 509 ++++++++++++++++++++++++ internal/editor/terminal_unix.go | 47 +++ internal/editor/terminal_windows.go | 22 ++ internal/icons/icons.go | 1 + 15 files changed, 1807 insertions(+), 28 deletions(-) create mode 100644 internal/app/terminal_test.go create mode 100644 internal/editor/terminal.go create mode 100644 internal/editor/terminal_test.go create mode 100644 internal/editor/terminal_unix.go create mode 100644 internal/editor/terminal_windows.go 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