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 1/8] 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 From fd090b67550b628037173b5eaf1a1193bd1695bb Mon Sep 17 00:00:00 2001 From: jaivial <114494844+jaivial@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:59:49 +0000 Subject: [PATCH 2/8] Add find-in-files search and git changes diff viewer --- README.md | 37 ++- internal/app/app.go | 70 +++++ internal/app/app_test.go | 14 +- internal/app/diffviewer.go | 498 +++++++++++++++++++++++++++++++ internal/app/diffviewer_test.go | 372 +++++++++++++++++++++++ internal/app/gitstatus.go | 15 + internal/app/leader.go | 1 + internal/app/modals.go | 7 +- internal/app/searchfiles.go | 484 ++++++++++++++++++++++++++++++ internal/app/searchfiles_test.go | 157 ++++++++++ internal/finder/grep.go | 174 +++++++++++ internal/finder/grep_test.go | 125 ++++++++ 12 files changed, 1944 insertions(+), 10 deletions(-) create mode 100644 internal/app/diffviewer.go create mode 100644 internal/app/diffviewer_test.go create mode 100644 internal/app/searchfiles.go create mode 100644 internal/app/searchfiles_test.go create mode 100644 internal/finder/grep.go create mode 100644 internal/finder/grep_test.go diff --git a/README.md b/README.md index 1a0c729..ba8021d 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ The goals, in order: - **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 + **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 @@ -195,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 F` | Find in files | | `Esc \`` | Open terminal tab | A lone `Esc` is harmless — if you don't follow it with a bound key @@ -255,9 +256,41 @@ fuzzy file finder over every non-ignored file in the project: inside the editor. - Only files are listed — no directories, no symlinked duplicates. +### Find in files + +`Esc F` (`Esc` then `Shift+F`, or **Find in files** from the `≡` +menu) searches the *contents* of every non-ignored file in the +project — the VS Code "Find in Files" (`Ctrl+Shift+F`) gesture: + +``` +┌ Find in files esc ┐ +│ main.go 42 │ +│ main.go:3 func widget() {} │ +│ internal/app/app.go:2 // a widget lives here │ +│ ... │ +└──────────────────────────────────────────────────────────┘ +``` + +- Type a word — matching is **case-insensitive substring**, the same + as the in-file find. Each row is one match: `path:line` followed by + the source line, with the hit highlighted. +- `↑` / `↓` to move through matches, `Enter` to jump straight to the + match (opens the file and drops the cursor on it), `Esc` to dismiss. + Mouse hover highlights, click jumps, and the wheel scrolls the list. +- Shares the finder's index, so the scope is identical: it greps the + same `.gitignore`-honouring file set and never descends into + `node_modules`, `.git`, or vendored dumps. +- Binary files (detected by a NUL byte) and files larger than 2 MB are + skipped, so a stray asset or minified bundle can't stall a search. +- The grep runs on a background goroutine and results stream in as + they land, so typing stays responsive even on a large repo. + +There's no regex, whole-word, or case-sensitive toggle in v1 — the +common case is "which files mention this word — take me there." + ## Terminal tabs -`Esc \`` (or **Open terminal in new tab** from the `≡` menu) opens your +`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. diff --git a/internal/app/app.go b/internal/app/app.go index 5ac54d9..ce3bd48 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -209,6 +209,11 @@ func builtinMenuGroups() [][]menuItemDef { { {label: "Find in file", shortcut: "Esc f", action: (*App).menuFind, enabled: (*App).hasFindable}, {label: "Find file in project", shortcut: "Esc p", action: (*App).menuFindFile, enabled: (*App).hasFinder}, + {label: "Find in files", shortcut: "Esc F", action: (*App).menuSearchFiles, enabled: (*App).hasSearchFiles}, + }, + // Git + { + {label: "Git changes", action: (*App).menuDiffViewer, enabled: (*App).hasDiffViewer}, }, // File actions { @@ -464,6 +469,26 @@ type App struct { // a git repo. Updated on the same 10-second tick as refreshGitStatus. gitBranch string + // gitStatus is the most recent snapshot from loadGitStatus (IsRepo, + // repo Root, DirtyFiles, Branch). Kept cached so the Git-changes modal + // (see diffviewer.go) can render the dirty-file list without forking + // `git status` on every open — same snapshot refreshGitStatus already + // stamps onto the file tree. The zero value reads as "not a repo / + // nothing dirty", the safe default. + gitStatus gitStatus + + // diff viewer modal state — the "Git changes" browser (≡ → Git changes + // or see diffviewer.go). diffViewFile distinguishes the two views: "" + // means the dirty-file list is shown; a set path means the modal is + // showing that file's scrollable unified diff. + diffOpen bool + diffEntries []diffEntry + diffSelected int + diffViewTop int + diffViewFile string + diffLines []string + diffScroll int + // customActions is the list of user-configured shell-out actions // loaded from ~/.config/spiceedit/actions.json at startup. When // non-empty they prepend a new group to the action menu — see @@ -482,6 +507,20 @@ type App struct { finderSelected int finderResults []finder.Result + // search modal state — project-wide content search ("Esc F" or + // ≡ → Find in files). Reuses the finder's cached path index but + // greps file *contents* on a background goroutine; searchGen drops + // stale results when the user keeps typing. + searchOpen bool + searchQuery []rune + searchCursor int + searchScroll int + searchSelected int + searchViewTop int + searchResults []finder.ContentMatch + searchGen int + searchDone bool + // confirmCancelHook runs when the active confirm modal is dismissed // without a Yes — i.e. the user picked No, hit Esc, or clicked // outside. Set after openConfirm by flows that want to react to the @@ -658,6 +697,7 @@ func (a *App) refreshGitStatus() { a.tree.DirtyFiles = nil a.tree.DirtyFolders = nil a.gitBranch = "" + a.gitStatus = gitStatus{} // cache the "not a repo" verdict for the diff modal a.refreshGitLineChanges() return } @@ -665,6 +705,7 @@ func (a *App) refreshGitStatus() { a.tree.DirtyFiles = dirtyFiles a.tree.DirtyFolders = dirtyFolderSet(dirtyFiles, a.tree.Root.Path) a.gitBranch = st.Branch + a.gitStatus = st // cache for the diff modal (Root/IsRepo/DirtyFiles) a.refreshGitLineChanges() } @@ -788,6 +829,13 @@ func (a *App) handleEvent(ev tcell.Event) { if a.finderOpen { a.refreshFinderResults() } + // A finished rebuild can also unblock a content search that + // was typed before the index was ready — re-run it now. + if a.searchOpen && len(a.searchQuery) > 0 { + a.runSearch() + } + case *searchResultsEvent: + a.applySearchResults(e) } } @@ -1053,6 +1101,10 @@ func (a *App) handleKey(ev *tcell.EventKey) { a.handleContextKey(ev) return } + if a.diffOpen { + a.handleDiffKey(ev) + return + } if a.findOpen { a.handleFindKey(ev) return @@ -1061,6 +1113,10 @@ func (a *App) handleKey(ev *tcell.EventKey) { a.handleFinderKey(ev) return } + if a.searchOpen { + a.handleSearchKey(ev) + return + } if ev.Key() == tcell.KeyEsc { // Esc is the editor's only command key. Behavior: @@ -1229,10 +1285,18 @@ func (a *App) handleMouse(ev *tcell.EventMouse) { a.handleContextMouse(x, y, btn) return } + if a.diffOpen { + a.handleDiffMouse(x, y, btn) + return + } if a.finderOpen { a.handleFinderMouse(x, y, btn) return } + if a.searchOpen { + a.handleSearchMouse(x, y, btn) + return + } if a.menuOpen { a.updateMenuHover(x, y) @@ -2439,9 +2503,15 @@ func (a *App) draw() { if a.formOpen { a.drawForm() } + if a.diffOpen { + a.drawDiff() + } if a.finderOpen { a.drawFinder() } + if a.searchOpen { + a.drawSearch() + } } // iconsOn reports whether Nerd Font glyphs should render in places diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 36b78e4..d4d04d1 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 != 32 { - t.Errorf("modalHeight = %d, want 32", h) + if h != 35 { + t.Errorf("modalHeight = %d, want 35", h) } - if got := len(items); got != 22 { - t.Errorf("item count = %d, want 22 built-ins", got) + if got := len(items); got != 24 { + t.Errorf("item count = %d, want 24 built-ins", got) } - wantDiv := []int{2, 6, 10, 13, 21, 26, 29} + wantDiv := []int{2, 6, 10, 14, 16, 24, 29, 32} 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 != 35 { // 32 + 2 items + 1 divider - t.Errorf("modalHeight = %d, want 35", h) + if h != 38 { // 35 + 2 items + 1 divider + t.Errorf("modalHeight = %d, want 38", 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/diffviewer.go b/internal/app/diffviewer.go new file mode 100644 index 0000000..f53e6d3 --- /dev/null +++ b/internal/app/diffviewer.go @@ -0,0 +1,498 @@ +// ============================================================================= +// File: internal/app/diffviewer.go +// Author: Spicer Matthews +// Created: 2026-07-24 +// Copyright: 2026 Cloudmanic, LLC. All rights reserved. +// ============================================================================= + +// diffviewer.go is the "Git changes" modal — a mouse-first browser for +// the repo's uncommitted work. It reuses plumbing that already exists: +// +// - the dirty-file set comes from the cached gitStatus snapshot that +// refreshGitStatus stamps onto the App (same source the file tree's +// dirty highlight reads), so there's no extra `git status` fork per +// open. +// - the per-file diff body is `git diff --unified=3 HEAD -- ` +// (loadGitFileDiff), rendered with confirmInfoLineStyle — the same +// colouring the per-line hunk preview already uses. +// +// The modal has two views, switched by diffViewFile: +// +// - list view (diffViewFile == ""): the dirty files, one per row, with +// a status glyph (M/A/D/R) and a path relative to the repo root. +// ↑/↓ move, Enter (or click) drills into a file's diff. +// - diff view (diffViewFile set): the file's unified diff, scrollable. +// Enter opens the file in a tab and drops the cursor on the first +// changed line; Backspace / Esc returns to the list view. +// +// Esc on the list view closes the modal outright — same convention as +// the finder and search modals. + +package app + +import ( + "path/filepath" + "sort" + + "github.com/cloudmanic/spice-edit/internal/editor" + "github.com/cloudmanic/spice-edit/internal/filetree" + "github.com/cloudmanic/spice-edit/internal/theme" + "github.com/gdamore/tcell/v2" +) + +const ( + // diffModalMaxWidth caps the modal so very wide terminals don't get + // a sprawling strip. Matches the file finder's comfortable width. + diffModalMaxWidth = 80 + // diffRowsVisible is how many list / diff rows render at once. Same + // floor as the finder — "feels useful" without dominating small + // terminals. + diffRowsVisible = 14 +) + +// diffEntry is one row in the list view: a dirty file's absolute path, +// its path relative to the repo root (for display), and the git change +// kind reported by porcelain. +type diffEntry struct { + abs string + rel string + kind filetree.GitChangeKind +} + +// openDiffViewer builds the list view from the cached gitStatus snapshot +// and shows the modal. If the project isn't a git repo, or git reported +// no changes, we flash a status message and bail instead of popping an +// empty dialog — the menu predicate (hasDiffViewer) keeps the row dimmed +// in that case too, but the leader-key path can still reach here. +func (a *App) openDiffViewer() { + if a.gitStatus.Root == "" || !a.gitStatus.IsRepo { + a.flash("Not a git repository") + return + } + entries := a.buildDiffEntries() + if len(entries) == 0 { + a.flash("No uncommitted changes") + return + } + a.closeAllModals() + a.diffOpen = true + a.diffEntries = entries + a.diffSelected = 0 + a.diffViewTop = 0 + a.diffViewFile = "" + a.diffLines = nil + a.diffScroll = 0 +} + +// closeDiffViewer tears down the modal's transient state. The cached +// gitStatus is left alone — it's owned by the tree-refresh tick. +func (a *App) closeDiffViewer() { + a.diffOpen = false + a.diffEntries = nil + a.diffSelected = 0 + a.diffViewTop = 0 + a.diffViewFile = "" + a.diffLines = nil + a.diffScroll = 0 +} + +// menuDiffViewer is the ≡ menu entry point. +func (a *App) menuDiffViewer() { + a.closeMenu() + a.openDiffViewer() +} + +// hasDiffViewer is the menu predicate: the row is enabled when we're in a +// git repo that currently reports at least one changed file. Using the +// cached snapshot means the menu greys out the instant the user commits +// the last change (on the next refresh tick) without forking git on every +// draw. +func (a *App) hasDiffViewer() bool { + return a.gitStatus.IsRepo && len(a.gitStatus.DirtyFiles) > 0 +} + +// buildDiffEntries flattens the cached dirty-file map into a sorted slice +// of display rows. Sorting gives a stable order across the random map +// iteration and a predictable ↑/↓ walk for the user. +func (a *App) buildDiffEntries() []diffEntry { + root := a.gitStatus.Root + entries := make([]diffEntry, 0, len(a.gitStatus.DirtyFiles)) + for abs, kind := range a.gitStatus.DirtyFiles { + rel, ok := relFromRoot(abs, root) + if !ok { + rel = filepath.Base(abs) + } + entries = append(entries, diffEntry{abs: abs, rel: rel, kind: kind}) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].rel < entries[j].rel }) + return entries +} + +// handleDiffKey routes keyboard input for the modal. In diff view, +// Backspace / Esc pop back to the list rather than closing outright — +// Esc-closes-everything would make it impossible to browse several files +// without re-opening the modal each time. Enter in diff view opens the +// file at the first change. +func (a *App) handleDiffKey(ev *tcell.EventKey) { + if a.diffViewFile != "" { + switch ev.Key() { + case tcell.KeyEsc, tcell.KeyBackspace, tcell.KeyBackspace2: + a.diffViewFile = "" + a.diffLines = nil + a.diffScroll = 0 + case tcell.KeyEnter: + a.openDiffFileAtChange() + case tcell.KeyUp: + a.scrollDiff(-1) + case tcell.KeyDown: + a.scrollDiff(1) + case tcell.KeyPgUp: + a.scrollDiff(-a.diffVisibleRows()) + case tcell.KeyPgDn: + a.scrollDiff(a.diffVisibleRows()) + } + return + } + switch ev.Key() { + case tcell.KeyEsc: + a.closeDiffViewer() + case tcell.KeyUp: + a.moveDiffSelection(-1) + case tcell.KeyDown: + a.moveDiffSelection(1) + case tcell.KeyPgUp: + a.moveDiffSelection(-a.diffVisibleRows()) + case tcell.KeyPgDn: + a.moveDiffSelection(a.diffVisibleRows()) + case tcell.KeyEnter: + a.showDiffForSelected() + } +} + +// handleDiffMouse routes mouse input for the modal. Hovering a list row +// selects it; clicking a row drills into its diff (or, in diff view, +// opens the file). Wheel scrolls the visible pane. Clicks outside the +// modal dismiss it — same convention as every other modal. +func (a *App) handleDiffMouse(x, y int, btn tcell.ButtonMask) { + mx, my, mw, mh := a.diffModalRect() + if btn&tcell.Button4 != 0 { + a.scrollDiff(-3) + return + } + if btn&tcell.Button5 != 0 { + a.scrollDiff(3) + return + } + if btn&tcell.Button1 == 0 { + // Motion with no button: update hover selection in list view. + if a.diffViewFile == "" && x >= mx && x < mx+mw && y >= my && y < my+mh { + row := y - (my + 3) + if row >= 0 && row < a.diffVisibleRows() { + idx := a.diffViewTop + row + if idx < len(a.diffEntries) { + a.diffSelected = idx + a.adjustDiffView() + } + } + } + return + } + if x < mx || x >= mx+mw || y < my || y >= my+mh { + a.closeDiffViewer() + return + } + if a.diffViewFile != "" { + // Any click inside the diff body opens the file. + a.openDiffFileAtChange() + return + } + row := y - (my + 3) + if row >= 0 && row < a.diffVisibleRows() { + idx := a.diffViewTop + row + if idx < len(a.diffEntries) { + a.diffSelected = idx + a.showDiffForSelected() + } + } +} + +// moveDiffSelection moves the list cursor by dir rows, clamped to the +// entry count, then keeps the selection inside the visible window. +func (a *App) moveDiffSelection(dir int) { + n := len(a.diffEntries) + if n == 0 { + return + } + a.diffSelected += dir + if a.diffSelected < 0 { + a.diffSelected = 0 + } + if a.diffSelected >= n { + a.diffSelected = n - 1 + } + a.adjustDiffView() +} + +// showDiffForSelected loads the unified diff for the selected file and +// flips the modal into diff view. Empty diff output (e.g. a fully-staged +// file with no worktree delta) is surfaced as a single placeholder line +// so the view never looks blank. +func (a *App) showDiffForSelected() { + if a.diffSelected < 0 || a.diffSelected >= len(a.diffEntries) { + return + } + e := a.diffEntries[a.diffSelected] + lines := loadGitFileDiff(a.rootDir, e.abs) + if len(lines) == 0 { + lines = []string{"(no worktree changes vs HEAD)"} + } + a.diffViewFile = e.abs + a.diffLines = lines + a.diffScroll = 0 +} + +// openDiffFileAtChange opens the file shown in diff view and drops the +// cursor on the first line the diff reports as changed (the new-file +// start of the first hunk). Falls back to line 0 when no hunk header is +// parseable — opening at the top beats not opening at all. +func (a *App) openDiffFileAtChange() { + path := a.diffViewFile + if path == "" { + return + } + // Capture the target line before closeDiffViewer wipes diffLines. + line := firstDiffNewLine(a.diffLines) + a.closeDiffViewer() + a.openFile(path) + tab := a.activeTabPtr() + if tab == nil { + return + } + tab.MoveCursorTo(editor.Position{Line: line, Col: 0}, false) + _, _, ew, eh := a.editorRect() + tab.EnsureVisible(ew, eh) +} + +// scrollDiff advances the diff-view scroll offset by delta, clamped to +// the valid range. A no-op delta still clamps, which is how the draw +// path guarantees the offset is sane before painting. +func (a *App) scrollDiff(delta int) { + if a.diffViewFile == "" { + return + } + maxScroll := len(a.diffLines) - a.diffVisibleRows() + if maxScroll < 0 { + maxScroll = 0 + } + a.diffScroll += delta + if a.diffScroll < 0 { + a.diffScroll = 0 + } + if a.diffScroll > maxScroll { + a.diffScroll = maxScroll + } +} + +// adjustDiffView slides the list-view top offset so the selection stays +// on screen. Mirrors the search modal's adjustSearchView. +func (a *App) adjustDiffView() { + rows := a.diffVisibleRows() + if rows <= 0 { + a.diffViewTop = 0 + return + } + if a.diffSelected < a.diffViewTop { + a.diffViewTop = a.diffSelected + } + if a.diffSelected >= a.diffViewTop+rows { + a.diffViewTop = a.diffSelected - rows + 1 + } + if a.diffViewTop < 0 { + a.diffViewTop = 0 + } +} + +// diffVisibleRows returns how many body rows the modal can show given +// the current terminal height, capped at diffRowsVisible. +func (a *App) diffVisibleRows() int { + _, _, _, mh := a.diffModalRect() + rows := mh - 4 // borders + title + divider + if rows > diffRowsVisible { + rows = diffRowsVisible + } + if rows < 0 { + rows = 0 + } + return rows +} + +// diffModalRect returns the on-screen rectangle of the modal, centered. +// Same layout budget as the search modal: 1 border + 1 title + 1 divider +// + N body rows + 1 border = N+4 rows. +func (a *App) diffModalRect() (x, y, w, h int) { + w = diffModalMaxWidth + if w > a.width-4 { + w = a.width - 4 + } + if w < 30 { + w = 30 + } + h = diffRowsVisible + 4 + if h > a.height-2 { + h = a.height - 2 + } + x = (a.width - w) / 2 + y = (a.height - h) / 3 + if x < 0 { + x = 0 + } + if y < 0 { + y = 0 + } + return +} + +// drawDiff paints the modal. The title and hint change with the view; +// the body is either the file list or the scrollable diff, both using +// confirmInfoLineStyle so colours match the existing git-diff preview. +func (a *App) drawDiff() { + mx, my, mw, mh := a.diffModalRect() + bg := a.theme.LineHL + bgStyle := tcell.StyleDefault.Background(bg).Foreground(a.theme.Text) + borderStyle := tcell.StyleDefault.Background(bg).Foreground(a.theme.Subtle) + titleStyle := tcell.StyleDefault.Background(bg).Foreground(a.theme.Accent).Bold(true) + mutedStyle := tcell.StyleDefault.Background(bg).Foreground(a.theme.Muted) + + fillRect(a.screen, mx, my, mw, mh, bgStyle) + drawBorder(a.screen, mx, my, mw, mh, borderStyle) + drawHDivider(a.screen, mx, my+2, mw, borderStyle) + + title := " Git changes" + hint := "esc " + if a.diffViewFile != "" { + title = " Git diff · " + filepath.Base(a.diffViewFile) + hint = "⌫ list " + } + drawAt(a.screen, mx+1, my+1, title, titleStyle) + drawAt(a.screen, mx+mw-1-runeLen(hint), my+1, hint, mutedStyle) + + bodyStart := my + 3 + rowsCap := a.diffVisibleRows() + + if a.diffViewFile == "" { + // List view. + for i := 0; i < rowsCap; i++ { + ry := bodyStart + i + idx := a.diffViewTop + i + if idx >= len(a.diffEntries) { + for cx := mx + 1; cx < mx+mw-1; cx++ { + a.screen.SetContent(cx, ry, ' ', nil, bgStyle) + } + continue + } + a.drawDiffListRow(mx, ry, mw, a.diffEntries[idx], idx == a.diffSelected, bg) + } + a.screen.HideCursor() + return + } + + // Diff view. + a.scrollDiff(0) + end := a.diffScroll + rowsCap + if end > len(a.diffLines) { + end = len(a.diffLines) + } + for i, line := range a.diffLines[a.diffScroll:end] { + ry := bodyStart + i + if runeLen(line) > mw-4 { + line = string([]rune(line)[:mw-4]) + } + drawAt(a.screen, mx+2, ry, line, confirmInfoLineStyle(a.theme, bg, line)) + } + // Blank any trailing rows so old content can't bleed through when the + // diff is shorter than the viewport. + for ry := bodyStart + (end - a.diffScroll); ry < bodyStart+rowsCap; ry++ { + for cx := mx + 1; cx < mx+mw-1; cx++ { + a.screen.SetContent(cx, ry, ' ', nil, bgStyle) + } + } + a.screen.HideCursor() +} + +// drawDiffListRow paints one list-view row: a coloured status glyph, a +// gutter space, then the relative path. The selected row's background +// flips to the editor BG so it reads as focused — same vocabulary as the +// search/finder rows. +func (a *App) drawDiffListRow(mx, ry, mw int, e diffEntry, selected bool, modalBG tcell.Color) { + glyph, fg := diffKindGlyphTheme(e.kind, a.theme) + rowBG := modalBG + if selected { + rowBG = a.theme.BG + } + rowStyle := tcell.StyleDefault.Background(rowBG).Foreground(a.theme.Text) + for cx := mx + 1; cx < mx+mw-1; cx++ { + a.screen.SetContent(cx, ry, ' ', nil, rowStyle) + } + glyphStyle := tcell.StyleDefault.Background(rowBG).Foreground(fg).Bold(true) + a.screen.SetContent(mx+2, ry, glyph, nil, glyphStyle) + label := e.rel + if runeLen(label) > mw-6 { + label = string([]rune(label)[:mw-6]) + } + drawAt(a.screen, mx+4, ry, label, rowStyle) +} + +// diffKindGlyphTheme maps a git change kind to a status character and +// colour. Mixed (a folder with conflicting kinds) surfaces as 'M' since a +// file row only ever carries one kind; the switch keeps the function total. +func diffKindGlyphTheme(k filetree.GitChangeKind, th theme.Theme) (rune, tcell.Color) { + switch k { + case filetree.GitChangeAdded: + return 'A', th.GitAdded + case filetree.GitChangeDeleted: + return 'D', th.GitDeleted + case filetree.GitChangeRenamed: + return 'R', th.AccentSoft + default: + return 'M', th.GitModified + } +} + +// firstDiffNewLine returns the zero-based new-file line of the first +// added/changed line in the diff, or 0 when no hunk parses. Used to land +// the cursor ON the first change (not the hunk's context top) when +// opening a file from diff view. It walks the first hunk tracking the +// new-file line counter: context and "+" lines advance it, "-" lines +// don't. +func firstDiffNewLine(lines []string) int { + inHunk := false + newLine := 0 + for _, l := range lines { + if len(l) >= 3 && l[:3] == "@@ " { + _, _, newStart, _, ok := parseHunkHeader(l) + if !ok || newStart < 1 { + return 0 + } + newLine = newStart - 1 + inHunk = true + continue + } + if !inHunk || len(l) == 0 { + continue + } + // Skip the file-path header lines inside the hunk body. + if len(l) >= 3 && (l[:3] == "+++" || l[:3] == "---") { + continue + } + switch l[0] { + case '+': + return newLine // first added line — land here. + case '-', '\\': + // deleted line / "\ No newline" — no new-file advance. + continue + } + // Context line (" ") advances the new-file counter. + newLine++ + } + return 0 +} diff --git a/internal/app/diffviewer_test.go b/internal/app/diffviewer_test.go new file mode 100644 index 0000000..64ceee2 --- /dev/null +++ b/internal/app/diffviewer_test.go @@ -0,0 +1,372 @@ +// ============================================================================= +// File: internal/app/diffviewer_test.go +// Author: Spicer Matthews +// Created: 2026-07-24 +// Copyright: 2026 Cloudmanic, LLC. All rights reserved. +// ============================================================================= + +// Tests for diffviewer.go. The pure helpers (firstDiffNewLine, +// diffKindGlyphTheme, buildDiffEntries) are exercised with synthetic +// input so they don't fork git. The shell-out path (loadGitFileDiff, +// showDiffForSelected end-to-end) runs against a real `git init`'d repo +// and skips when git isn't on PATH. + +package app + +import ( + "path/filepath" + "testing" + + "github.com/cloudmanic/spice-edit/internal/filetree" + "github.com/cloudmanic/spice-edit/internal/theme" + "github.com/gdamore/tcell/v2" +) + +// TestHasDiffViewer_DefaultOff pins the menu predicate on a fresh App: +// no gitStatus snapshot yet → the "Git changes" row stays disabled. +func TestHasDiffViewer_DefaultOff(t *testing.T) { + a := newTestApp(t, t.TempDir()) + if a.hasDiffViewer() { + t.Fatal("hasDiffViewer should be false with no gitStatus snapshot") + } +} + +// TestMenuLayout_GitChangesRow ensures the row is present in the menu +// (disabled by default) so the gesture is discoverable even before the +// first refresh tick populates gitStatus. +func TestMenuLayout_GitChangesRow(t *testing.T) { + a := newTestApp(t, t.TempDir()) + item := menuItemByLabel(t, a, "Git changes") + if item.action == nil { + t.Fatal("Git changes row has no action") + } + if item.enabled == nil { + t.Fatal("Git changes row has no enabled predicate") + } + if item.enabled(a) { + t.Fatal("Git changes should be disabled without a repo / dirty files") + } +} + +// TestOpenDiffViewer_NotARepo guards the no-repo early return: the modal +// never opens and the cached empty gitStatus is reported via flash. +func TestOpenDiffViewer_NotARepo(t *testing.T) { + a := newTestApp(t, t.TempDir()) + a.openDiffViewer() + if a.diffOpen { + t.Fatal("modal should not open for a non-repo") + } +} + +// TestOpenDiffViewer_RepoNoChanges guards the no-dirty-files return. +func TestOpenDiffViewer_RepoNoChanges(t *testing.T) { + a := newTestApp(t, t.TempDir()) + a.gitStatus = gitStatus{IsRepo: true, Root: a.rootDir, DirtyFiles: map[string]filetree.GitChangeKind{}} + a.openDiffViewer() + if a.diffOpen { + t.Fatal("modal should not open with no dirty files") + } +} + +// TestOpenDiffViewer_BuildsList seeds a synthetic gitStatus and confirms +// openDiffViewer flips the modal on and builds a sorted entry list with +// the list view active (diffViewFile empty). +func TestOpenDiffViewer_BuildsList(t *testing.T) { + a := newTestApp(t, t.TempDir()) + root := a.rootDir + a.gitStatus = gitStatus{ + IsRepo: true, + Root: root, + DirtyFiles: map[string]filetree.GitChangeKind{ + filepath.Join(root, "zeta.go"): filetree.GitChangeModified, + filepath.Join(root, "alpha.go"): filetree.GitChangeAdded, + filepath.Join(root, "mid.txt"): filetree.GitChangeDeleted, + }, + } + a.openDiffViewer() + if !a.diffOpen { + t.Fatal("modal should be open") + } + if a.diffViewFile != "" { + t.Fatalf("expected list view, diffViewFile=%q", a.diffViewFile) + } + want := []string{"alpha.go", "mid.txt", "zeta.go"} + if len(a.diffEntries) != len(want) { + t.Fatalf("entry count = %d, want %d (%v)", len(a.diffEntries), len(want), a.diffEntries) + } + for i, w := range want { + if a.diffEntries[i].rel != w { + t.Errorf("entries[%d].rel = %q, want %q", i, a.diffEntries[i].rel, w) + } + } + if a.diffSelected != 0 { + t.Errorf("diffSelected = %d, want 0", a.diffSelected) + } +} + +// TestBuildDiffEntries_RelativePaths confirms rel paths are computed +// against the repo root, not the tree root, so renames across the root +// boundary still display cleanly. +func TestBuildDiffEntries_RelativePaths(t *testing.T) { + a := newTestApp(t, t.TempDir()) + root := a.rootDir + a.gitStatus = gitStatus{ + IsRepo: true, + Root: root, + DirtyFiles: map[string]filetree.GitChangeKind{ + filepath.Join(root, "pkg", "inner.go"): filetree.GitChangeRenamed, + }, + } + entries := a.buildDiffEntries() + if len(entries) != 1 { + t.Fatalf("entry count = %d, want 1", len(entries)) + } + want := filepath.Join("pkg", "inner.go") + if entries[0].rel != want { + t.Errorf("rel = %q, want %q", entries[0].rel, want) + } + if entries[0].kind != filetree.GitChangeRenamed { + t.Errorf("kind = %v, want Renamed", entries[0].kind) + } +} + +// TestFirstDiffNewLine pins the hunk-header parser used to land the +// cursor on the first visible change. Zero-based return: a hunk +// "+10,3" points at new-file line 10 → index 9. +func TestFirstDiffNewLine(t *testing.T) { + cases := []struct { + name string + lines []string + want int + }{ + {"first hunk", []string{"diff --git a/x b/x", "@@ -1,3 +10,3 @@", "+foo", "-bar"}, 9}, + {"no hunks", []string{"diff --git a/x b/x"}, 0}, + {"malformed header", []string{"@@ junk @@"}, 0}, // malformed: parser skips + {"empty", []string{}, 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := firstDiffNewLine(c.lines); got != c.want { + t.Errorf("firstDiffNewLine = %d, want %d", got, c.want) + } + }) + } +} + +// TestFirstDiffNewLine_RealHunk uses a realistic unified-diff header. +func TestFirstDiffNewLine_RealHunk(t *testing.T) { + lines := []string{ + "diff --git a/main.go b/main.go", + "index 1234567..89abcde 100644", + "--- a/main.go", + "+++ b/main.go", + "@@ -20,7 +20,9 @@ func main() {", + " old", + "+new", + } + if got := firstDiffNewLine(lines); got != 20 { + t.Fatalf("firstDiffNewLine = %d, want 20 (first + line after context)", got) + } +} + +// TestDiffKindGlyphTheme pins the kind → (glyph, colour) mapping so a +// refactor of the filetree kinds doesn't silently recolour the list. +func TestDiffKindGlyphTheme(t *testing.T) { + th := theme.Default() + cases := []struct { + kind filetree.GitChangeKind + wantG rune + wantCol tcell.Color + }{ + {filetree.GitChangeAdded, 'A', th.GitAdded}, + {filetree.GitChangeDeleted, 'D', th.GitDeleted}, + {filetree.GitChangeRenamed, 'R', th.AccentSoft}, + {filetree.GitChangeModified, 'M', th.GitModified}, + {filetree.GitChangeMixed, 'M', th.GitModified}, + {filetree.GitChangeNone, 'M', th.GitModified}, + } + for _, c := range cases { + g, col := diffKindGlyphTheme(c.kind, th) + if g != c.wantG { + t.Errorf("kind %v glyph = %q, want %q", c.kind, g, c.wantG) + } + if col != c.wantCol { + t.Errorf("kind %v colour mismatch", c.kind) + } + } +} + +// TestCloseAllModals_ClearsDiff ensures closeAllModals tears down diff +// state so a stale diffViewFile can't leak into a later modal. +func TestCloseAllModals_ClearsDiff(t *testing.T) { + a := newTestApp(t, t.TempDir()) + a.diffOpen = true + a.diffEntries = []diffEntry{{abs: "/x", rel: "x", kind: filetree.GitChangeModified}} + a.diffViewFile = "/x" + a.diffLines = []string{"@@ -1 +1 @@"} + a.closeAllModals() + if a.diffOpen { + t.Fatal("diffOpen should be false after closeAllModals") + } + if a.diffViewFile != "" { + t.Fatalf("diffViewFile = %q, want cleared", a.diffViewFile) + } + if len(a.diffLines) != 0 { + t.Fatal("diffLines should be cleared") + } +} + +// TestAnyModalOpen_IncludesDiff confirms the event-router guard sees +// the diff modal as an open overlay. +func TestAnyModalOpen_IncludesDiff(t *testing.T) { + a := newTestApp(t, t.TempDir()) + if a.anyModalOpen() { + t.Fatal("no modal open initially") + } + a.diffOpen = true + if !a.anyModalOpen() { + t.Fatal("anyModalOpen should be true while diff modal is up") + } +} + +// TestOpenDiffViewer_EndToEnd runs the whole pipeline against a real git +// repo: commit a file, modify it, refresh gitStatus, open the modal, +// drill into the diff, then open the file and confirm the cursor lands +// on the changed line. Skipped when git isn't installed. +func TestOpenDiffViewer_EndToEnd(t *testing.T) { + requireGit(t) + repo := initRepo(t) + target := filepath.Join(repo, "main.go") + writeFileT(t, target, "package main\n\nfunc a() {}\n") + gitRun(t, repo, "add", ".") + gitRun(t, repo, "commit", "-q", "-m", "init") + + // Modify: insert a line so the first hunk's new-start is line 3. + writeFileT(t, target, "package main\n\nfunc a() {}\nfunc b() {}\n") + + a := newTestApp(t, repo) + a.refreshGitStatus() + if !a.hasDiffViewer() { + t.Fatalf("hasDiffViewer should be true after refresh, gitStatus=%+v", a.gitStatus) + } + a.openDiffViewer() + if !a.diffOpen { + t.Fatal("modal should be open") + } + if len(a.diffEntries) != 1 { + t.Fatalf("expected 1 dirty entry, got %d (%+v)", len(a.diffEntries), a.diffEntries) + } + + // Drill into the diff view. + a.showDiffForSelected() + if a.diffViewFile == "" { + t.Fatal("expected diff view after showDiffForSelected") + } + if len(a.diffLines) == 0 { + t.Fatal("diffLines should be populated from git diff") + } + // The diff body must contain at least one + line (the added func b). + found := false + for _, l := range a.diffLines { + if len(l) > 0 && l[0] == '+' && l != "+++" { + found = true + break + } + } + if !found { + t.Fatalf("diffLines contained no added line: %v", a.diffLines) + } + + // Jump: open the file and confirm cursor lands on a line >= 0 inside + // the changed region. + a.openDiffFileAtChange() + if a.diffOpen { + t.Fatal("modal should close after openDiffFileAtChange") + } + tab := a.activeTabPtr() + if tab == nil { + t.Fatal("expected an open tab after jump") + } + if tab.Path != target { + t.Errorf("tab.Path = %q, want %q", tab.Path, target) + } + // Hunk is "@@ -3 +3,2 @@" → new-start line 3 → zero-based 2. + if tab.Cursor.Line < 2 { + t.Errorf("cursor line = %d, want >= 2 (first changed line)", tab.Cursor.Line) + } +} + +// TestDrawDiff_ListView renders the modal and asserts the title and a +// seeded filename land on screen. Locks the draw path so a future layout +// refactor can't silently blank the modal. +func TestDrawDiff_ListView(t *testing.T) { + a := newTestApp(t, t.TempDir()) + root := a.rootDir + a.gitStatus = gitStatus{ + IsRepo: true, + Root: root, + DirtyFiles: map[string]filetree.GitChangeKind{ + filepath.Join(root, "alpha.go"): filetree.GitChangeAdded, + }, + } + a.openDiffViewer() + a.draw() + scr := a.screen.(tcell.SimulationScreen) + scr.Show() + + _, my, _, _ := a.diffModalRect() + title := screenLine(scr, my+1) + if !contains(title, "Git changes") { + t.Errorf("title row = %q, want to contain 'Git changes'", trimSpace(title)) + } + row := screenLine(scr, my+3) + if !contains(row, "alpha.go") { + t.Errorf("list row = %q, want to contain 'alpha.go'", trimSpace(row)) + } +} + +// TestDrawDiff_DiffView drills into a synthetic diff body and confirms +// the title switches to "Git diff · ". +func TestDrawDiff_DiffView(t *testing.T) { + a := newTestApp(t, t.TempDir()) + root := a.rootDir + target := filepath.Join(root, "main.go") + a.gitStatus = gitStatus{ + IsRepo: true, + Root: root, + DirtyFiles: map[string]filetree.GitChangeKind{ + target: filetree.GitChangeModified, + }, + } + a.openDiffViewer() + // Inject a synthetic diff body instead of shelling to git. + a.diffViewFile = target + a.diffLines = []string{"@@ -1 +1,2 @@", " ctx", "+added"} + a.draw() + scr := a.screen.(tcell.SimulationScreen) + scr.Show() + + _, my, _, _ := a.diffModalRect() + title := screenLine(scr, my+1) + if !contains(title, "Git diff") || !contains(title, "main.go") { + t.Errorf("title row = %q, want 'Git diff · main.go'", trimSpace(title)) + } + body := screenLine(scr, my+3) + if !contains(body, "@@") { + t.Errorf("diff body row = %q, want to contain '@@'", trimSpace(body)) + } +} + +// contains is a minimal strings.Contains stand-in kept local so the test +// file doesn't grow a strings import just for two assertions. +func contains(s, sub string) bool { + if len(sub) == 0 { + return true + } + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/app/gitstatus.go b/internal/app/gitstatus.go index a6a1c99..71d41f4 100644 --- a/internal/app/gitstatus.go +++ b/internal/app/gitstatus.go @@ -258,6 +258,21 @@ func loadGitLineChanges(rootDir, path string) map[int]editor.GitLineChange { return parseGitDiffLines(out) } +// loadGitFileDiff returns the full unified-diff output for path against +// HEAD, one entry per output line (trailing newline trimmed). Used by the +// Git-changes modal (diffviewer.go) to render a scrollable per-file diff. +// Best-effort: any git error, empty output, or non-repo root yields nil. +func loadGitFileDiff(rootDir, path string) []string { + if rootDir == "" || path == "" { + return nil + } + out, err := exec.Command("git", "-C", rootDir, "diff", "--unified=3", "HEAD", "--", path).Output() + if err != nil || len(out) == 0 { + return nil + } + return strings.Split(strings.TrimRight(string(out), "\n"), "\n") +} + // loadGitHunkPreview returns the unified diff hunk covering zero-based line. func loadGitHunkPreview(rootDir, path string, line int) []string { if rootDir == "" || path == "" || line < 0 { diff --git a/internal/app/leader.go b/internal/app/leader.go index 312700c..903581b 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}, + {'F', (*App).openSearchFiles}, {'`', (*App).menuOpenTerminal}, } } diff --git a/internal/app/modals.go b/internal/app/modals.go index 8fcd9c2..b477455 100644 --- a/internal/app/modals.go +++ b/internal/app/modals.go @@ -62,6 +62,8 @@ func (a *App) closeAllModals() { a.formOpen = false a.findOpen = false a.finderOpen = false + a.searchOpen = false + a.diffOpen = false a.findValue = nil a.findCursor = 0 a.findScroll = 0 @@ -87,6 +89,9 @@ func (a *App) closeAllModals() { // hook set it *after* calling openConfirm precisely so this // clear doesn't erase their own arming. a.confirmCancelHook = nil + a.diffEntries = nil + a.diffViewFile = "" + a.diffLines = nil a.dragMode = "" a.stopAutoScroll() } @@ -98,7 +103,7 @@ func (a *App) closeAllModals() { // is what the user wants), but a key/mouse handler can use this to know // "is the user mid-task in some overlay surface". func (a *App) anyModalOpen() bool { - return a.menuOpen || a.promptOpen || a.confirmOpen || a.contextOpen || a.dirtyOpen || a.formOpen || a.findOpen || a.finderOpen + return a.menuOpen || a.promptOpen || a.confirmOpen || a.contextOpen || a.dirtyOpen || a.formOpen || a.findOpen || a.finderOpen || a.diffOpen } // ----------------------------------------------------------------------------- diff --git a/internal/app/searchfiles.go b/internal/app/searchfiles.go new file mode 100644 index 0000000..b787a70 --- /dev/null +++ b/internal/app/searchfiles.go @@ -0,0 +1,484 @@ +// ============================================================================= +// File: internal/app/searchfiles.go +// Author: Spicer Matthews +// Created: 2026-06-21 +// Copyright: 2026 Cloudmanic, LLC. All rights reserved. +// ============================================================================= + +package app + +// Project-wide content search ("Find in files") — the VS Code +// Ctrl+Shift+F gesture. A centered modal with a search input on top and a +// scrollable list of "path:line preview" match rows below. Type a word to +// grep every file the project index knows about, ↑/↓ to move through hits, +// Enter to jump straight to the match, Esc to dismiss. +// +// This mirrors finder.go (the file finder) almost beat-for-beat — same +// modal shape, same input editing, same async-index handling — but the +// results are line-level content matches instead of file paths, and the +// grep runs on a background goroutine (it touches the filesystem, so it +// can't block the UI thread on a large repo). A generation counter drops +// stale results when the user keeps typing. + +import ( + "path/filepath" + "time" + + "github.com/cloudmanic/spice-edit/internal/editor" + "github.com/cloudmanic/spice-edit/internal/finder" + "github.com/gdamore/tcell/v2" +) + +const ( + // searchModalMaxWidth caps the modal width. Content matches need more + // room than bare file paths — the preview snippet eats horizontal + // space — so this runs wider than the file finder's 80. + searchModalMaxWidth = 100 + // searchResultsVisible is how many match rows we render at once. + searchResultsVisible = 14 + // searchLimit caps how many matches the grep collects. Past this the + // user should refine the query rather than scroll thousands of rows. + searchLimit = 500 +) + +// searchResultsEvent is posted by the background grep goroutine when a +// query finishes. gen guards against stale results: the main loop only +// applies the payload when its gen still matches the live query, so a +// slow search for an old query can't clobber a newer one. +type searchResultsEvent struct { + when time.Time + gen int + results []finder.ContentMatch +} + +// When satisfies the tcell.Event interface. +func (e *searchResultsEvent) When() time.Time { return e.when } + +// openSearchFiles shows the project-wide content search modal. Like the +// file finder it's a no-op in single-file mode (no project index) and it +// kicks a background index rebuild so freshly-changed files are searched. +func (a *App) openSearchFiles() { + if a.tree == nil { + a.flash("Find in files isn't available in single-file mode") + return + } + a.closeAllModals() + a.searchOpen = true + a.searchQuery = nil + a.searchCursor = 0 + a.searchScroll = 0 + a.searchSelected = 0 + a.searchViewTop = 0 + a.searchResults = nil + a.searchDone = false + scr := a.screen + if a.finder != nil && a.finder.State() != finder.StateReady { + a.finder.Rebuild(func() { + _ = scr.PostEvent(&finderRebuiltEvent{when: time.Now()}) + }) + } +} + +// closeSearchFiles dismisses the modal and clears its transient state. +// Bumping searchGen guarantees any in-flight grep goroutine's result is +// ignored when it eventually posts. +func (a *App) closeSearchFiles() { + a.searchOpen = false + a.searchQuery = nil + a.searchCursor = 0 + a.searchScroll = 0 + a.searchSelected = 0 + a.searchViewTop = 0 + a.searchResults = nil + a.searchDone = false + a.searchGen++ +} + +// menuSearchFiles is the ≡ menu entry point. Sits next to menuFindFile in +// the Search group — same vocabulary, different scope (contents vs paths). +func (a *App) menuSearchFiles() { + a.closeMenu() + a.openSearchFiles() +} + +// hasSearchFiles is the menu predicate: available whenever the project +// finder is wired (i.e. not single-file mode). +func (a *App) hasSearchFiles() bool { + return a.finder != nil +} + +// runSearch kicks off a background grep for the current query. It bumps +// the generation counter first so any earlier in-flight search is dropped +// when it returns, then spawns a goroutine that greps and posts the +// results back to the event loop. An empty query clears results without +// touching the filesystem. +func (a *App) runSearch() { + a.searchGen++ + gen := a.searchGen + query := string(a.searchQuery) + a.searchSelected = 0 + a.searchViewTop = 0 + if query == "" || a.finder == nil { + a.searchResults = nil + a.searchDone = false + return + } + a.searchDone = false + scr := a.screen + f := a.finder + go func() { + results := f.SearchContent(query, searchLimit) + _ = scr.PostEvent(&searchResultsEvent{when: time.Now(), gen: gen, results: results}) + }() +} + +// applySearchResults installs the payload of a searchResultsEvent when it +// still matches the live query. Called from the main event loop. +func (a *App) applySearchResults(e *searchResultsEvent) { + if !a.searchOpen || e.gen != a.searchGen { + return + } + a.searchResults = e.results + a.searchDone = true + a.searchSelected = 0 + a.searchViewTop = 0 +} + +// handleSearchKey routes keyboard input while the modal is open. Text +// editing mirrors the file finder; the search-specific bits are result +// navigation and Enter-to-jump. +func (a *App) handleSearchKey(ev *tcell.EventKey) { + switch ev.Key() { + case tcell.KeyEsc: + a.closeSearchFiles() + case tcell.KeyEnter: + a.openSelectedSearchResult() + case tcell.KeyUp: + if a.searchSelected > 0 { + a.searchSelected-- + a.adjustSearchView() + } + case tcell.KeyDown: + if a.searchSelected < len(a.searchResults)-1 { + a.searchSelected++ + a.adjustSearchView() + } + case tcell.KeyLeft: + if a.searchCursor > 0 { + a.searchCursor-- + } + case tcell.KeyRight: + if a.searchCursor < len(a.searchQuery) { + a.searchCursor++ + } + case tcell.KeyHome: + a.searchCursor = 0 + case tcell.KeyEnd: + a.searchCursor = len(a.searchQuery) + case tcell.KeyBackspace, tcell.KeyBackspace2: + if a.searchCursor > 0 { + a.searchQuery = append(a.searchQuery[:a.searchCursor-1], a.searchQuery[a.searchCursor:]...) + a.searchCursor-- + a.runSearch() + } + case tcell.KeyDelete: + if a.searchCursor < len(a.searchQuery) { + a.searchQuery = append(a.searchQuery[:a.searchCursor], a.searchQuery[a.searchCursor+1:]...) + a.runSearch() + } + case tcell.KeyRune: + r := ev.Rune() + if r < 0x20 { + return + } + next := make([]rune, 0, len(a.searchQuery)+1) + next = append(next, a.searchQuery[:a.searchCursor]...) + next = append(next, r) + next = append(next, a.searchQuery[a.searchCursor:]...) + a.searchQuery = next + a.searchCursor++ + a.runSearch() + } +} + +// handleSearchMouse handles mouse input while the modal is open. Hover +// highlights the row under the cursor; click jumps to it; the wheel +// scrolls the result list; a click outside dismisses. +func (a *App) handleSearchMouse(x, y int, btn tcell.ButtonMask) { + mx, my, mw, mh := a.searchModalRect() + rowsStart := my + 4 + rowsCap := a.searchVisibleRows() + + if btn&tcell.WheelUp != 0 { + if a.searchViewTop > 0 { + a.searchViewTop-- + } + return + } + if btn&tcell.WheelDown != 0 { + if a.searchViewTop < len(a.searchResults)-rowsCap { + a.searchViewTop++ + } + return + } + + row := y - rowsStart + if row >= 0 && row < rowsCap && x >= mx && x < mx+mw { + idx := a.searchViewTop + row + if idx < len(a.searchResults) { + a.searchSelected = idx + } + } + if btn&tcell.Button1 == 0 { + return + } + if x < mx || x >= mx+mw || y < my || y >= my+mh { + a.closeSearchFiles() + return + } + if row >= 0 && row < rowsCap { + idx := a.searchViewTop + row + if idx < len(a.searchResults) { + a.searchSelected = idx + a.openSelectedSearchResult() + } + } +} + +// openSelectedSearchResult opens the file for the selected match, drops +// the cursor onto the match, scrolls it into view, and closes the modal. +func (a *App) openSelectedSearchResult() { + if a.searchSelected < 0 || a.searchSelected >= len(a.searchResults) { + return + } + m := a.searchResults[a.searchSelected] + a.closeSearchFiles() + abs := filepath.Join(a.rootDir, filepath.FromSlash(m.Path)) + a.openFile(abs) + tab := a.activeTabPtr() + if tab == nil { + return + } + tab.MoveCursorTo(editor.Position{Line: m.Line, Col: m.Col}, false) + _, _, ew, eh := a.editorRect() + tab.EnsureVisible(ew, eh) +} + +// searchVisibleRows returns how many result rows the modal can show given +// the current terminal height, capped at searchResultsVisible. +func (a *App) searchVisibleRows() int { + _, _, _, mh := a.searchModalRect() + rowsCap := mh - 5 // borders + title + divider + input + if rowsCap > searchResultsVisible { + rowsCap = searchResultsVisible + } + if rowsCap < 0 { + rowsCap = 0 + } + return rowsCap +} + +// adjustSearchView slides the vertical scroll offset so the selected row +// stays inside the visible window. +func (a *App) adjustSearchView() { + rows := a.searchVisibleRows() + if rows <= 0 { + a.searchViewTop = 0 + return + } + if a.searchSelected < a.searchViewTop { + a.searchViewTop = a.searchSelected + } + if a.searchSelected >= a.searchViewTop+rows { + a.searchViewTop = a.searchSelected - rows + 1 + } + if a.searchViewTop < 0 { + a.searchViewTop = 0 + } +} + +// searchModalRect returns the on-screen rectangle of the search modal. +func (a *App) searchModalRect() (x, y, w, h int) { + w = searchModalMaxWidth + if w > a.width-4 { + w = a.width - 4 + } + if w < 30 { + w = 30 + } + // Layout: 1 border + 1 title + 1 divider + 1 input + N results + // + 1 border = N+5 rows. + h = searchResultsVisible + 5 + if h > a.height-2 { + h = a.height - 2 + } + x = (a.width - w) / 2 + y = (a.height - h) / 3 + if x < 0 { + x = 0 + } + if y < 0 { + y = 0 + } + return +} + +// drawSearch paints the modal: title + Esc hint, input field with a +// match-count tail, then either an "Indexing…" / "Searching…" line or the +// match rows. +func (a *App) drawSearch() { + mx, my, mw, mh := a.searchModalRect() + bg := a.theme.LineHL + bgStyle := tcell.StyleDefault.Background(bg).Foreground(a.theme.Text) + borderStyle := tcell.StyleDefault.Background(bg).Foreground(a.theme.Subtle) + titleStyle := tcell.StyleDefault.Background(bg).Foreground(a.theme.Accent).Bold(true) + mutedStyle := tcell.StyleDefault.Background(bg).Foreground(a.theme.Muted) + hitStyle := tcell.StyleDefault.Background(bg).Foreground(a.theme.FindCurrent).Bold(true) + + fillRect(a.screen, mx, my, mw, mh, bgStyle) + drawBorder(a.screen, mx, my, mw, mh, borderStyle) + drawHDivider(a.screen, mx, my+2, mw, borderStyle) + + drawAt(a.screen, mx+1, my+1, " Find in files", titleStyle) + hint := "esc " + drawAt(a.screen, mx+mw-1-runeLen(hint), my+1, hint, mutedStyle) + + // Input row. + inputBg := a.theme.BG + inputStyle := tcell.StyleDefault.Background(inputBg).Foreground(a.theme.Text) + fieldStart := mx + 3 + fieldEnd := mx + mw - 14 // leave room for the count tail + fieldWidth := fieldEnd - fieldStart + a.adjustSearchScroll(fieldWidth) + for cx := fieldStart - 1; cx <= fieldEnd; cx++ { + a.screen.SetContent(cx, my+3, ' ', nil, inputStyle) + } + for i := 0; i < fieldWidth; i++ { + idx := a.searchScroll + i + if idx >= len(a.searchQuery) { + break + } + a.screen.SetContent(fieldStart+i, my+3, a.searchQuery[idx], nil, inputStyle) + } + caret := fieldStart + (a.searchCursor - a.searchScroll) + if caret >= fieldStart && caret <= fieldEnd { + a.screen.ShowCursor(caret, my+3) + } + + // Count tail — mirrors the finder's status vocabulary. + tail := a.searchTail() + drawAt(a.screen, mx+mw-1-runeLen(tail), my+3, tail, mutedStyle) + + // Result rows. + rowsStart := my + 4 + rowsCap := a.searchVisibleRows() + for i := 0; i < rowsCap; i++ { + ry := rowsStart + i + idx := a.searchViewTop + i + if idx >= len(a.searchResults) { + for cx := mx + 1; cx < mx+mw-1; cx++ { + a.screen.SetContent(cx, ry, ' ', nil, bgStyle) + } + continue + } + a.drawSearchRow(mx, ry, mw, a.searchResults[idx], idx == a.searchSelected, hitStyle, mutedStyle, bg) + } +} + +// searchTail returns the status string shown at the right of the input: +// index state, a "searching…" spinner-less placeholder, a match count, or +// "no results" when a finished search came back empty. +func (a *App) searchTail() string { + state := finder.StateIdle + if a.finder != nil { + state = a.finder.State() + } + switch state { + case finder.StateBuilding, finder.StateIdle: + return "indexing… " + case finder.StateErrored: + return "index err " + } + if len(a.searchQuery) == 0 { + return "" + } + if !a.searchDone { + return "searching… " + } + if len(a.searchResults) == 0 { + return "no results " + } + n := len(a.searchResults) + if n >= searchLimit { + return itoa(n) + "+ " + } + return itoa(n) + " " +} + +// drawSearchRow paints one match line: a dimmed "path:line" location +// prefix, then the trimmed source line with the matched run highlighted. +// The selected row's background flips to the editor BG so it reads as a +// single block, matching the file finder's selection styling. +func (a *App) drawSearchRow(mx, ry, mw int, m finder.ContentMatch, selected bool, hitStyle, mutedStyle tcell.Style, modalBG tcell.Color) { + rowBG := modalBG + if selected { + rowBG = a.theme.BG + } + rowStyle := tcell.StyleDefault.Background(rowBG).Foreground(a.theme.Text) + hitOnRow := hitStyle.Background(rowBG) + mutedOnRow := mutedStyle.Background(rowBG) + + // Background fill. + for cx := mx + 1; cx < mx+mw-1; cx++ { + a.screen.SetContent(cx, ry, ' ', nil, rowStyle) + } + + startCol := mx + 2 + maxCols := mw - 4 + + // Location prefix: "path:line " (line shown 1-based to match editors). + loc := m.Path + ":" + itoa(m.Line+1) + " " + locRunes := []rune(loc) + col := 0 + for ; col < len(locRunes) && col < maxCols; col++ { + a.screen.SetContent(startCol+col, ry, locRunes[col], nil, mutedOnRow) + } + + // Preview: trim leading whitespace so the code content starts right + // after the location, tracking how many runes we dropped so the + // highlight offset stays correct. + preview := []rune(m.Preview) + trimmed := 0 + for trimmed < len(preview) && (preview[trimmed] == ' ' || preview[trimmed] == '\t') { + trimmed++ + } + preview = preview[trimmed:] + hitStart := m.Col - trimmed + hitEnd := hitStart + m.Width + + for i := 0; i < len(preview) && col < maxCols; i, col = i+1, col+1 { + st := rowStyle + if i >= hitStart && i < hitEnd { + st = hitOnRow + } + a.screen.SetContent(startCol+col, ry, preview[i], nil, st) + } +} + +// adjustSearchScroll keeps the input caret visible by sliding searchScroll +// within the input field. Mirrors adjustFinderScroll. +func (a *App) adjustSearchScroll(width int) { + if width <= 0 { + a.searchScroll = 0 + return + } + if a.searchCursor < a.searchScroll { + a.searchScroll = a.searchCursor + } + if a.searchCursor-a.searchScroll >= width { + a.searchScroll = a.searchCursor - width + 1 + } + if a.searchScroll < 0 { + a.searchScroll = 0 + } +} diff --git a/internal/app/searchfiles_test.go b/internal/app/searchfiles_test.go new file mode 100644 index 0000000..69bca9c --- /dev/null +++ b/internal/app/searchfiles_test.go @@ -0,0 +1,157 @@ +// ============================================================================= +// File: internal/app/searchfiles_test.go +// Author: Spicer Matthews +// Created: 2026-06-21 +// Copyright: 2026 Cloudmanic, LLC. All rights reserved. +// ============================================================================= + +package app + +import ( + "os" + "path/filepath" + "testing" + + "github.com/cloudmanic/spice-edit/internal/finder" + "github.com/gdamore/tcell/v2" +) + +// withSearch wires an App + indexed finder rooted at a tempdir seeded with +// files whose *contents* we can grep. Mirrors withFinder but the bodies +// matter here, not just the paths. +func withSearch(t *testing.T) (*App, string) { + t.Helper() + dir := t.TempDir() + files := map[string]string{ + "main.go": "package main\n\nfunc widget() {}\n", + "internal/app/app.go": "package app\n// a widget lives here\n", + "internal/finder/x.go": "package finder\nfunc unrelated() {}\n", + "README.md": "no match on that word\n", + } + for f, body := range files { + abs := filepath.Join(dir, f) + if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(abs, []byte(body), 0644); err != nil { + t.Fatalf("write: %v", err) + } + } + a := newTestApp(t, dir) + a.finder = finder.New(a.rootDir) + a.finder.Rebuild(nil) + waitForFinderReady(t, a) + return a, dir +} + +// runSearchSync drives the search synchronously: it greps on the calling +// goroutine and applies the result the same way the event loop would, so +// tests don't have to race the background goroutine. +func runSearchSync(a *App, query string) { + a.searchQuery = []rune(query) + a.searchCursor = len(a.searchQuery) + a.searchGen++ + gen := a.searchGen + results := a.finder.SearchContent(query, searchLimit) + a.applySearchResults(&searchResultsEvent{gen: gen, results: results}) +} + +// TestOpenSearchFiles_State pins the open/close wiring. +func TestOpenSearchFiles_State(t *testing.T) { + a, _ := withSearch(t) + a.openSearchFiles() + if !a.searchOpen { + t.Fatal("searchOpen should be true after openSearchFiles") + } + a.closeSearchFiles() + if a.searchOpen || a.searchResults != nil { + t.Fatal("closeSearchFiles should clear modal state") + } +} + +// TestSearch_FindsAcrossFiles greps a word present in two files and +// asserts both matches show up. +func TestSearch_FindsAcrossFiles(t *testing.T) { + a, _ := withSearch(t) + a.openSearchFiles() + runSearchSync(a, "widget") + + if !a.searchDone { + t.Fatal("searchDone should be set after results applied") + } + if len(a.searchResults) != 2 { + t.Fatalf("want 2 'widget' matches, got %d: %+v", len(a.searchResults), a.searchResults) + } +} + +// TestSearch_EmptyQueryClears verifies deleting back to empty clears the +// result list without hitting the filesystem. +func TestSearch_EmptyQueryClears(t *testing.T) { + a, _ := withSearch(t) + a.openSearchFiles() + runSearchSync(a, "widget") + if len(a.searchResults) == 0 { + t.Fatal("precondition: expected matches") + } + a.searchQuery = nil + a.searchCursor = 0 + a.runSearch() + if a.searchResults != nil { + t.Fatalf("empty query should clear results, got %+v", a.searchResults) + } +} + +// TestSearch_OpenSelectedJumps opens the selected match and asserts the +// right file is active with the cursor on the match line. +func TestSearch_OpenSelectedJumps(t *testing.T) { + a, _ := withSearch(t) + a.openSearchFiles() + runSearchSync(a, "unrelated") + + if len(a.searchResults) != 1 { + t.Fatalf("want 1 match, got %d", len(a.searchResults)) + } + m := a.searchResults[0] + a.searchSelected = 0 + a.openSelectedSearchResult() + + if a.searchOpen { + t.Fatal("opening a result should close the modal") + } + tab := a.activeTabPtr() + if tab == nil { + t.Fatal("expected an active tab after jumping to a match") + } + if filepath.Base(tab.Path) != "x.go" { + t.Fatalf("active tab = %q, want x.go", tab.Path) + } + if tab.Cursor.Line != m.Line { + t.Fatalf("cursor line = %d, want %d", tab.Cursor.Line, m.Line) + } +} + +// TestSearch_SingleFileModeNoOp confirms the modal refuses to open when +// there's no project tree (single-file invocation). +func TestSearch_SingleFileModeNoOp(t *testing.T) { + a, _ := withSearch(t) + a.tree = nil + a.openSearchFiles() + if a.searchOpen { + t.Fatal("search modal must not open in single-file mode") + } +} + +// TestSearch_KeyTypingTriggers ensures typing a rune routes through the +// search key handler and updates the query. +func TestSearch_KeyTypingTriggers(t *testing.T) { + a, _ := withSearch(t) + a.openSearchFiles() + a.handleSearchKey(keyEv(tcell.KeyRune, 'w')) + if string(a.searchQuery) != "w" { + t.Fatalf("query = %q, want w", string(a.searchQuery)) + } + a.handleSearchKey(keyEv(tcell.KeyEsc, 0)) + if a.searchOpen { + t.Fatal("Esc should close the search modal") + } +} diff --git a/internal/finder/grep.go b/internal/finder/grep.go new file mode 100644 index 0000000..4d24248 --- /dev/null +++ b/internal/finder/grep.go @@ -0,0 +1,174 @@ +// ============================================================================= +// File: internal/finder/grep.go +// Author: Spicer Matthews +// Created: 2026-06-21 +// Copyright: 2026 Cloudmanic, LLC. All rights reserved. +// ============================================================================= + +package finder + +// Content search ("Find in files"). Where Search() fuzzy-matches file +// *paths*, SearchContent() greps the *contents* of every indexed file for a +// substring — the VS Code "Search across files" (Ctrl+Shift+F) gesture. +// +// It reuses the exact same path list the file finder already builds (git +// fast path + gitignore fallback), so the scope is identical: tracked and +// untracked-not-ignored files only, never node_modules / .git / vendored +// dumps. That means the content search inherits the finder's ignore rules +// for free and never surprises the user by matching inside a file the tree +// wouldn't show. +// +// Matching mirrors the in-file find (internal/editor/find.go): a +// case-insensitive substring over rune-decoded lines, so multi-byte +// characters count as one column and the reported Col lines up with the +// editor's cursor model. Regex / whole-word / case-sensitive toggles are +// intentionally out of scope — the 80/20 is "type a word, jump to it". + +import ( + "bytes" + "os" + "path/filepath" + "strings" +) + +const ( + // maxGrepFileSize skips files larger than this so a stray multi-MB + // log or minified bundle can't stall a keystroke-driven search. 2MB + // comfortably covers real source files. + maxGrepFileSize = 2 << 20 // 2 MiB + + // maxMatchesPerFile caps how many hits a single file contributes so + // one file full of the query doesn't crowd out every other file in + // the result list. The user refines the query to dig deeper. + maxMatchesPerFile = 50 + + // binarySniffBytes is how much of a file's head we scan for a NUL + // byte before deciding it's binary and skipping it. 8000 is the same + // heuristic git uses. + binarySniffBytes = 8000 +) + +// ContentMatch is one line-level hit returned by SearchContent. Path is +// project-relative (forward slashes); Line/Col are 0-based rune-indexed +// coordinates matching the editor's cursor model, so a caller can open +// the file and drop the cursor straight onto the match. Width is the rune +// length of the query. Preview is the full text of the matched line so the +// renderer can show context and highlight the hit at column Col. +type ContentMatch struct { + Path string + Line int + Col int + Width int + Preview string +} + +// SearchContent greps every indexed file for query and returns up to +// `limit` line-level matches in (path, line) order. An empty query, or a +// call made before the index is ready, returns nil — the caller renders +// an "Indexing…" / empty placeholder instead. +// +// Reads happen off the cached path snapshot, so it's safe to call from a +// background goroutine while the index rebuilds underneath it. It is the +// caller's job to run this off the UI thread: it touches the filesystem +// and, on a large repo, can take longer than a frame. +func (f *Finder) SearchContent(query string, limit int) []ContentMatch { + if limit <= 0 { + limit = 200 + } + if query == "" { + return nil + } + f.mu.RLock() + paths := f.paths + root := f.rootDir + state := f.state + f.mu.RUnlock() + if state == StateIdle || state == StateBuilding { + return nil + } + + needle := []rune(strings.ToLower(query)) + if len(needle) == 0 { + return nil + } + + out := make([]ContentMatch, 0, 64) + for _, rel := range paths { + abs := filepath.Join(root, filepath.FromSlash(rel)) + info, err := os.Stat(abs) + if err != nil || info.IsDir() || info.Size() > maxGrepFileSize { + continue + } + data, err := os.ReadFile(abs) + if err != nil || isBinary(data) { + continue + } + out = grepFile(out, rel, data, needle, limit) + if len(out) >= limit { + return out[:limit] + } + } + return out +} + +// grepFile appends every case-insensitive match of needle inside data to +// out, tagging each with rel/line/col. Stops early once out reaches limit +// or the file contributes maxMatchesPerFile hits. Non-overlapping: after a +// hit the scanner advances past the matched run. +func grepFile(out []ContentMatch, rel string, data []byte, needle []rune, limit int) []ContentMatch { + perFile := 0 + // bytes.Split keeps empty lines so line numbers stay faithful to the + // file — the editor counts them the same way. + lines := bytes.Split(data, []byte{'\n'}) + for lineIdx, raw := range lines { + // Strip a trailing CR so CRLF files don't leave a stray column at + // the end of every preview. + raw = bytes.TrimSuffix(raw, []byte{'\r'}) + hayRaw := []rune(string(raw)) + hay := []rune(strings.ToLower(string(raw))) + col := 0 + for col+len(needle) <= len(hay) { + if runesEqualLower(hay[col:col+len(needle)], needle) { + out = append(out, ContentMatch{ + Path: rel, + Line: lineIdx, + Col: col, + Width: len(needle), + Preview: string(hayRaw), + }) + perFile++ + if len(out) >= limit || perFile >= maxMatchesPerFile { + return out + } + col += len(needle) + continue + } + col++ + } + } + return out +} + +// runesEqualLower compares an already-lowercased haystack slice against a +// lowercased needle element-for-element. Inlined so the hot inner loop of +// grepFile doesn't pay for a generic slices.Equal call. +func runesEqualLower(a, b []rune) bool { + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// isBinary reports whether data looks like a binary file — i.e. contains a +// NUL byte in its first binarySniffBytes. Same cheap heuristic git uses to +// decide "binary"; good enough to keep the search from dumping garbage +// previews for images / compiled objects that slipped past gitignore. +func isBinary(data []byte) bool { + head := data + if len(head) > binarySniffBytes { + head = head[:binarySniffBytes] + } + return bytes.IndexByte(head, 0) >= 0 +} diff --git a/internal/finder/grep_test.go b/internal/finder/grep_test.go new file mode 100644 index 0000000..be30409 --- /dev/null +++ b/internal/finder/grep_test.go @@ -0,0 +1,125 @@ +// ============================================================================= +// File: internal/finder/grep_test.go +// Author: Spicer Matthews +// Created: 2026-06-21 +// Copyright: 2026 Cloudmanic, LLC. All rights reserved. +// ============================================================================= + +package finder + +import ( + "os" + "path/filepath" + "testing" +) + +// buildReady returns a Finder whose index has been built synchronously so +// SearchContent has a stable path list to grep. +func buildReady(t *testing.T, root string) *Finder { + t.Helper() + f := New(root) + done := make(chan struct{}) + f.Rebuild(func() { close(done) }) + <-done + if f.State() != StateReady { + t.Fatalf("index state = %v, want StateReady", f.State()) + } + return f +} + +func writeFile(t *testing.T, dir, rel, body string) { + t.Helper() + p := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestSearchContentFindsMatches(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.go", "package main\nfunc Hello() {}\n") + writeFile(t, dir, "sub/b.txt", "hello world\nHELLO again\n") + + f := buildReady(t, dir) + + got := f.SearchContent("hello", 100) + if len(got) != 3 { + t.Fatalf("SearchContent(hello) = %d matches, want 3: %+v", len(got), got) + } + + // Case-insensitive: the ALL-CAPS "HELLO" line must match too. + var sawCaps bool + for _, m := range got { + if m.Path == "sub/b.txt" && m.Line == 1 { + sawCaps = true + if m.Col != 0 { + t.Errorf("HELLO match col = %d, want 0", m.Col) + } + } + } + if !sawCaps { + t.Errorf("expected a case-insensitive match on the HELLO line") + } +} + +func TestSearchContentEmptyQuery(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.txt", "anything") + f := buildReady(t, dir) + if got := f.SearchContent("", 100); got != nil { + t.Fatalf("empty query should return nil, got %+v", got) + } +} + +func TestSearchContentSkipsBinary(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "bin.dat", "match\x00match\n") + writeFile(t, dir, "text.txt", "match here\n") + f := buildReady(t, dir) + + got := f.SearchContent("match", 100) + for _, m := range got { + if m.Path == "bin.dat" { + t.Fatalf("binary file should be skipped, got match %+v", m) + } + } + if len(got) != 1 { + t.Fatalf("want 1 match (text only), got %d: %+v", len(got), got) + } +} + +func TestSearchContentLimit(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "many.txt", "x\nx\nx\nx\nx\n") + f := buildReady(t, dir) + + got := f.SearchContent("x", 3) + if len(got) != 3 { + t.Fatalf("limit=3 should cap results, got %d", len(got)) + } +} + +func TestSearchContentPreviewAndCol(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "c.txt", " indented needle here\n") + f := buildReady(t, dir) + + got := f.SearchContent("needle", 10) + if len(got) != 1 { + t.Fatalf("want 1 match, got %d", len(got)) + } + m := got[0] + if m.Preview != " indented needle here" { + t.Errorf("preview = %q, want full untrimmed line", m.Preview) + } + // " indented " is 11 runes before "needle". + if m.Col != 11 { + t.Errorf("col = %d, want 11", m.Col) + } + if m.Width != 6 { + t.Errorf("width = %d, want 6", m.Width) + } +} From 5036bda02f20957cd640427ac39402c91c3db9d6 Mon Sep 17 00:00:00 2001 From: jaivial <114494844+jaivial@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:07:05 +0000 Subject: [PATCH 3/8] Bump version to 0.0.44 --- internal/version/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/version/version.go b/internal/version/version.go index c59f593..9475cac 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -12,4 +12,4 @@ package version // Version is the SpiceEdit release version, displayed in the menu footer. // Bump this constant on each release (or let release automation do it). -const Version = "0.0.43" +const Version = "0.0.44" From 23c85a9fbd3f05ad044e006cbbaa6786f06a1b44 Mon Sep 17 00:00:00 2001 From: Jaime Villanueva Alcon <114494844+jaivial@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:02:02 +0200 Subject: [PATCH 4/8] feat: sidebar find-in-files panel with Files/Find tab strip (#1) Add persistent Find in files surface in the sidebar, grouped into per-file accordions; matches jump to line on click. Independent state from the Esc-F modal so both search surfaces coexist. Also add far-right terminal tab button in the tab bar. --- internal/app/app.go | 91 ++++- internal/app/sidebarsearch.go | 542 +++++++++++++++++++++++++++++ internal/app/sidebarsearch_test.go | 111 ++++++ internal/app/terminal_test.go | 22 ++ 4 files changed, 765 insertions(+), 1 deletion(-) create mode 100644 internal/app/sidebarsearch.go create mode 100644 internal/app/sidebarsearch_test.go diff --git a/internal/app/app.go b/internal/app/app.go index ce3bd48..ab85fb0 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -74,6 +74,9 @@ const ( // of the tab bar. Tabs render starting just after it. menuButtonWidth = 4 + // terminalTabBtnWidth is the far-right "+"/terminal button's cell count. + terminalTabBtnWidth = 3 + // modalWidth is the action modal's column count. Sized to comfortably // fit the longest dynamic label — "Rename folder (subdir/)" with a // folder name up to maxLabelSuffix runes — plus the leading "▸ " @@ -521,6 +524,25 @@ type App struct { searchGen int searchDone bool + // Terminal-tab button x in the tab bar (far right). -1 when hidden. + newTabBtnX int + + // Sidebar header tab + find-in-files panel state. Independent of the + // searchOpen modal above so the two search surfaces coexist. + sidebarTab string // "files" | "search" + sidebarSearchFocused bool + sidebarSearchQuery []rune + sidebarSearchCursor int + sidebarSearchScroll int + sidebarSearchResults []finder.ContentMatch + sidebarSearchGen int + sidebarSearchDone bool + sidebarSearchSelected int + sidebarSearchViewTop int + sidebarSearchCollapsed map[string]bool + sidebarSearchRows []sidebarSearchRow + sidebarSearchVisibleMatches []finder.ContentMatch + // confirmCancelHook runs when the active confirm modal is dismissed // without a Yes — i.e. the user picked No, hit Esc, or clicked // outside. Set after openConfirm by flows that want to react to the @@ -562,6 +584,7 @@ func New(rootDir string) (*App, error) { hoveredMenuRow: -1, sidebarShown: true, sidebarWidth: defaultSidebarWidth, + sidebarTab: "files", } a.setActiveFolder(tree.Root.Path) a.loadSpiceConfig() @@ -621,6 +644,7 @@ func NewSingleFile(filePath string) (*App, error) { hoveredMenuRow: -1, sidebarShown: false, sidebarWidth: defaultSidebarWidth, + sidebarTab: "files", } a.setActiveFolder(rootDir) a.loadSpiceConfig() @@ -834,8 +858,13 @@ func (a *App) handleEvent(ev tcell.Event) { if a.searchOpen && len(a.searchQuery) > 0 { a.runSearch() } + if a.sidebarTab == "search" && len(a.sidebarSearchQuery) > 0 { + a.sidebarRunSearch() + } case *searchResultsEvent: a.applySearchResults(e) + case *sidebarSearchResultsEvent: + a.applySidebarSearchResults(e) } } @@ -1117,6 +1146,10 @@ func (a *App) handleKey(ev *tcell.EventKey) { a.handleSearchKey(ev) return } + if a.sidebarTab == "search" && a.sidebarSearchFocused { + a.handleSidebarSearchKey(ev) + return + } if ev.Key() == tcell.KeyEsc { // Esc is the editor's only command key. Behavior: @@ -1422,6 +1455,13 @@ func (a *App) handleMenuMouse(x, y int, btn tcell.ButtonMask) { // scrollAt scrolls whichever panel the (x, y) cursor is over. func (a *App) scrollAt(x, y, delta int) { if sw := a.sidebarW(); sw > 0 && x < sw { + if a.sidebarTab == "search" && y >= 2 { + a.sidebarSearchViewTop += delta + if a.sidebarSearchViewTop < 0 { + a.sidebarSearchViewTop = 0 + } + return + } a.tree.Scroll(delta) return } @@ -1454,6 +1494,9 @@ func (a *App) scrollAtH(x, y, delta int) { // menu's New File defaults to a sensible target even after the context // menu closes. func (a *App) tryTreeContextClick(x, y int) bool { + if a.sidebarTab != "files" { + return false + } sw := a.sidebarW() if sw <= 0 { return false @@ -1484,7 +1527,24 @@ func (a *App) tryTreeContextClick(x, y int) bool { // since the root is always shown and there's no useful "collapsed // root" state. func (a *App) sidebarClick(x, y int) { - sx, sy, _, _ := a.sidebarRect() + sx, sy, sw, _ := a.sidebarRect() + + // Header tab strip (row 0): Files | Find in files. + if y == sy { + if x < sx+sw/2 { + a.switchSidebarTab("files") + } else { + a.switchSidebarTab("search") + } + return + } + + // Find-in-files panel owns the sidebar body when its tab is active. + if a.sidebarTab == "search" { + a.sidebarSearchClick(x, y) + return + } + n, ok := a.tree.HitTest(x-sx, y-sy) if !ok { return @@ -1524,6 +1584,10 @@ func (a *App) tabBarClick(x, _ int) { a.openMenu() return } + if a.newTabBtnX >= 0 && x >= a.newTabBtnX && x < a.newTabBtnX+terminalTabBtnWidth { + a.menuOpenTerminal() + return + } for _, r := range a.lastTabRects { if x >= r.X && x < r.X+r.Width { if x == r.CloseX { @@ -2465,6 +2529,10 @@ func (a *App) draw() { if a.sidebarShown { sx, sy, sw, sh := a.sidebarRect() a.tree.Render(a.screen, a.theme, sx, sy, sw, sh) + a.drawSidebarTabs() + if a.sidebarTab == "search" { + a.drawSidebarSearch() + } a.drawSplitter() } @@ -2633,6 +2701,27 @@ func (a *App) drawTabBar() { a.screen.SetContent(col, ty, '×', nil, closeStyle) } } + + // Terminal-tab button pinned to the far right of the tab bar. Drawn + // last so it covers any tab that would overflow beneath it. + a.newTabBtnX = -1 + if a.canOpenTerminal() { + btnX := tx + tw - terminalTabBtnWidth + if btnX >= tx { + a.newTabBtnX = btnX + btnStyle := tcell.StyleDefault.Background(a.theme.SidebarBG).Foreground(a.theme.Accent) + for cx := btnX; cx < tx+tw; cx++ { + a.screen.SetContent(cx, ty, ' ', nil, btnStyle) + } + if a.iconsOn() { + for _, gr := range icons.Terminal { + a.screen.SetContent(btnX+1, ty, gr, nil, btnStyle) + } + } else { + a.screen.SetContent(btnX+1, ty, '+', nil, btnStyle) + } + } + } } // drawSplitter paints a 1-column vertical line at the right edge of the diff --git a/internal/app/sidebarsearch.go b/internal/app/sidebarsearch.go new file mode 100644 index 0000000..78f3c39 --- /dev/null +++ b/internal/app/sidebarsearch.go @@ -0,0 +1,542 @@ +// ============================================================================= +// File: internal/app/sidebarsearch.go +// Author: Spicer Matthews +// Created: 2026-08-12 +// Copyright: 2026 Cloudmanic, LLC. All rights reserved. +// ============================================================================= + +package app + +import ( + "path/filepath" + "time" + + "github.com/gdamore/tcell/v2" + + "github.com/cloudmanic/spice-edit/internal/editor" + "github.com/cloudmanic/spice-edit/internal/finder" + "github.com/cloudmanic/spice-edit/internal/theme" +) + +// Sidebar "Find in files" panel plus the Files/Find header tab strip. +// +// This is a second, persistent surface for the same grep the centered +// "Find in files" modal (searchfiles.go) runs. The sidebar keeps its own +// query/result state so opening the Esc-F modal doesn't clobber it, and +// vice-versa. Results are grouped into per-file accordions: a file header +// toggles its match list, and each match row jumps to that line on click. + +// sidebarSearchGroup is one file's worth of matches, in path order. +type sidebarSearchGroup struct { + Path string + Matches []finder.ContentMatch + Collapsed bool +} + +// sidebarSearchRow is one rendered row in the results area, used for click +// hit-testing (same idea as filetree's visible-row list). +type sidebarSearchRow struct { + kind string // "header" | "match" + path string + count int // matches in this file, for header rows + collapsed bool + match finder.ContentMatch + matchIndex int // index into sidebarSearchVisibleMatches, for match rows +} + +// sidebarSearchResultsEvent is posted by the background grep goroutine when +// a sidebar query finishes. gen drops stale results, mirroring the modal. +type sidebarSearchResultsEvent struct { + when time.Time + gen int + results []finder.ContentMatch +} + +// When satisfies the tcell.Event interface. +func (e *sidebarSearchResultsEvent) When() time.Time { return e.when } + +// switchSidebarTab moves between the Files and Find-in-files sidebar tabs. +// Single-file mode has no tree, so the sidebar (and thus the tab) can't +// exist; the call is a no-op there. +func (a *App) switchSidebarTab(tab string) { + if a.tree == nil { + return + } + a.sidebarTab = tab + if tab == "search" { + a.activateSidebarSearch() + } else { + a.sidebarSearchFocused = false + } +} + +// activateSidebarSearch focuses the input and makes sure the finder index +// is being built so an immediate keystroke doesn't sit on "indexing…". +func (a *App) activateSidebarSearch() { + a.sidebarSearchFocused = true + a.sidebarSearchCursor = len(a.sidebarSearchQuery) + a.sidebarSearchScroll = 0 + scr := a.screen + if a.finder != nil && a.finder.State() != finder.StateReady { + a.finder.Rebuild(func() { + _ = scr.PostEvent(&finderRebuiltEvent{when: time.Now()}) + }) + } +} + +// drawSidebarTabs paints the two-tab header at the top of the sidebar. It +// is drawn over the file tree's own " EXPLORER" title row, so the title is +// hidden without touching the filetree package. +func (a *App) drawSidebarTabs() { + sx, sy, sw, _ := a.sidebarRect() + half := sw / 2 + for i := 0; i < sw; i++ { + active := (i < half && a.sidebarTab != "search") || (i >= half && a.sidebarTab == "search") + bg := a.theme.SidebarBG + fg := a.theme.Muted + if active { + bg = a.theme.BG + fg = a.theme.Accent + } + st := tcell.StyleDefault.Background(bg).Foreground(fg) + if active { + st = st.Bold(true) + } + a.screen.SetContent(sx+i, sy, ' ', nil, st) + } + drawCenteredTab(a.screen, sx, sy, half, "Files", a.theme, a.sidebarTab != "search") + drawCenteredTab(a.screen, sx+half, sy, sw-half, "Find in files", a.theme, a.sidebarTab == "search") +} + +// drawCenteredTab draws one header-tab label, centered and truncated. +func drawCenteredTab(scr tcell.Screen, x, y, w int, label string, th theme.Theme, active bool) { + runes := []rune(label) + if len(runes) > w { + runes = runes[:w] + } + pad := (w - len(runes)) / 2 + bg := th.SidebarBG + fg := th.Muted + if active { + bg = th.BG + fg = th.Accent + } + st := tcell.StyleDefault.Background(bg).Foreground(fg) + if active { + st = st.Bold(true) + } + for i, r := range runes { + scr.SetContent(x+pad+i, y, r, nil, st) + } +} + +// drawSidebarSearch fills the sidebar body (rows 1..h) with the input row +// and the accordion results. Row 0 is the tab strip, drawn separately. +func (a *App) drawSidebarSearch() { + sx, sy, sw, sh := a.sidebarRect() + bg := a.theme.SidebarBG + bgStyle := tcell.StyleDefault.Background(bg).Foreground(a.theme.Text) + for cy := sy + 1; cy < sy+sh; cy++ { + for cx := sx; cx < sx+sw; cx++ { + a.screen.SetContent(cx, cy, ' ', nil, bgStyle) + } + } + a.drawSidebarSearchInput(sx, sy+1, sw) + a.drawSidebarSearchResults(sx, sy+2, sw, sh-2) +} + +// drawSidebarSearchInput renders the single-line query field with a caret +// and a status tail (count / searching / indexing / no results). +func (a *App) drawSidebarSearchInput(x, y, w int) { + inputStyle := tcell.StyleDefault.Background(a.theme.SidebarBG).Foreground(a.theme.Text) + a.screen.SetContent(x, y, '›', nil, inputStyle.Foreground(a.theme.Accent)) + + fieldStart := x + 1 + fieldEnd := x + w - 1 + fieldWidth := fieldEnd - fieldStart + a.adjustSidebarSearchScroll(fieldWidth) + for i := 0; i < fieldWidth; i++ { + idx := a.sidebarSearchScroll + i + if idx >= len(a.sidebarSearchQuery) { + break + } + a.screen.SetContent(fieldStart+i, y, a.sidebarSearchQuery[idx], nil, inputStyle) + } + + caret := fieldStart + (a.sidebarSearchCursor - a.sidebarSearchScroll) + if a.sidebarSearchFocused && caret >= fieldStart && caret <= fieldEnd { + a.screen.ShowCursor(caret, y) + } + + tail := a.sidebarSearchTail() + if tail != "" && runeLen(tail) < w-1 { + drawAt(a.screen, x+w-runeLen(tail)-1, y, tail, inputStyle.Foreground(a.theme.Muted)) + } +} + +// sidebarSearchTail returns the status string for the input row's right edge. +func (a *App) sidebarSearchTail() string { + state := finder.StateIdle + if a.finder != nil { + state = a.finder.State() + } + switch state { + case finder.StateBuilding, finder.StateIdle: + return "indexing…" + case finder.StateErrored: + return "index err" + } + if len(a.sidebarSearchQuery) == 0 { + return "" + } + if !a.sidebarSearchDone { + return "searching…" + } + if len(a.sidebarSearchResults) == 0 { + return "no results" + } + n := len(a.sidebarSearchResults) + if n >= searchLimit { + return itoa(n) + "+" + } + return itoa(n) +} + +// adjustSidebarSearchScroll keeps the caret visible inside the input field. +func (a *App) adjustSidebarSearchScroll(width int) { + if width <= 0 { + a.sidebarSearchScroll = 0 + return + } + if a.sidebarSearchCursor < a.sidebarSearchScroll { + a.sidebarSearchScroll = a.sidebarSearchCursor + } + if a.sidebarSearchCursor-a.sidebarSearchScroll >= width { + a.sidebarSearchScroll = a.sidebarSearchCursor - width + 1 + } + if a.sidebarSearchScroll < 0 { + a.sidebarSearchScroll = 0 + } +} + +// sidebarSearchGroups groups the flat (path,line)-ordered results into +// per-file accordion groups, folding in the per-file collapse state. +func (a *App) sidebarSearchGroups() []sidebarSearchGroup { + var groups []sidebarSearchGroup + var cur *sidebarSearchGroup + for _, m := range a.sidebarSearchResults { + if cur == nil || cur.Path != m.Path { + groups = append(groups, sidebarSearchGroup{Path: m.Path, Collapsed: a.sidebarSearchCollapsed[m.Path]}) + cur = &groups[len(groups)-1] + } + cur.Matches = append(cur.Matches, m) + } + return groups +} + +// drawSidebarSearchResults renders the accordion. It also rebuilds the +// transient row/match lists used by click hit-testing and keyboard nav. +func (a *App) drawSidebarSearchResults(x, y, w, h int) { + groups := a.sidebarSearchGroups() + a.sidebarSearchRows = a.sidebarSearchRows[:0] + a.sidebarSearchVisibleMatches = a.sidebarSearchVisibleMatches[:0] + for _, g := range groups { + a.sidebarSearchRows = append(a.sidebarSearchRows, sidebarSearchRow{ + kind: "header", + path: g.Path, + count: len(g.Matches), + collapsed: g.Collapsed, + }) + if g.Collapsed { + continue + } + for _, m := range g.Matches { + a.sidebarSearchRows = append(a.sidebarSearchRows, sidebarSearchRow{ + kind: "match", + path: g.Path, + match: m, + matchIndex: len(a.sidebarSearchVisibleMatches), + }) + a.sidebarSearchVisibleMatches = append(a.sidebarSearchVisibleMatches, m) + } + } + + // Clamp vertical scroll to the current row count. + if a.sidebarSearchViewTop < 0 { + a.sidebarSearchViewTop = 0 + } + total := len(a.sidebarSearchRows) + if total > h && a.sidebarSearchViewTop > total-h { + a.sidebarSearchViewTop = total - h + } + if total <= h { + a.sidebarSearchViewTop = 0 + } + + bg := a.theme.SidebarBG + for r := 0; r < h; r++ { + idx := a.sidebarSearchViewTop + r + ry := y + r + if idx >= total { + fillRect(a.screen, x, ry, w, 1, tcell.StyleDefault.Background(bg)) + continue + } + row := a.sidebarSearchRows[idx] + if row.kind == "header" { + a.drawSidebarSearchHeader(x, ry, w, row) + } else { + a.drawSidebarSearchMatchRow(x, ry, w, row.match, row.matchIndex == a.sidebarSearchSelected) + } + } +} + +// drawSidebarSearchHeader paints one file header with a chevron and count. +func (a *App) drawSidebarSearchHeader(x, y, w int, row sidebarSearchRow) { + st := tcell.StyleDefault.Background(a.theme.SidebarBG).Foreground(a.theme.FolderColor).Bold(true) + fillRect(a.screen, x, y, w, 1, st) + chev := "▾" + if row.collapsed { + chev = "▸" + } + label := chev + " " + filepath.Base(row.path) + " (" + itoa(row.count) + ")" + drawAt(a.screen, x+1, y, trimRunes(label, w-1), st) +} + +// drawSidebarSearchMatchRow paints one match: a line number then the source +// line with the matched run highlighted. Selected rows invert to editor BG. +func (a *App) drawSidebarSearchMatchRow(x, y, w int, m finder.ContentMatch, selected bool) { + bg := a.theme.SidebarBG + if selected { + bg = a.theme.BG + } + rowStyle := tcell.StyleDefault.Background(bg).Foreground(a.theme.Text) + fillRect(a.screen, x, y, w, 1, rowStyle) + + lineStr := itoa(m.Line + 1) + for len(lineStr) < 4 { + lineStr = " " + lineStr + } + col := 1 + for _, r := range lineStr { + if col >= w-1 { + return + } + a.screen.SetContent(x+col, y, r, nil, rowStyle.Foreground(a.theme.Muted)) + col++ + } + if col >= w-1 { + return + } + a.screen.SetContent(x+col, y, ' ', nil, rowStyle) + col++ + + preview := []rune(m.Preview) + trimmed := 0 + for trimmed < len(preview) && (preview[trimmed] == ' ' || preview[trimmed] == '\t') { + trimmed++ + } + preview = preview[trimmed:] + hitStart := m.Col - trimmed + hitEnd := hitStart + m.Width + hitStyle := rowStyle.Foreground(a.theme.FindCurrent).Bold(true) + + for i := 0; i < len(preview) && col < w-1; i++ { + st := rowStyle + if i >= hitStart && i < hitEnd { + st = hitStyle + } + a.screen.SetContent(x+col, y, preview[i], nil, st) + col++ + } +} + +// sidebarSearchClick routes a click inside the sidebar body while the +// search tab is active: input row focuses the field, header rows toggle +// collapse, match rows jump to the match. +func (a *App) sidebarSearchClick(x, y int) { + sx, sy, _, _ := a.sidebarRect() + if y == sy+1 { + a.sidebarSearchFocused = true + pos := x - (sx + 1) + if pos < 0 { + pos = 0 + } + if pos > len(a.sidebarSearchQuery) { + pos = len(a.sidebarSearchQuery) + } + a.sidebarSearchCursor = pos + return + } + if y >= sy+2 { + rel := y - (sy + 2) + idx := a.sidebarSearchViewTop + rel + if idx < 0 || idx >= len(a.sidebarSearchRows) { + return + } + row := a.sidebarSearchRows[idx] + if row.kind == "header" { + a.toggleSidebarSearchCollapse(row.path) + return + } + a.sidebarSearchSelected = row.matchIndex + a.openSidebarSearchMatch(row.match) + } +} + +// toggleSidebarSearchCollapse flips a file's accordion state and resets the +// selected match (its flat index is no longer meaningful after reflow). +func (a *App) toggleSidebarSearchCollapse(path string) { + if a.sidebarSearchCollapsed == nil { + a.sidebarSearchCollapsed = map[string]bool{} + } + a.sidebarSearchCollapsed[path] = !a.sidebarSearchCollapsed[path] + a.sidebarSearchSelected = 0 +} + +// sidebarRunSearch launches a background grep for the current query. +func (a *App) sidebarRunSearch() { + a.sidebarSearchGen++ + gen := a.sidebarSearchGen + query := string(a.sidebarSearchQuery) + a.sidebarSearchSelected = 0 + a.sidebarSearchViewTop = 0 + if query == "" || a.finder == nil { + a.sidebarSearchResults = nil + a.sidebarSearchDone = false + a.sidebarSearchCollapsed = nil + return + } + a.sidebarSearchDone = false + scr := a.screen + f := a.finder + go func() { + results := f.SearchContent(query, searchLimit) + _ = scr.PostEvent(&sidebarSearchResultsEvent{when: time.Now(), gen: gen, results: results}) + }() +} + +// applySidebarSearchResults installs the payload when it still matches the +// live query generation and the search tab is still active. +func (a *App) applySidebarSearchResults(e *sidebarSearchResultsEvent) { + if a.sidebarTab != "search" || e.gen != a.sidebarSearchGen { + return + } + a.sidebarSearchResults = e.results + a.sidebarSearchDone = true + a.sidebarSearchSelected = 0 + a.sidebarSearchViewTop = 0 + a.sidebarSearchCollapsed = nil +} + +// handleSidebarSearchKey routes keyboard input while the search input is +// focused. Esc returns to the Files tab; everything else mirrors the modal. +func (a *App) handleSidebarSearchKey(ev *tcell.EventKey) { + switch ev.Key() { + case tcell.KeyEsc: + a.switchSidebarTab("files") + case tcell.KeyEnter: + a.openSidebarSearchSelected() + case tcell.KeyUp: + if a.sidebarSearchSelected > 0 { + a.sidebarSearchSelected-- + a.adjustSidebarSearchView() + } + case tcell.KeyDown: + if a.sidebarSearchSelected < len(a.sidebarSearchVisibleMatches)-1 { + a.sidebarSearchSelected++ + a.adjustSidebarSearchView() + } + case tcell.KeyLeft: + if a.sidebarSearchCursor > 0 { + a.sidebarSearchCursor-- + } + case tcell.KeyRight: + if a.sidebarSearchCursor < len(a.sidebarSearchQuery) { + a.sidebarSearchCursor++ + } + case tcell.KeyHome: + a.sidebarSearchCursor = 0 + case tcell.KeyEnd: + a.sidebarSearchCursor = len(a.sidebarSearchQuery) + case tcell.KeyBackspace, tcell.KeyBackspace2: + if a.sidebarSearchCursor > 0 { + a.sidebarSearchQuery = append(a.sidebarSearchQuery[:a.sidebarSearchCursor-1], a.sidebarSearchQuery[a.sidebarSearchCursor:]...) + a.sidebarSearchCursor-- + a.sidebarRunSearch() + } + case tcell.KeyDelete: + if a.sidebarSearchCursor < len(a.sidebarSearchQuery) { + a.sidebarSearchQuery = append(a.sidebarSearchQuery[:a.sidebarSearchCursor], a.sidebarSearchQuery[a.sidebarSearchCursor+1:]...) + a.sidebarRunSearch() + } + case tcell.KeyRune: + r := ev.Rune() + if r < 0x20 { + return + } + next := make([]rune, 0, len(a.sidebarSearchQuery)+1) + next = append(next, a.sidebarSearchQuery[:a.sidebarSearchCursor]...) + next = append(next, r) + next = append(next, a.sidebarSearchQuery[a.sidebarSearchCursor:]...) + a.sidebarSearchQuery = next + a.sidebarSearchCursor++ + a.sidebarRunSearch() + } +} + +// openSidebarSearchSelected jumps to the selected visible match. +func (a *App) openSidebarSearchSelected() { + if a.sidebarSearchSelected < 0 || a.sidebarSearchSelected >= len(a.sidebarSearchVisibleMatches) { + return + } + a.openSidebarSearchMatch(a.sidebarSearchVisibleMatches[a.sidebarSearchSelected]) +} + +// openSidebarSearchMatch opens the file, drops the cursor on the match, and +// returns focus to the editor while leaving the search tab visible. +func (a *App) openSidebarSearchMatch(m finder.ContentMatch) { + abs := filepath.Join(a.rootDir, filepath.FromSlash(m.Path)) + a.openFile(abs) + tab := a.activeTabPtr() + if tab != nil { + tab.MoveCursorTo(editor.Position{Line: m.Line, Col: m.Col}, false) + _, _, ew, eh := a.editorRect() + tab.EnsureVisible(ew, eh) + } + a.sidebarSearchFocused = false +} + +// adjustSidebarSearchView slides the vertical scroll so the selected match +// stays visible in the results area. +func (a *App) adjustSidebarSearchView() { + _, _, _, sh := a.sidebarRect() + viewH := sh - 2 + if viewH <= 0 { + a.sidebarSearchViewTop = 0 + return + } + row := a.sidebarSearchMatchRowIndex(a.sidebarSearchSelected) + if row < 0 { + return + } + if row < a.sidebarSearchViewTop { + a.sidebarSearchViewTop = row + } + if row >= a.sidebarSearchViewTop+viewH { + a.sidebarSearchViewTop = row - viewH + 1 + } + if a.sidebarSearchViewTop < 0 { + a.sidebarSearchViewTop = 0 + } +} + +// sidebarSearchMatchRowIndex returns the rendered row index of a visible +// match, or -1 when the match isn't present. +func (a *App) sidebarSearchMatchRowIndex(matchIndex int) int { + for i, r := range a.sidebarSearchRows { + if r.kind == "match" && r.matchIndex == matchIndex { + return i + } + } + return -1 +} diff --git a/internal/app/sidebarsearch_test.go b/internal/app/sidebarsearch_test.go new file mode 100644 index 0000000..0fdf64d --- /dev/null +++ b/internal/app/sidebarsearch_test.go @@ -0,0 +1,111 @@ +// ============================================================================= +// File: internal/app/sidebarsearch_test.go +// Author: Spicer Matthews +// Created: 2026-08-12 +// Copyright: 2026 Cloudmanic, LLC. All rights reserved. +// ============================================================================= + +package app + +import ( + "os" + "path/filepath" + "testing" + + "github.com/gdamore/tcell/v2" + + "github.com/cloudmanic/spice-edit/internal/finder" +) + +func TestSwitchSidebarTab(t *testing.T) { + a := newTestApp(t, t.TempDir()) + a.sidebarTab = "files" + + a.switchSidebarTab("search") + if a.sidebarTab != "search" || !a.sidebarSearchFocused { + t.Fatalf("search: tab=%q focused=%v", a.sidebarTab, a.sidebarSearchFocused) + } + + a.switchSidebarTab("files") + if a.sidebarTab != "files" || a.sidebarSearchFocused { + t.Fatalf("files: tab=%q focused=%v", a.sidebarTab, a.sidebarSearchFocused) + } +} + +func TestSidebarSearchGroups_OrderAndCollapse(t *testing.T) { + a := newTestApp(t, t.TempDir()) + a.sidebarSearchResults = []finder.ContentMatch{ + {Path: "a.go", Line: 0, Col: 0, Width: 3, Preview: "aaa"}, + {Path: "a.go", Line: 5, Col: 1, Width: 3, Preview: "bbb"}, + {Path: "b.go", Line: 2, Col: 0, Width: 3, Preview: "ccc"}, + } + a.sidebarSearchCollapsed = map[string]bool{"a.go": true} + + groups := a.sidebarSearchGroups() + if len(groups) != 2 { + t.Fatalf("groups = %d, want 2", len(groups)) + } + if groups[0].Path != "a.go" || len(groups[0].Matches) != 2 || !groups[0].Collapsed { + t.Errorf("group[0] = %+v, want a.go/2/collapsed", groups[0]) + } + if groups[1].Path != "b.go" || len(groups[1].Matches) != 1 || groups[1].Collapsed { + t.Errorf("group[1] = %+v, want b.go/1/expanded", groups[1]) + } +} + +func TestSidebarClick_TabStrip(t *testing.T) { + a := newTestApp(t, t.TempDir()) + a.sidebarTab = "files" + _, _, sw, _ := a.sidebarRect() + + a.sidebarClick(1, 0) + if a.sidebarTab != "files" { + t.Fatalf("left half: tab=%q, want files", a.sidebarTab) + } + + a.sidebarClick(sw-1, 0) + if a.sidebarTab != "search" { + t.Fatalf("right half: tab=%q, want search", a.sidebarTab) + } +} + +func TestSidebarSearchClick_OpenMatch(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello world\nsecond\n"), 0644); err != nil { + t.Fatal(err) + } + + a := newTestApp(t, dir) + a.sidebarTab = "search" + a.sidebarSearchResults = []finder.ContentMatch{ + {Path: "a.txt", Line: 0, Col: 0, Width: 5, Preview: "hello world"}, + } + // Populate the transient row list used for click hit-testing. + a.drawSidebarSearch() + + // Header at y=2, match row at y=3 (results area starts at y=2). + a.sidebarSearchClick(1, 3) + + tab := a.activeTabPtr() + if tab == nil || tab.Path != filepath.Join(dir, "a.txt") { + t.Fatalf("active tab = %+v, want a.txt", tab) + } + if tab.Cursor.Line != 0 || tab.Cursor.Col != 0 { + t.Errorf("cursor = %d:%d, want 0:0", tab.Cursor.Line, tab.Cursor.Col) + } +} + +func TestSidebarSearchKey_EscReturnsToFiles(t *testing.T) { + a := newTestApp(t, t.TempDir()) + a.sidebarTab = "search" + a.sidebarSearchFocused = true + + a.handleSidebarSearchKey(tcell.NewEventKey(tcell.KeyEsc, 0, 0)) + + if a.sidebarTab != "files" { + t.Fatalf("tab = %q, want files", a.sidebarTab) + } + if a.sidebarSearchFocused { + t.Fatal("focused still true after Esc") + } +} diff --git a/internal/app/terminal_test.go b/internal/app/terminal_test.go index a39b8cf..12cc310 100644 --- a/internal/app/terminal_test.go +++ b/internal/app/terminal_test.go @@ -407,3 +407,25 @@ func TestClose_ReapsTerminalsOnQuit(t *testing.T) { t.Errorf("closeAllTerminals took %v; shells should be hung up concurrently", elapsed) } } + +// TestTabBarClick_TerminalButton verifies the far-right tab-bar button opens +// and focuses a terminal tab. +func TestTabBarClick_TerminalButton(t *testing.T) { + skipWithoutPTY(t) + a := newTestApp(t, t.TempDir()) + t.Cleanup(a.closeAllTerminals) + + a.drawTabBar() // lays out the far-right button and sets newTabBtnX + if a.newTabBtnX < 0 { + t.Fatal("terminal button not laid out") + } + + a.tabBarClick(a.newTabBtnX+1, 0) + + if len(a.tabs) != 1 { + t.Fatalf("tab count = %d, want 1", len(a.tabs)) + } + if a.activeTab != 0 || a.activeTabPtr() == nil || !a.activeTabPtr().IsTerminal() { + t.Fatal("terminal tab not opened/focused") + } +} From bdc9ab19d23d0192bd4837cf3c53c432267b84f7 Mon Sep 17 00:00:00 2001 From: Jaime Villanueva Alcon <114494844+jaivial@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:19:15 +0200 Subject: [PATCH 5/8] feat: command bar with bash-style cd completion and re-root (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Esc-: (or the ≡ menu) opens a bottom command line. First built-in command: cd, which re-roots the editor — file tree, project finder index, git status and search surfaces all point at the new directory (nvim :cd behavior). Tab completes directories with common-prefix extension then cycles; Up/Down and click adopt a suggestion; invalid paths keep the bar open with a hint. Open tabs survive the re-root. Tests: commandbar_test.go (completion, cd validation, re-root wiring, leader routing, drawing); menu layout expectations updated for the new row. Full suite passes with -race. --- internal/app/app.go | 27 ++ internal/app/app_test.go | 16 +- internal/app/commandbar.go | 612 ++++++++++++++++++++++++++++++++ internal/app/commandbar_test.go | 350 ++++++++++++++++++ internal/app/leader.go | 1 + internal/app/modals.go | 10 +- 6 files changed, 1007 insertions(+), 9 deletions(-) create mode 100644 internal/app/commandbar.go create mode 100644 internal/app/commandbar_test.go diff --git a/internal/app/app.go b/internal/app/app.go index ab85fb0..ebf933c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -240,6 +240,10 @@ func builtinMenuGroups() [][]menuItemDef { {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}, }, + // Commands + { + {label: "Command bar", shortcut: "Esc :", action: (*App).menuCommandBar, enabled: alwaysTrue}, + }, // Quit { {label: "Quit editor", shortcut: "Esc q", action: (*App).menuQuit, enabled: alwaysTrue}, @@ -455,6 +459,18 @@ type App struct { findCursor int findScroll int + // Command bar — the ":" line for running editor commands (Esc-: or the + // ≡ menu). Currently hosts the cd command with bash-style directory + // completion; see commandbar.go. Mutually exclusive with every modal. + commandOpen bool + commandValue []rune + commandCursor int + commandScroll int + commandSuggestion []string + commandSelected int + commandCycling bool // candidate list frozen while Tab/arrows adopt + commandHint string + // Auto-scroll while drag-selecting past the editor's top/bottom edge. // lastDragX/Y is the most recent mouse position so the auto-scroll // tick can extend the selection at the user's column even though the @@ -1146,6 +1162,10 @@ func (a *App) handleKey(ev *tcell.EventKey) { a.handleSearchKey(ev) return } + if a.commandOpen { + a.handleCommandKey(ev) + return + } if a.sidebarTab == "search" && a.sidebarSearchFocused { a.handleSidebarSearchKey(ev) return @@ -1330,6 +1350,10 @@ func (a *App) handleMouse(ev *tcell.EventMouse) { a.handleSearchMouse(x, y, btn) return } + if a.commandOpen { + a.handleCommandMouse(x, y, btn) + return + } if a.menuOpen { a.updateMenuHover(x, y) @@ -2548,6 +2572,9 @@ func (a *App) draw() { if a.findOpen { a.drawFindBar() } + if a.commandOpen { + a.drawCommandBar() + } a.drawStatusBar() // Modal layering, bottom-up. Only one of these is open at a time diff --git a/internal/app/app_test.go b/internal/app/app_test.go index d4d04d1..62669e4 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1655,7 +1655,7 @@ func TestDrawStatusBar_OmitsBranchWhenEmpty(t *testing.T) { } // TestMenuLayout_NoCustomActions pins down the baseline geometry: with -// zero custom actions the modal still has seven built-in groups and the +// zero custom actions the modal still has eight built-in groups and the // height matches the expected layout total. Catches accidental // off-by-one regressions when someone tweaks the layout helper. func TestMenuLayout_NoCustomActions(t *testing.T) { @@ -1663,13 +1663,13 @@ func TestMenuLayout_NoCustomActions(t *testing.T) { a.customActions = nil items, dividers, h := a.menuLayout() - if h != 35 { - t.Errorf("modalHeight = %d, want 35", h) + if h != 37 { + t.Errorf("modalHeight = %d, want 37", h) } - if got := len(items); got != 24 { - t.Errorf("item count = %d, want 24 built-ins", got) + if got := len(items); got != 25 { + t.Errorf("item count = %d, want 25 built-ins", got) } - wantDiv := []int{2, 6, 10, 14, 16, 24, 29, 32} + wantDiv := []int{2, 6, 10, 14, 16, 24, 29, 32, 34} 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 != 38 { // 35 + 2 items + 1 divider - t.Errorf("modalHeight = %d, want 38", h) + if h != 40 { // 37 + 2 items + 1 divider + t.Errorf("modalHeight = %d, want 40", 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/commandbar.go b/internal/app/commandbar.go new file mode 100644 index 0000000..6da65a8 --- /dev/null +++ b/internal/app/commandbar.go @@ -0,0 +1,612 @@ +// ============================================================================= +// File: internal/app/commandbar.go +// Author: Spicer Matthews +// Created: 2026-08-13 +// Copyright: 2026 Cloudmanic, LLC. All rights reserved. +// ============================================================================= + +// commandbar.go owns the ":" command line — a 1-row input strip above the +// status bar, opened with the Esc-: leader or the ≡ menu. Commands run +// against a small built-in registry; the first command is cd, which +// re-roots the editor (file tree, project index, git status) at the given +// directory the way :cd does in nvim. The input gets bash-style Tab +// completion for directory paths, with the suggestion list popping up +// above the bar. + +package app + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + + "github.com/cloudmanic/spice-edit/internal/filetree" + "github.com/cloudmanic/spice-edit/internal/finder" +) + +// commandSuggestRows caps how many completion candidates render above the +// input row. Five is plenty for a quick scan and keeps the popup from +// covering most of a short terminal. +const commandSuggestRows = 5 + +// openCommandBar shows the command line with an empty input. Called from +// the Esc-: leader and the ≡ menu; both paths funnel through closeAllModals +// first so the bar can never stack on another modal. +func (a *App) openCommandBar() { + a.closeAllModals() + a.commandOpen = true + a.commandValue = nil + a.commandCursor = 0 + a.commandScroll = 0 + a.commandSelected = -1 // no suggestion adopted yet; Down/Up pick the first + a.commandCycling = false + a.commandRefresh() +} + +// menuCommandBar is the ≡ menu entry point for the command bar. +func (a *App) menuCommandBar() { + a.closeMenu() + a.openCommandBar() +} + +// closeCommandBar dismisses the bar and clears its transient state so a +// future open starts from a clean slate (same convention as the find bar). +func (a *App) closeCommandBar() { + a.commandOpen = false + a.commandValue = nil + a.commandCursor = 0 + a.commandScroll = 0 + a.commandSuggestion = nil + a.commandSelected = -1 + a.commandCycling = false + a.commandHint = "" +} + +// handleCommandKey processes keyboard input while the command bar is open: +// printable runes edit the line, Tab / arrows drive completion, Enter runs +// the command, Esc dismisses. +func (a *App) handleCommandKey(ev *tcell.EventKey) { + switch ev.Key() { + case tcell.KeyEsc: + a.closeCommandBar() + case tcell.KeyEnter: + a.commandSubmit() + case tcell.KeyTab: + a.commandComplete() + case tcell.KeyUp: + a.commandCycle(-1) + case tcell.KeyDown: + a.commandCycle(1) + case tcell.KeyLeft: + if a.commandCursor > 0 { + a.commandCursor-- + } + case tcell.KeyRight: + if a.commandCursor < len(a.commandValue) { + a.commandCursor++ + } + case tcell.KeyHome: + a.commandCursor = 0 + case tcell.KeyEnd: + a.commandCursor = len(a.commandValue) + case tcell.KeyBackspace, tcell.KeyBackspace2: + if a.commandCursor > 0 { + a.commandValue = append(a.commandValue[:a.commandCursor-1], a.commandValue[a.commandCursor:]...) + a.commandCursor-- + a.commandCycling = false + a.commandRefresh() + } + case tcell.KeyDelete: + if a.commandCursor < len(a.commandValue) { + a.commandValue = append(a.commandValue[:a.commandCursor], a.commandValue[a.commandCursor+1:]...) + a.commandCycling = false + a.commandRefresh() + } + case tcell.KeyRune: + r := ev.Rune() + if r < 0x20 { + return + } + next := make([]rune, 0, len(a.commandValue)+1) + next = append(next, a.commandValue[:a.commandCursor]...) + next = append(next, r) + next = append(next, a.commandValue[a.commandCursor:]...) + a.commandValue = next + a.commandCursor++ + a.commandCycling = false + a.commandRefresh() + } +} + +// handleCommandMouse routes clicks while the command bar is open: a click on +// a suggestion row adopts it as the last token, a click on the input row +// moves the caret, and a click anywhere else dismisses the bar. +func (a *App) handleCommandMouse(x, y int, btn tcell.ButtonMask) { + if btn&tcell.Button1 == 0 { + return + } + bx, by, bw, _ := a.commandBarRect() + if y < by && y >= by-commandSuggestRows && x >= bx && x < bx+bw { + // Suggestion row: index 0 is the row directly above the input. + idx := by - 1 - y + if idx >= 0 && idx < len(a.commandSuggestion) { + a.commandCycling = true + a.replaceLastToken(a.commandSuggestion[idx]) + a.commandRefresh() + } + return + } + if y == by { + col := x - (bx + runeLen(" : ")) + a.commandScroll + if col < 0 { + col = 0 + } + if col > len(a.commandValue) { + col = len(a.commandValue) + } + a.commandCursor = col + return + } + a.closeCommandBar() +} + +// commandBarRect returns the on-screen rectangle of the command bar's input +// row: the row directly above the status bar. The suggestion popup grows +// upward from there and drawCommandBar clips it against the window top. +func (a *App) commandBarRect() (x, y, w, h int) { + sw := a.sidebarW() + return sw, a.height - 2, a.width - sw, 1 +} + +// drawCommandBar renders the command bar: a " : " input row above the +// status bar with up to commandSuggestRows completion candidates stacked +// above it. The selected candidate is highlighted; the hint (match count, +// error text) sits right-aligned on the input row and is dropped first on +// narrow windows. +func (a *App) drawCommandBar() { + if !a.commandOpen { + return + } + bx, by, bw, _ := a.commandBarRect() + + bg := a.theme.LineHL + barStyle := tcell.StyleDefault.Background(bg).Foreground(a.theme.Text) + labelStyle := tcell.StyleDefault.Background(bg).Foreground(a.theme.Accent).Bold(true) + mutedStyle := tcell.StyleDefault.Background(bg).Foreground(a.theme.Muted) + selStyle := tcell.StyleDefault.Background(a.theme.Selection).Foreground(a.theme.Text).Bold(true) + + // Suggestion rows, nearest to the input first. + for i, s := range a.commandSuggestion { + if i >= commandSuggestRows { + break + } + cy := by - 1 - i + if cy < 0 { + break + } + for cx := bx; cx < bx+bw; cx++ { + a.screen.SetContent(cx, cy, ' ', nil, barStyle) + } + st := mutedStyle + marker := " " + if i == a.commandSelected%len(a.commandSuggestion) { + st = selStyle + marker = "▸" + } + drawAt(a.screen, bx, cy, marker, st) + label := s + if runeLen(label) > bw-2 { + label = string([]rune(label)[:bw-2]) + } + drawAt(a.screen, bx+1, cy, label, st) + } + + // Input row. + for cx := bx; cx < bx+bw; cx++ { + a.screen.SetContent(cx, by, ' ', nil, barStyle) + } + label := " : " + drawAt(a.screen, bx, by, label, labelStyle) + inputStart := bx + runeLen(label) + + hint := "" + if a.commandHint != "" { + hint = " " + a.commandHint + " " + } + rightStart := bx + bw + if bw > runeLen(label)+runeLen(hint)+10 { + rightStart -= runeLen(hint) + drawAt(a.screen, rightStart, by, hint, mutedStyle) + } + + inputEnd := rightStart - 1 + if inputEnd <= inputStart { + inputEnd = bx + bw - 1 + } + width := inputEnd - inputStart + if width < 1 { + width = 1 + } + a.adjustCommandScroll(width) + for i := 0; i < width; i++ { + idx := a.commandScroll + i + if idx >= len(a.commandValue) { + break + } + a.screen.SetContent(inputStart+i, by, a.commandValue[idx], nil, barStyle) + } + caret := inputStart + (a.commandCursor - a.commandScroll) + if caret >= inputStart && caret <= inputEnd { + a.screen.ShowCursor(caret, by) + } +} + +// adjustCommandScroll keeps the caret within the visible window of the +// command input by sliding commandScroll left or right as needed. +func (a *App) adjustCommandScroll(width int) { + if width <= 0 { + a.commandScroll = 0 + return + } + if a.commandCursor < a.commandScroll { + a.commandScroll = a.commandCursor + } + if a.commandCursor >= a.commandScroll+width { + a.commandScroll = a.commandCursor - width + 1 + } + if a.commandScroll < 0 { + a.commandScroll = 0 + } +} + +// commandRefresh recomputes the completion candidates and hint for the +// current line. Called on every keystroke; the directory listing behind it +// is cheap (one ReadDir of the deepest prefix directory). While the user +// is cycling with Tab / arrows the candidate list stays frozen — bash +// keeps the list stable between adoptions, only a manual edit narrows it. +func (a *App) commandRefresh() { + line := string(a.commandValue) + fields := strings.Fields(line) + if len(fields) == 0 || fields[0] != "cd" { + a.commandSuggestion = nil + a.commandHint = "" + return + } + prefix := "" + if len(fields) > 1 { + prefix = fields[len(fields)-1] + } else if !strings.HasSuffix(line, " ") { + // Caret is still on the command word itself — nothing to + // complete until the user adds the argument separator. + a.commandSuggestion = nil + a.commandHint = "" + return + } + if !a.commandCycling { + a.commandSuggestion = a.completeDirPath(prefix) + } + switch { + case len(a.commandSuggestion) == 0: + a.commandHint = "no matching directories" + case len(a.commandSuggestion) == 1: + a.commandHint = "1 match · Tab to complete" + default: + a.commandHint = fmt.Sprintf("%d matches · Tab cycles", len(a.commandSuggestion)) + } + if a.commandSelected >= len(a.commandSuggestion) { + a.commandSelected = 0 + } +} + +// commandComplete implements bash-style Tab completion on the last token of +// the line: +// +// - no candidates: nothing happens +// - one candidate: the token is replaced with it plus a trailing space +// - several candidates: the token extends to their common prefix; a +// second Tab cycles through the list (wrapping) +func (a *App) commandComplete() { + cands := a.commandSuggestion + if len(cands) == 0 { + return + } + line := string(a.commandValue) + fields := strings.Fields(line) + token := "" + if len(fields) > 1 { + token = fields[len(fields)-1] + } else if len(fields) == 1 && !strings.HasSuffix(line, " ") { + return // caret is on the command word itself + } + if len(cands) == 1 { + a.commandCycling = true + a.replaceLastToken(cands[0] + " ") + a.commandRefresh() + return + } + common := commonPrefix(cands) + if len(common) > len(token) { + a.commandCycling = true + a.replaceLastToken(common) + a.commandSelected = 0 // the common prefix is the first candidate's + a.commandRefresh() + return + } + // Second Tab: advance to the next candidate and adopt it, wrapping. + a.commandCycling = true + if a.commandSelected < 0 { + a.commandSelected = 0 + } else { + a.commandSelected = (a.commandSelected + 1) % len(cands) + } + a.replaceLastToken(cands[a.commandSelected]) + a.commandRefresh() +} + +// selIndex returns a safe suggestion index for callers that need one even +// when nothing has been adopted yet (-1): falls back to the first +// candidate. +func (a *App) selIndex() int { + if a.commandSelected < 0 { + return 0 + } + return a.commandSelected % len(a.commandSuggestion) +} + +// commandCycle adopts the next / previous suggestion as the last token, +// wrapping around the list. Up and Down in the bar, bash's menu-complete +// gesture. A -1 selection (nothing adopted yet) starts at the list ends +// rather than skipping the first candidate. +func (a *App) commandCycle(delta int) { + cands := a.commandSuggestion + if len(cands) == 0 { + return + } + if a.commandSelected < 0 { + if delta > 0 { + a.commandSelected = 0 + } else { + a.commandSelected = len(cands) - 1 + } + } else { + a.commandSelected = (a.commandSelected + delta + len(cands)) % len(cands) + } + a.commandCycling = true + a.replaceLastToken(cands[a.commandSelected]) + a.commandRefresh() +} + +// replaceLastToken swaps the final whitespace-delimited token of the +// command line for repl and parks the caret at the end of the replacement. +// When the line ends in whitespace the token region is empty and the +// replacement appends after the separator — "cd " + Tab yields "cd alpha", +// not "cdalpha". +func (a *App) replaceLastToken(repl string) { + runes := a.commandValue + start := len(runes) + for start > 0 && !isSpaceRune(runes[start-1]) { + start-- + } + replR := []rune(repl) + next := make([]rune, 0, start+len(replR)) + next = append(next, runes[:start]...) + next = append(next, replR...) + a.commandValue = next + a.commandCursor = len(next) + a.commandScroll = 0 +} + +// isSpaceRune reports whether r is a command-line field separator. +func isSpaceRune(r rune) bool { + return r == ' ' || r == '\t' +} + +// commonPrefix returns the longest string every candidate starts with, or +// "" when they share nothing. Rune-safe — it slices on rune boundaries so +// multi-byte names never produce half a character. +func commonPrefix(strs []string) string { + if len(strs) == 0 { + return "" + } + p := []rune(strs[0]) + for _, s := range strs[1:] { + rs := []rune(s) + for len(p) > 0 && (len(rs) < len(p) || string(rs[:len(p)]) != string(p)) { + p = p[:len(p)-1] + } + if len(p) == 0 { + return "" + } + } + return string(p) +} + +// completeDirPath returns full replacement strings for a cd completion +// prefix: every directory under the resolved search path whose name starts +// with the prefix's basename. Candidates replace the token wholesale, so a +// nested prefix like "a/ba" yields "a/bar" rather than just "bar". +func (a *App) completeDirPath(prefix string) []string { + search, base, outPrefix := a.commandSearchBase(prefix) + var out []string + for _, name := range listDirCandidates(search, base) { + out = append(out, outPrefix+name) + } + return out +} + +// commandSearchBase resolves a cd completion prefix into the directory to +// list, the basename filter, and the token text to keep when splicing +// candidates back in: +// +// - "" → list the project root +// - "foo" → list the root, filter "foo", splice "foo…" +// - "a/ba" → list root/a, filter "ba", splice "a/ba…" +// - "a/" → list root/a, no filter, splice "a/…" +// - "/abs" → absolute: list the filesystem, no splice prefix +// - "~", "~/x" → home-directory forms keep their "~" spelling +func (a *App) commandSearchBase(prefix string) (search, base, outPrefix string) { + if prefix == "" { + return a.rootDir, "", "" + } + home, homeErr := os.UserHomeDir() + if prefix == "~" { + if homeErr != nil { + return "", "", "" + } + return home, "", "~/" + } + if strings.HasPrefix(prefix, "~/") { + if homeErr != nil { + return "", "", "" + } + rest := prefix[2:] + if strings.HasSuffix(rest, "/") { + return filepath.Join(home, rest), "", prefix + } + d := filepath.Dir(rest) + out := "~/" + if d != "." { + out += d + "/" + } + return filepath.Join(home, d), filepath.Base(rest), out + } + if filepath.IsAbs(prefix) { + if strings.HasSuffix(prefix, "/") { + return prefix, "", prefix + } + return filepath.Dir(prefix), filepath.Base(prefix), "" + } + if strings.HasSuffix(prefix, "/") { + return filepath.Join(a.rootDir, prefix), "", prefix + } + if d := filepath.Dir(prefix); d != "." { + return filepath.Join(a.rootDir, d), filepath.Base(prefix), d + "/" + } + return a.rootDir, prefix, "" +} + +// listDirCandidates returns the sorted names of directories under dir whose +// names start with base (or every directory when base is ""). The project's +// own .git is skipped — cd'ing into it is a mistake, not a destination. +func listDirCandidates(dir, base string) []string { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var out []string + for _, e := range entries { + if !e.IsDir() { + continue + } + name := e.Name() + if base != "" && !strings.HasPrefix(name, base) { + continue + } + if name == ".git" { + continue + } + out = append(out, name) + } + sort.Strings(out) + return out +} + +// commandSubmit runs the typed command. cd is the first built-in; unknown +// commands flash a hint and keep the bar open so the typo can be fixed +// without retyping the whole line. +func (a *App) commandSubmit() { + line := trimSpace(string(a.commandValue)) + if line == "" { + a.closeCommandBar() + return + } + fields := strings.Fields(line) + cmd, args := fields[0], "" + if len(fields) > 1 { + args = strings.Join(fields[1:], " ") + } + switch cmd { + case "cd": + a.cmdCd(args) + default: + a.commandHint = "unknown command: " + cmd + a.commandSelected = 0 + } +} + +// cmdCd implements the cd command: expand and validate the target, then +// re-root the editor so the file tree, project index and git status all +// point at the new directory. Errors keep the bar open with the reason as +// the hint — nvim's E344 behavior. +func (a *App) cmdCd(args string) { + if a.tree == nil { + a.commandHint = "cd: not available in single-file mode" + return + } + target := strings.TrimSpace(args) + if target == "" { + a.commandHint = "cd: missing directory" + return + } + if target == "~" || strings.HasPrefix(target, "~/") { + home, err := os.UserHomeDir() + if err != nil { + a.commandHint = "cd: cannot expand ~" + return + } + target = filepath.Join(home, strings.TrimPrefix(target, "~/")) + } + abs := target + if !filepath.IsAbs(abs) { + abs = filepath.Join(a.rootDir, abs) + } + info, err := os.Stat(abs) + if err != nil || !info.IsDir() { + a.commandHint = "cd: no such directory: " + target + a.commandSelected = 0 + return + } + a.closeCommandBar() + a.reroot(abs) + a.flash("cd → " + abs) +} + +// reroot points the editor's project state at path: the sidebar file tree, +// the finder's file index, git status and every search surface. Open tabs +// are left alone — like nvim, changing directory doesn't close buffers. +func (a *App) reroot(path string) { + abs, err := filepath.Abs(path) + if err != nil { + a.flash("cd: bad path " + path) + return + } + tree, err := filetree.New(abs) + if err != nil { + a.flash("cd: cannot open " + abs) + return + } + icons := a.tree.IconsEnabled + a.tree = tree + a.tree.IconsEnabled = icons + a.rootDir = abs + a.setActiveFolder(abs) + a.refreshGitStatus() + // Re-root the project index and drop every root-relative result list + // so stale paths from the old tree can't be jumped to. + a.finder = finder.New(abs) + scr := a.screen + a.finder.Rebuild(func() { + _ = scr.PostEvent(&finderRebuiltEvent{when: time.Now()}) + }) + a.finderResults = nil + a.searchResults = nil + a.sidebarSearchResults = nil + a.sidebarSearchVisibleMatches = nil + a.sidebarSearchQuery = nil + a.sidebarSearchCollapsed = map[string]bool{} +} diff --git a/internal/app/commandbar_test.go b/internal/app/commandbar_test.go new file mode 100644 index 0000000..2237a56 --- /dev/null +++ b/internal/app/commandbar_test.go @@ -0,0 +1,350 @@ +// ============================================================================= +// File: internal/app/commandbar_test.go +// Author: Spicer Matthews +// Created: 2026-08-13 +// Copyright: 2026 Cloudmanic, LLC. All rights reserved. +// ============================================================================= + +// Tests for the command bar: open/close routing, bash-style directory +// completion, the cd command's validation, and the re-root that rewires +// the file tree, finder index and git status at the new directory. + +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cloudmanic/spice-edit/internal/finder" + "github.com/gdamore/tcell/v2" +) + +// typeIntoCommand drives runes through the command bar's key handler. +func typeIntoCommand(a *App, s string) { + for _, r := range s { + a.handleCommandKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) + } +} + +// seedDirs creates a small directory tree for completion tests: +// root/{alpha, alphabeta, beta, sub/{nested/{deep}, other}}, plus a file +// that must never surface as a cd candidate. +func seedDirs(t *testing.T, root string) { + t.Helper() + for _, d := range []string{"alpha", "alphabeta", "beta", "sub/nested/deep", "sub/other", "sub/.git"} { + if err := os.MkdirAll(filepath.Join(root, d), 0755); err != nil { + t.Fatalf("mkdir %s: %v", d, err) + } + } + // A file that must never appear in cd completion. + if err := os.WriteFile(filepath.Join(root, "alpha.txt"), []byte("x"), 0644); err != nil { + t.Fatalf("write: %v", err) + } +} + +// TestOpenCommandBar opens the bar via the leader path and checks the +// initial state is a clean, empty input. +func TestOpenCommandBar(t *testing.T) { + a := newTestApp(t, t.TempDir()) + a.openCommandBar() + if !a.commandOpen { + t.Fatal("commandOpen should be true after openCommandBar") + } + if len(a.commandValue) != 0 { + t.Errorf("commandValue = %q, want empty", string(a.commandValue)) + } + a.closeCommandBar() + if a.commandOpen { + t.Fatal("commandOpen should be false after closeCommandBar") + } +} + +// TestCommandLeaderRouting verifies the Esc-: leader opens the bar and +// that a bare ':' still types into the editor when no leader is armed. +func TestCommandLeaderRouting(t *testing.T) { + a := newTestApp(t, t.TempDir()) + + // Bare ':' with no pending Esc inserts normally. + if a.commandOpen { + t.Fatal("command bar should start closed") + } + a.lastEscape = time.Now() + a.handleKey(tcell.NewEventKey(tcell.KeyRune, ':', tcell.ModNone)) + if !a.commandOpen { + t.Fatal("Esc-: should open the command bar") + } + + // Esc alone (stale leader) must not open the bar. + a.closeCommandBar() + a.lastEscape = time.Now().Add(-2 * doubleEscMs) + a.handleKey(tcell.NewEventKey(tcell.KeyRune, ':', tcell.ModNone)) + if a.commandOpen { + t.Fatal("stale Esc should not open the command bar") + } +} + +// TestCommandCompletionSingle extends one candidate to a full directory +// plus a trailing space, bash-style. +func TestCommandCompletionSingle(t *testing.T) { + root := t.TempDir() + seedDirs(t, root) + a := newTestApp(t, t.TempDir()) + a.rootDir = root + a.openCommandBar() + + typeIntoCommand(a, "cd bet") + a.handleCommandKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) + if got := string(a.commandValue); got != "cd beta " { + t.Errorf("value = %q, want %q", got, "cd beta ") + } +} + +// TestCommandCompletionCommonPrefix extends the token only as far as the +// candidates agree, then a second Tab cycles through the list. +func TestCommandCompletionCommonPrefix(t *testing.T) { + root := t.TempDir() + seedDirs(t, root) + a := newTestApp(t, t.TempDir()) + a.rootDir = root + a.openCommandBar() + + typeIntoCommand(a, "cd alp") + a.handleCommandKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) + if got := string(a.commandValue); got != "cd alpha" { + t.Errorf("after first Tab = %q, want common prefix %q", got, "cd alpha") + } + a.handleCommandKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) + if got := string(a.commandValue); got != "cd alphabeta" { + t.Errorf("after cycle Tab = %q, want %q", got, "cd alphabeta") + } + a.handleCommandKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) + if got := string(a.commandValue); got != "cd alpha" { + t.Errorf("after wrap Tab = %q, want %q", got, "cd alpha") + } +} + +// TestCommandCompletionNested completes inside a subdirectory and keeps +// the directory prefix in the replacement. +func TestCommandCompletionNested(t *testing.T) { + root := t.TempDir() + seedDirs(t, root) + a := newTestApp(t, t.TempDir()) + a.rootDir = root + a.openCommandBar() + + typeIntoCommand(a, "cd sub/nes") + a.handleCommandKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) + if got := string(a.commandValue); got != "cd sub/nested " { + t.Errorf("value = %q, want %q", got, "cd sub/nested ") + } + + // Trailing slash lists the directory's own children. + a.closeCommandBar() + a.openCommandBar() + typeIntoCommand(a, "cd sub/nested/") + a.handleCommandKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) + if got := string(a.commandValue); got != "cd sub/nested/deep " { + t.Errorf("value = %q, want %q", got, "cd sub/nested/deep ") + } +} + +// TestCommandCompletionTilde completes under the home directory while +// preserving the ~ spelling. +func TestCommandCompletionTilde(t *testing.T) { + home := t.TempDir() + if err := os.MkdirAll(filepath.Join(home, "Documents"), 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + // Redirect os.UserHomeDir by setting HOME — it consults the env var + // on unix before falling back to passwd. + t.Setenv("HOME", home) + + a := newTestApp(t, t.TempDir()) + a.openCommandBar() + typeIntoCommand(a, "cd ~/Doc") + a.handleCommandKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) + if got := string(a.commandValue); got != "cd ~/Documents " { + t.Errorf("value = %q, want %q", got, "cd ~/Documents ") + } +} + +// TestCommandCompletionIgnoresFilesAndGit asserts files and the project's +// .git directory never surface as cd candidates. +func TestCommandCompletionIgnoresFilesAndGit(t *testing.T) { + root := t.TempDir() + seedDirs(t, root) + a := newTestApp(t, t.TempDir()) + a.rootDir = root + a.openCommandBar() + + typeIntoCommand(a, "cd ") + for _, name := range a.commandSuggestion { + if name == "alpha.txt" || name == ".git" { + t.Errorf("candidate %q must not appear", name) + } + } + if len(a.commandSuggestion) != 4 { // alpha, alphabeta, beta, sub + t.Errorf("candidates = %v, want 4 dirs", a.commandSuggestion) + } +} + +// TestCommandCompletionArrows cycles candidates with Up/Down. The prefix +// stays broad ("alp" matches both alpha and alphabeta) so the list doesn't +// narrow under the cursor the way a full candidate would. +func TestCommandCompletionArrows(t *testing.T) { + root := t.TempDir() + seedDirs(t, root) + a := newTestApp(t, t.TempDir()) + a.rootDir = root + a.openCommandBar() + + typeIntoCommand(a, "cd alp") + a.handleCommandKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)) + if got := string(a.commandValue); got != "cd alpha" { + t.Errorf("after Down = %q, want %q", got, "cd alpha") + } + a.handleCommandKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)) + if got := string(a.commandValue); got != "cd alphabeta" { + t.Errorf("after second Down = %q, want %q", got, "cd alphabeta") + } + a.handleCommandKey(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)) + if got := string(a.commandValue); got != "cd alpha" { + t.Errorf("after Up = %q, want %q", got, "cd alpha") + } +} + +// TestCmdCdReroot runs a valid cd through the bar and verifies every +// project surface — tree root, active folder, rootDir, finder index — +// points at the new directory, while open tabs survive. +func TestCmdCdReroot(t *testing.T) { + old := t.TempDir() + sub := filepath.Join(old, "sub") + if err := os.MkdirAll(sub, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(sub, "main.go"), []byte("x"), 0644); err != nil { + t.Fatalf("write: %v", err) + } + a := newTestApp(t, old) + a.openCommandBar() + typeIntoCommand(a, "cd sub") + a.handleCommandKey(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) + + if a.commandOpen { + t.Fatal("command bar should close after a successful cd") + } + if a.rootDir != sub { + t.Errorf("rootDir = %q, want %q", a.rootDir, sub) + } + if a.tree == nil || a.tree.Root.Path != sub { + t.Errorf("tree root = %v, want %q", a.tree, sub) + } + if a.activeFolder != sub { + t.Errorf("activeFolder = %q, want %q", a.activeFolder, sub) + } + waitForFinderReady(t, a) + if a.finder == nil { + t.Fatal("finder should be re-created after reroot") + } + // The new index must contain the new root's file, not the old one's. + found := false + for _, r := range a.finder.Search("main.go", 10) { + if r.Path == "main.go" { + found = true + } + } + if !found { + t.Errorf("finder index lacks main.go under the new root") + } +} + +// TestCmdCdInvalidPath keeps the bar open with a hint for a missing +// directory — nvim's E344 behavior. +func TestCmdCdInvalidPath(t *testing.T) { + a := newTestApp(t, t.TempDir()) + a.openCommandBar() + typeIntoCommand(a, "cd nope") + a.handleCommandKey(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) + + if !a.commandOpen { + t.Fatal("bar should stay open after a failed cd") + } + if !strings.Contains(a.commandHint, "no such directory") { + t.Errorf("hint = %q, want 'no such directory'", a.commandHint) + } +} + +// TestCmdCdMissingArg flashes a hint instead of running. +func TestCmdCdMissingArg(t *testing.T) { + a := newTestApp(t, t.TempDir()) + a.openCommandBar() + typeIntoCommand(a, "cd") + a.handleCommandKey(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) + if !a.commandOpen { + t.Fatal("bar should stay open") + } + if a.commandHint != "cd: missing directory" { + t.Errorf("hint = %q, want missing-directory", a.commandHint) + } +} + +// TestCommandUnknownCommand reports unknown commands and keeps the bar +// open so the typo can be fixed. +func TestCommandUnknownCommand(t *testing.T) { + a := newTestApp(t, t.TempDir()) + a.openCommandBar() + typeIntoCommand(a, "ls -la") + a.handleCommandKey(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) + if !a.commandOpen { + t.Fatal("bar should stay open for unknown commands") + } + if a.commandHint != "unknown command: ls" { + t.Errorf("hint = %q, want unknown-command hint", a.commandHint) + } +} + +// TestRerootClearsSearchResults asserts stale root-relative results from +// every search surface are dropped when the project re-roots. +func TestRerootClearsSearchResults(t *testing.T) { + a := newTestApp(t, t.TempDir()) + a.finder = finder.New(a.rootDir) + a.finderResults = []finder.Result{{Path: "old/file.go"}} + a.searchResults = []finder.ContentMatch{{Path: "old/file.go"}} + a.sidebarSearchResults = []finder.ContentMatch{{Path: "old/file.go"}} + + next := t.TempDir() + a.reroot(next) + + if a.finderResults != nil { + t.Error("finderResults should be cleared on reroot") + } + if a.searchResults != nil { + t.Error("searchResults should be cleared on reroot") + } + if a.sidebarSearchResults != nil { + t.Error("sidebarSearchResults should be cleared on reroot") + } + if a.tree == nil || a.tree.Root.Path != next { + t.Errorf("tree root = %v, want %q", a.tree, next) + } +} + +// TestCommandBarDraw renders the bar on a simulation screen and checks +// the input row paints with the typed command. +func TestCommandBarDraw(t *testing.T) { + a := newTestApp(t, t.TempDir()) + a.openCommandBar() + typeIntoCommand(a, "cd sub") + a.draw() + scr := a.screen.(tcell.SimulationScreen) + scr.Show() + + _, by, _, _ := a.commandBarRect() + line := screenLine(scr, by) + if !strings.Contains(line, "cd sub") { + t.Errorf("command row = %q, want it to contain %q", line, "cd sub") + } +} diff --git a/internal/app/leader.go b/internal/app/leader.go index 903581b..7d300a2 100644 --- a/internal/app/leader.go +++ b/internal/app/leader.go @@ -48,6 +48,7 @@ func leaderBindings() []leaderBinding { {'f', (*App).openFind}, {'p', (*App).openFinder}, {'F', (*App).openSearchFiles}, + {':', (*App).openCommandBar}, {'`', (*App).menuOpenTerminal}, } } diff --git a/internal/app/modals.go b/internal/app/modals.go index b477455..812f570 100644 --- a/internal/app/modals.go +++ b/internal/app/modals.go @@ -94,6 +94,14 @@ func (a *App) closeAllModals() { a.diffLines = nil a.dragMode = "" a.stopAutoScroll() + a.commandOpen = false + a.commandValue = nil + a.commandCursor = 0 + a.commandScroll = 0 + a.commandSuggestion = nil + a.commandSelected = -1 + a.commandCycling = false + a.commandHint = "" } // anyModalOpen reports whether any modal is on screen. Used by the main @@ -103,7 +111,7 @@ func (a *App) closeAllModals() { // is what the user wants), but a key/mouse handler can use this to know // "is the user mid-task in some overlay surface". func (a *App) anyModalOpen() bool { - return a.menuOpen || a.promptOpen || a.confirmOpen || a.contextOpen || a.dirtyOpen || a.formOpen || a.findOpen || a.finderOpen || a.diffOpen + return a.menuOpen || a.promptOpen || a.confirmOpen || a.contextOpen || a.dirtyOpen || a.formOpen || a.findOpen || a.finderOpen || a.diffOpen || a.commandOpen } // ----------------------------------------------------------------------------- From df72919717a92b9dc70ce3b52e00bfef5392fd55 Mon Sep 17 00:00:00 2001 From: Jaime Villanueva Alcon <114494844+jaivial@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:23:42 +0200 Subject: [PATCH 6/8] fix: keep full path prefix in absolute-path cd completion (#3) commandSearchBase dropped the directory prefix for absolute tokens, so '/var/www/new' + Tab completed to bare 'newvillacarmen' instead of '/var/www/newvillacarmen'. The absolute branch now splices the dirname back in (single slash at filesystem root). Adds a regression test covering nested absolute completion, cycling, and root-level tokens. --- internal/app/commandbar.go | 7 ++++++- internal/app/commandbar_test.go | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/internal/app/commandbar.go b/internal/app/commandbar.go index 6da65a8..61e6b37 100644 --- a/internal/app/commandbar.go +++ b/internal/app/commandbar.go @@ -479,7 +479,12 @@ func (a *App) commandSearchBase(prefix string) (search, base, outPrefix string) if strings.HasSuffix(prefix, "/") { return prefix, "", prefix } - return filepath.Dir(prefix), filepath.Base(prefix), "" + d := filepath.Dir(prefix) + pre := d + "/" + if d == "/" { + pre = "/" // avoid the doubled slash + } + return d, filepath.Base(prefix), pre } if strings.HasSuffix(prefix, "/") { return filepath.Join(a.rootDir, prefix), "", prefix diff --git a/internal/app/commandbar_test.go b/internal/app/commandbar_test.go index 2237a56..f0101df 100644 --- a/internal/app/commandbar_test.go +++ b/internal/app/commandbar_test.go @@ -151,6 +151,40 @@ func TestCommandCompletionNested(t *testing.T) { } } +// TestCommandCompletionAbsolute keeps the full directory prefix when the +// token is an absolute path — "/var/www/new" + Tab must yield +// "/var/www/newvillacarmen", not just "newvillacarmen". +func TestCommandCompletionAbsolute(t *testing.T) { + root := t.TempDir() + seedDirs(t, root) + a := newTestApp(t, t.TempDir()) + a.rootDir = root + a.openCommandBar() + + typeIntoCommand(a, "cd "+root+"/al") + a.handleCommandKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) + want := "cd " + root + "/alpha" + if got := string(a.commandValue); got != want { + t.Errorf("value = %q, want %q", got, want) + } + + // Cycle must keep the prefix too. + a.handleCommandKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) + want = "cd " + root + "/alphabeta" + if got := string(a.commandValue); got != want { + t.Errorf("after cycle = %q, want %q", got, want) + } + + // Root-level prefix "/" gets a single leading slash. + a.closeCommandBar() + a.openCommandBar() + typeIntoCommand(a, "cd /tm") + a.handleCommandKey(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) + if got := string(a.commandValue); got != "cd /tmp " { + t.Errorf("root-level value = %q, want %q", got, "cd /tmp ") + } +} + // TestCommandCompletionTilde completes under the home directory while // preserving the ~ spelling. func TestCommandCompletionTilde(t *testing.T) { From 781f5de4117bed388d9897dfd5b58df4fb46641b Mon Sep 17 00:00:00 2001 From: jaivial <114494844+jaivial@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:02:28 +0000 Subject: [PATCH 7/8] feat: tab-bar + button opens a new scratch tab Draws a + button right after the last tab (or after the menu button when no tabs are open) that opens and focuses a new unsaved scratch tab. Styled like the far-right terminal button. --- internal/app/app.go | 40 +++++++++++++++++++- internal/app/app_test.go | 82 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/internal/app/app.go b/internal/app/app.go index ebf933c..d82ba2d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -77,6 +77,9 @@ const ( // terminalTabBtnWidth is the far-right "+"/terminal button's cell count. terminalTabBtnWidth = 3 + // newFileTabBtnWidth is the new-scratch-tab "+" button's cell count. + newFileTabBtnWidth = 3 + // modalWidth is the action modal's column count. Sized to comfortably // fit the longest dynamic label — "Rename folder (subdir/)" with a // folder name up to maxLabelSuffix runes — plus the leading "▸ " @@ -543,6 +546,10 @@ type App struct { // Terminal-tab button x in the tab bar (far right). -1 when hidden. newTabBtnX int + // New-scratch-tab button x in the tab bar, right after the last tab + // (or after the menu button when no tabs are open). -1 when hidden. + newFileBtnX int + // Sidebar header tab + find-in-files panel state. Independent of the // searchOpen modal above so the two search surfaces coexist. sidebarTab string // "files" | "search" @@ -1608,6 +1615,16 @@ func (a *App) tabBarClick(x, _ int) { a.openMenu() return } + if a.newFileBtnX >= 0 && x >= a.newFileBtnX && x < a.newFileBtnX+newFileTabBtnWidth { + t, err := editor.NewTab("") + if err != nil { + a.flash(fmt.Sprintf("Error: %v", err)) + return + } + a.tabs = append(a.tabs, t) + a.activeTab = len(a.tabs) - 1 + return + } if a.newTabBtnX >= 0 && x >= a.newTabBtnX && x < a.newTabBtnX+terminalTabBtnWidth { a.menuOpenTerminal() return @@ -2648,7 +2665,8 @@ func (a *App) layoutTabs() []tabRect { } // drawTabBar paints the tab bar across the top of the editor area: first -// the menu button (≡), then any open tabs. +// the menu button (≡), then any open tabs, then the new-tab button, with +// the terminal button pinned to the far right. func (a *App) drawTabBar() { tx, ty, tw, _ := a.tabBarRect() barStyle := tcell.StyleDefault.Background(a.theme.SidebarBG).Foreground(a.theme.Muted) @@ -2729,6 +2747,26 @@ func (a *App) drawTabBar() { } } + // New-scratch-tab button, right after the last tab. With no tabs it + // sits right after the menu button. Drawn before the terminal + // button so the far-right pin still wins on overflow. + a.newFileBtnX = -1 + { + btnX := a.sidebarW() + menuButtonWidth + if len(rects) > 0 { + last := rects[len(rects)-1] + btnX = last.X + last.Width + } + if btnX+newFileTabBtnWidth <= tx+tw { + a.newFileBtnX = btnX + btnStyle := tcell.StyleDefault.Background(a.theme.SidebarBG).Foreground(a.theme.Accent) + for cx := btnX; cx < btnX+newFileTabBtnWidth; cx++ { + a.screen.SetContent(cx, ty, ' ', nil, btnStyle) + } + a.screen.SetContent(btnX+1, ty, '+', nil, btnStyle) + } + } + // Terminal-tab button pinned to the far right of the tab bar. Drawn // last so it covers any tab that would overflow beneath it. a.newTabBtnX = -1 diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 62669e4..889e8c7 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1607,6 +1607,88 @@ func TestTabBarClick_ClosesViaX(t *testing.T) { } } +// TestTabBarClick_NewTabButton opens a scratch tab from the "+" button, +// both with no tabs open (button sits after the menu button) and with a +// tab open (button sits right after the last tab). +func TestTabBarClick_NewTabButton(t *testing.T) { + dir := t.TempDir() + a := newTestApp(t, dir) + + // Zero tabs: button lives after the menu button. + a.drawTabBar() + if a.newFileBtnX < 0 { + t.Fatal("new-tab button not laid out with zero tabs") + } + a.tabBarClick(a.newFileBtnX+1, 0) + if len(a.tabs) != 1 { + t.Fatalf("tab count = %d, want 1", len(a.tabs)) + } + tab := a.activeTabPtr() + if a.activeTab != 0 || tab == nil || tab.Path != "" { + t.Fatal("expected focused scratch tab with empty path") + } + if tab.IsTerminal() { + t.Fatal("new-tab button should open a scratch tab, not a terminal") + } + + // One tab open: button sits right after the last tab. + target := filepath.Join(dir, "f.txt") + if err := os.WriteFile(target, []byte("x"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + a.openFile(target) + a.drawTabBar() + rects := a.layoutTabs() + last := rects[len(rects)-1] + if a.newFileBtnX != last.X+last.Width { + t.Fatalf("new-tab button x = %d, want %d (after last tab)", a.newFileBtnX, last.X+last.Width) + } + a.tabBarClick(a.newFileBtnX+1, 0) + if len(a.tabs) != 3 { + t.Fatalf("tab count = %d, want 3", len(a.tabs)) + } + if a.activeTab != 2 || a.activeTabPtr().Path != "" { + t.Fatal("expected new scratch tab focused") + } +} + +// TestDrawTabBar_NewTabButtonRendered verifies the "+" glyph lands on +// screen after the menu button with zero tabs and after the last tab +// when tabs are open. +func TestDrawTabBar_NewTabButtonRendered(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "f.txt") + if err := os.WriteFile(target, []byte("x"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + a := newTestApp(t, dir) + _, ty, _, _ := a.tabBarRect() + + a.drawTabBar() + a.screen.Show() + cells, w, _ := a.screen.(tcell.SimulationScreen).GetContents() + if a.newFileBtnX < 0 || a.newFileBtnX+1 >= w { + t.Fatal("new-tab button not laid out with zero tabs") + } + if r := cells[ty*w+a.newFileBtnX+1].Runes; len(r) == 0 || r[0] != '+' { + t.Fatal("expected + glyph after menu button with zero tabs") + } + + a.openFile(target) + a.drawTabBar() + a.screen.Show() + cells, w, _ = a.screen.(tcell.SimulationScreen).GetContents() + rects := a.layoutTabs() + last := rects[len(rects)-1] + wantX := last.X + last.Width + if a.newFileBtnX != wantX { + t.Fatalf("new-tab button x = %d, want %d (after last tab)", a.newFileBtnX, wantX) + } + if r := cells[ty*w+wantX+1].Runes; len(r) == 0 || r[0] != '+' { + t.Fatal("expected + glyph after last tab") + } +} + // TestDrawStatusBar_RendersBranchRightAligned pins down the lower-right // branch label: when gitBranch is set, the rightmost cells of the // status bar carry " " in order, so the user can glance at From acd4505e82071ab9f3e113aaeb272d5bc25582ae Mon Sep 17 00:00:00 2001 From: jaivial <114494844+jaivial@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:34:36 +0000 Subject: [PATCH 8/8] fix: forward shift-tab, F-keys, alt-arrows to terminal shell --- internal/editor/terminal.go | 43 ++++++++++++++++++++++++++++++++ internal/editor/terminal_test.go | 9 +++++++ 2 files changed, 52 insertions(+) diff --git a/internal/editor/terminal.go b/internal/editor/terminal.go index 386d0d7..b1d15a7 100644 --- a/internal/editor/terminal.go +++ b/internal/editor/terminal.go @@ -523,17 +523,36 @@ func TerminalKeyBytes(ev *tcell.EventKey) []byte { return []byte{'\r'} case tcell.KeyTab: return []byte{'\t'} + case tcell.KeyBacktab: + // Shift-Tab, used by readline and CLIs like codex to cycle + // completion backwards. xterm encodes it as CSI Z. + return []byte("\x1b[Z") 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: + if ev.Modifiers()&tcell.ModAlt != 0 { + return []byte("\x1b[1;3A") + } return []byte("\x1b[A") case tcell.KeyDown: + if ev.Modifiers()&tcell.ModAlt != 0 { + return []byte("\x1b[1;3B") + } return []byte("\x1b[B") case tcell.KeyRight: + if ev.Modifiers()&tcell.ModAlt != 0 { + // ESC f is readline's forward-word, what Alt-Right means + // in every shell. + return []byte("\x1bf") + } return []byte("\x1b[C") case tcell.KeyLeft: + if ev.Modifiers()&tcell.ModAlt != 0 { + // ESC b is readline's backward-word (Alt-Left). + return []byte("\x1bb") + } return []byte("\x1b[D") case tcell.KeyHome: return []byte("\x1b[H") @@ -547,6 +566,30 @@ func TerminalKeyBytes(ev *tcell.EventKey) []byte { return []byte("\x1b[3~") case tcell.KeyInsert: return []byte("\x1b[2~") + case tcell.KeyF1: + return []byte("\x1bOP") + case tcell.KeyF2: + return []byte("\x1bOQ") + case tcell.KeyF3: + return []byte("\x1bOR") + case tcell.KeyF4: + return []byte("\x1bOS") + case tcell.KeyF5: + return []byte("\x1b[15~") + case tcell.KeyF6: + return []byte("\x1b[17~") + case tcell.KeyF7: + return []byte("\x1b[18~") + case tcell.KeyF8: + return []byte("\x1b[19~") + case tcell.KeyF9: + return []byte("\x1b[20~") + case tcell.KeyF10: + return []byte("\x1b[21~") + case tcell.KeyF11: + return []byte("\x1b[23~") + case tcell.KeyF12: + return []byte("\x1b[24~") } // Control keys reach us in one of two encodings, and we have to diff --git a/internal/editor/terminal_test.go b/internal/editor/terminal_test.go index 68c8df5..518362d 100644 --- a/internal/editor/terminal_test.go +++ b/internal/editor/terminal_test.go @@ -387,6 +387,15 @@ func TestTerminalKeyBytes(t *testing.T) { {"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"}, + {"shift-tab is CSI Z", tcell.NewEventKey(tcell.KeyBacktab, 0, tcell.ModShift), "\x1b[Z"}, + {"f1", tcell.NewEventKey(tcell.KeyF1, 0, tcell.ModNone), "\x1bOP"}, + {"f4", tcell.NewEventKey(tcell.KeyF4, 0, tcell.ModNone), "\x1bOS"}, + {"f5", tcell.NewEventKey(tcell.KeyF5, 0, tcell.ModNone), "\x1b[15~"}, + {"f12", tcell.NewEventKey(tcell.KeyF12, 0, tcell.ModNone), "\x1b[24~"}, + {"alt-left is backward-word", tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModAlt), "\x1bb"}, + {"alt-right is forward-word", tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModAlt), "\x1bf"}, + {"alt-up", tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModAlt), "\x1b[1;3A"}, + {"alt-down", tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModAlt), "\x1b[1;3B"}, } for _, tc := range cases {