diff --git a/README.md b/README.md index ea68654..ba8021d 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,8 @@ 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 within the window, your next keystroke goes to the editor as normal, @@ -250,6 +256,77 @@ 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 +`$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..d82ba2d 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" @@ -72,6 +74,12 @@ 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 + + // 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 "▸ " @@ -116,6 +124,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 @@ -195,6 +215,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 { @@ -216,6 +241,11 @@ 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}, + }, + // Commands + { + {label: "Command bar", shortcut: "Esc :", action: (*App).menuCommandBar, enabled: alwaysTrue}, }, // Quit { @@ -432,6 +462,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 @@ -449,6 +491,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 @@ -467,6 +529,43 @@ 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 + + // 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" + 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 @@ -508,6 +607,7 @@ func New(rootDir string) (*App, error) { hoveredMenuRow: -1, sidebarShown: true, sidebarWidth: defaultSidebarWidth, + sidebarTab: "files", } a.setActiveFolder(tree.Root.Path) a.loadSpiceConfig() @@ -567,6 +667,7 @@ func NewSingleFile(filePath string) (*App, error) { hoveredMenuRow: -1, sidebarShown: false, sidebarWidth: defaultSidebarWidth, + sidebarTab: "files", } a.setActiveFolder(rootDir) a.loadSpiceConfig() @@ -643,6 +744,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 } @@ -650,13 +752,14 @@ 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() } // 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 +801,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 +861,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: @@ -747,6 +876,18 @@ 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() + } + if a.sidebarTab == "search" && len(a.sidebarSearchQuery) > 0 { + a.sidebarRunSearch() + } + case *searchResultsEvent: + a.applySearchResults(e) + case *sidebarSearchResultsEvent: + a.applySidebarSearchResults(e) } } @@ -1012,6 +1153,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 @@ -1020,6 +1165,18 @@ func (a *App) handleKey(ev *tcell.EventKey) { a.handleFinderKey(ev) return } + if a.searchOpen { + a.handleSearchKey(ev) + return + } + if a.commandOpen { + a.handleCommandKey(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: @@ -1047,7 +1204,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 +1251,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. @@ -1168,10 +1345,22 @@ 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.commandOpen { + a.handleCommandMouse(x, y, btn) + return + } if a.menuOpen { a.updateMenuHover(x, y) @@ -1297,6 +1486,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 } @@ -1329,6 +1525,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 @@ -1359,7 +1558,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 @@ -1399,6 +1615,20 @@ 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 + } for _, r := range a.lastTabRects { if x >= r.X && x < r.X+r.Width { if x == r.CloseX { @@ -1426,11 +1656,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 +1708,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 +2029,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 +2175,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 +2196,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 +2394,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 +2444,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 @@ -2269,6 +2570,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() } @@ -2284,6 +2589,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 @@ -2307,9 +2615,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 @@ -2351,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) @@ -2396,6 +2711,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) @@ -2425,6 +2746,47 @@ func (a *App) drawTabBar() { a.screen.SetContent(col, ty, '×', nil, closeStyle) } } + + // 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 + 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 @@ -2517,7 +2879,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..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 @@ -1655,7 +1737,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 +1745,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 != 37 { + t.Errorf("modalHeight = %d, want 37", h) } - if got := len(items); got != 21 { - t.Errorf("item count = %d, want 21 built-ins", got) + if got := len(items); got != 25 { + t.Errorf("item count = %d, want 25 built-ins", got) } - wantDiv := []int{2, 6, 10, 13, 21, 26, 28} + 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 +1912,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 != 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..61e6b37 --- /dev/null +++ b/internal/app/commandbar.go @@ -0,0 +1,617 @@ +// ============================================================================= +// 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 + } + 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 + } + 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..f0101df --- /dev/null +++ b/internal/app/commandbar_test.go @@ -0,0 +1,384 @@ +// ============================================================================= +// 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 ") + } +} + +// 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) { + 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/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/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/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 dba3770..7d300a2 100644 --- a/internal/app/leader.go +++ b/internal/app/leader.go @@ -47,6 +47,9 @@ func leaderBindings() []leaderBinding { {'/', (*App).menuToggleLineComment}, {'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 8fcd9c2..812f570 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,8 +89,19 @@ 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() + 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 @@ -98,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 + return a.menuOpen || a.promptOpen || a.confirmOpen || a.contextOpen || a.dirtyOpen || a.formOpen || a.findOpen || a.finderOpen || a.diffOpen || a.commandOpen } // ----------------------------------------------------------------------------- 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/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 new file mode 100644 index 0000000..12cc310 --- /dev/null +++ b/internal/app/terminal_test.go @@ -0,0 +1,431 @@ +// ============================================================================= +// 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) + } +} + +// 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") + } +} 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..b1d15a7 --- /dev/null +++ b/internal/editor/terminal.go @@ -0,0 +1,616 @@ +// ============================================================================= +// 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.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") + 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~") + 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 + // 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..518362d --- /dev/null +++ b/internal/editor/terminal_test.go @@ -0,0 +1,518 @@ +// ============================================================================= +// 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"}, + {"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 { + 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/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) + } +} 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 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"