Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -191,6 +195,7 @@ within half a second tap one of the letters below.
| `Esc /` | Toggle line comment |
| `Esc f` | Find in file |
| `Esc p` | Find file in project |
| `Esc \`` | Open terminal tab |

A lone `Esc` is harmless — if you don't follow it with a bound key
within the window, your next keystroke goes to the editor as normal,
Expand Down Expand Up @@ -250,6 +255,45 @@ fuzzy file finder over every non-ignored file in the project:
inside the editor.
- Only files are listed — no directories, no symlinked duplicates.

## Terminal tabs

`Esc \`` (or **Open terminal in new tab** from the `≡` menu) opens your
`$SHELL` in a new tab, so you can run tests, `git`, or anything else
without dropping the editor or reaching for a second tmux pane.

The terminal starts in the folder you're currently working in (the
selected file's directory, falling back to the project root), so
relative commands land where you expect.

It's a real terminal, not a log pane: SpiceEdit embeds a VT emulator and
paints the shell's screen into the tab, so `Ctrl+C`, colours, and
full-screen programs all behave. Because the editor owns the emulation,
none of it leaks into your host terminal — it works over SSH and inside
`tmux` with no passthrough configuration.

A few deliberate details:

- **`Esc` belongs to the editor.** It's the only key the terminal never
receives, because double-tapping it is how you get back to the `≡`
menu. Everything else — including `Ctrl+C`, `Ctrl+D`, `Ctrl+Z`, and
arrow-key history — goes to the shell.
- **The `Esc`-leader shortcuts stand down inside a terminal.** Elsewhere
`Esc s` saves and `Esc q` quits, but a shell prompt is the one place
you press `Esc` by reflex, and swallowing the next key to run an editor
action would be both surprising and destructive. Double-tap `Esc` for
the menu instead — every action is still there.
- **Terminal tabs never look "unsaved."** They have no file, so Save,
Find, and the git gutter skip them, and quitting won't prompt about
them.
- **Closing the tab closes the shell**, and quitting the editor closes
every terminal it opened. Backgrounded jobs are hung up with the shell
rather than orphaned.
- **The status bar** shows `terminal · shell running`, or the exit
status once the shell has exited.

Terminal tabs are unix-only (macOS and Linux). On Windows the menu row
is greyed out, since Windows has no PTY the editor can drive this way.

## Custom actions (open remote files on your laptop)

[![Watch the walkthrough](https://img.youtube.com/vi/vDWZWEmIiZ8/maxresdefault.jpg)](https://www.youtube.com/watch?v=vDWZWEmIiZ8)
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
167 changes: 157 additions & 10 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"

"github.com/gdamore/tcell/v2"
Expand Down Expand Up @@ -116,6 +118,18 @@ type treeRefreshEvent struct {
// When satisfies the tcell.Event interface.
func (e *treeRefreshEvent) When() time.Time { return e.when }

// termOutputEvent is posted by a terminal tab's PTY reader goroutine when
// the child shell produces output. It carries no payload — the emulator
// state was already updated under its own lock; this exists purely to
// wake the main loop so it redraws. Following the project's rule that
// background goroutines never mutate UI state directly.
type termOutputEvent struct {
when time.Time
}

// When satisfies the tcell.Event interface.
func (e *termOutputEvent) When() time.Time { return e.when }

// customActionDoneEvent is posted by runCustomAction when its background
// shell-out finishes. Carries the label and any error so the main loop
// can flash a sensible status message — running scp / ssh inline would
Expand Down Expand Up @@ -216,6 +230,7 @@ func builtinMenuGroups() [][]menuItemDef {
// View toggle
{
{shortcut: "Esc t", action: (*App).menuToggleSidebar, enabled: alwaysTrue, labelFor: (*App).sidebarToggleLabel, visible: (*App).hasTree},
{label: "Open terminal in new tab", shortcut: "Esc `", action: (*App).menuOpenTerminal, enabled: (*App).canOpenTerminal},
},
// Quit
{
Expand Down Expand Up @@ -656,7 +671,7 @@ func (a *App) refreshGitStatus() {
// refreshGitLineChanges refreshes gutter markers for every open text tab.
func (a *App) refreshGitLineChanges() {
for _, tab := range a.tabs {
if tab == nil || tab.Path == "" || tab.IsImage() {
if tab == nil || tab.Path == "" || !tab.IsTextual() {
continue
}
tab.GitLines = loadGitLineChanges(a.rootDir, tab.Path)
Expand Down Expand Up @@ -698,11 +713,33 @@ func (a *App) stopTreeRefresh() {
func (a *App) Close() {
a.stopTreeRefresh()
a.stopAutoScroll()
a.closeAllTerminals()
if a.screen != nil {
a.screen.Fini()
}
}

// closeAllTerminals kills every child shell the session started. Called
// from Close so quitting the editor doesn't orphan them.
//
// The closes run concurrently because each one may wait up to
// terminalCloseGrace for its shell to hang up its jobs; doing that
// serially would freeze the UI for grace × N on the way out.
func (a *App) closeAllTerminals() {
var wg sync.WaitGroup
for _, t := range a.tabs {
if !t.IsTerminal() {
continue
}
wg.Add(1)
go func(tab *editor.Tab) {
defer wg.Done()
tab.CloseTerminal()
}(t)
}
wg.Wait()
}

// Run is the editor's main event loop. It blocks on PollEvent, dispatches
// each event, redraws, and exits when a.quit is set.
func (a *App) Run() error {
Expand Down Expand Up @@ -736,6 +773,10 @@ func (a *App) handleEvent(ev tcell.Event) {
a.handleAutoScroll()
case *treeRefreshEvent:
a.refreshTreeNow()
case *termOutputEvent:
// Nothing to do — the terminal's emulator state is already
// current. Falling through to the loop's unconditional redraw
// is the whole point of the event.
case *customActionDoneEvent:
a.handleCustomActionDone(e)
case *formatDoneEvent:
Expand Down Expand Up @@ -1047,7 +1088,15 @@ func (a *App) handleKey(ev *tcell.EventKey) {
// key is bound in the leader table, fire the action and consume the
// keystroke. Unbound keys fall through to normal handling so a stray
// Esc doesn't swallow the next character the user types.
if !a.lastEscape.IsZero() && time.Since(a.lastEscape) < doubleEscMs {
//
// Terminal tabs opt out of the single-Esc leader entirely. Esc is a
// key shell users press constantly (vi keybindings, cancelling a
// completion, plain habit), and swallowing the *next* rune to run an
// editor action is both surprising and destructive: "Esc" then "q"
// would quit the editor — hanging up every running shell — instead of
// typing "q" at the prompt. Double-Esc still opens the action menu,
// so every action remains reachable.
if !a.lastEscape.IsZero() && time.Since(a.lastEscape) < doubleEscMs && !a.activeTabIsTerminal() {
if ev.Key() == tcell.KeyRune {
if action := leaderActionFor(ev.Rune()); action != nil {
a.lastEscape = time.Time{}
Expand Down Expand Up @@ -1086,6 +1135,18 @@ func (a *App) handleKey(ev *tcell.EventKey) {
if tab == nil {
return
}
// Terminal tabs forward almost every keystroke to the child shell,
// including the Ctrl keys the editor otherwise refuses to bind —
// there they mean "signal the foreground process", not an editor
// action. Esc never reaches here (it's consumed above for the menu
// and leader table), which is the one sequence a shell user has to
// reach via Esc-Esc → menu instead.
if tab.IsTerminal() {
if b := editor.TerminalKeyBytes(ev); b != nil {
tab.Term.Write(b)
}
return
}
// Image-preview tabs are read-only — no cursor, no editing, no
// caret movement. Drop every key here so the user can mash arrow
// keys without anything mysterious happening behind the splash.
Expand Down Expand Up @@ -1426,11 +1487,11 @@ func (a *App) syncActiveTreeFile() {
}

// editorPress handles the initial mouse press inside the editor — placing
// the caret, optionally selecting a word on double-click. Image tabs
// have no caret, so the press is dropped.
// the caret, optionally selecting a word on double-click. Non-text tabs
// (image previews, terminals) have no caret, so the press is dropped.
func (a *App) editorPress(x, y int) {
tab := a.activeTabPtr()
if tab == nil || tab.IsImage() {
if tab == nil || !tab.IsTextual() {
return
}
ex, ey, ew, eh := a.editorRect()
Expand Down Expand Up @@ -1478,7 +1539,7 @@ func (a *App) openGitHunkAt(tab *editor.Tab, localX, localY int) bool {
// drop the drag entirely.
func (a *App) editorDrag(x, y int) {
tab := a.activeTabPtr()
if tab == nil || tab.IsImage() {
if tab == nil || !tab.IsTextual() {
return
}
ex, ey, ew, eh := a.editorRect()
Expand Down Expand Up @@ -1799,6 +1860,9 @@ func (a *App) closeTab(idx int) {
if idx < 0 || idx >= len(a.tabs) {
return
}
// Terminal tabs own a child shell — tear it down with the tab so we
// don't leak a running process for the rest of the session.
a.tabs[idx].CloseTerminal()
a.tabs = append(a.tabs[:idx], a.tabs[idx+1:]...)
if a.activeTab >= len(a.tabs) {
a.activeTab = len(a.tabs) - 1
Expand Down Expand Up @@ -1942,7 +2006,7 @@ func (a *App) hasTab() bool { return a.activeTabPtr() != nil }
// preview. Used by Save and Save & Close.
func (a *App) hasSavableTab() bool {
t := a.activeTabPtr()
return t != nil && t.Path != "" && !t.IsImage()
return t != nil && t.Path != "" && t.IsTextual()
}

// hasFileTab reports whether the active tab is backed by a real file
Expand All @@ -1963,7 +2027,7 @@ func (a *App) hasSelection() bool {
// known single-line comment marker.
func (a *App) hasCommentableTab() bool {
t := a.activeTabPtr()
if t == nil || t.IsImage() {
if t == nil || !t.IsTextual() {
return false
}
_, ok := editor.LineCommentPrefix(t.Path)
Expand Down Expand Up @@ -2161,7 +2225,7 @@ func (a *App) menuPaste() {
func (a *App) menuToggleLineComment() {
a.closeMenu()
tab := a.activeTabPtr()
if tab == nil || tab.IsImage() {
if tab == nil || !tab.IsTextual() {
return
}
changed, ok := tab.ToggleLineComment()
Expand Down Expand Up @@ -2211,6 +2275,74 @@ func (a *App) sidebarToggleLabel() string {
return "Show file explorer"
}

// activeTabIsTerminal reports whether the focused tab hosts a shell.
// Used to keep the Esc-leader table from stealing keystrokes that belong
// to the terminal.
func (a *App) activeTabIsTerminal() bool {
t := a.activeTabPtr()
return t != nil && t.IsTerminal()
}

// canOpenTerminal reports whether a terminal tab can be opened. PTYs are
// a unix affair — creack/pty compiles on Windows but every call returns
// ErrUnsupported — so the row is greyed out there rather than offering an
// action that can only fail.
func (a *App) canOpenTerminal() bool {
return runtime.GOOS != "windows"
}

// terminalCwd picks the working directory a new terminal starts in: the
// folder the user is "in" according to the tree (activeFolder, which
// tracks the selected file's directory), falling back to the project
// root. This is the behaviour that makes `go test ./...` land where the
// user expects instead of at a root they navigated away from.
func (a *App) terminalCwd() string {
if a.activeFolder != "" {
if info, err := os.Stat(a.activeFolder); err == nil && info.IsDir() {
return a.activeFolder
}
}
return a.rootDir
}

// menuOpenTerminal opens a new tab running the user's shell and focuses
// it. The shell is started at terminalCwd() and sized to the current
// editor pane; the first Render corrects the size if the pane geometry
// differs from our estimate.
//
// Output arrives on a background goroutine which posts termOutputEvent
// to wake the main loop — the goroutine never touches UI state itself.
func (a *App) menuOpenTerminal() {
a.closeMenu()
if !a.canOpenTerminal() {
a.flash("Terminal tabs aren't supported on this platform")
return
}

w, h := a.editorSize()
scr := a.screen
notify := func() {
// PostEvent can block if the queue is full and the main loop is
// busy; the non-blocking variant would drop redraws. A blocking
// post is correct here because the reader goroutine has nothing
// else to do, and it can't deadlock — the main loop drains the
// queue continuously.
_ = scr.PostEvent(&termOutputEvent{when: time.Now()})
}

tab, err := editor.NewTerminalTab(a.terminalCwd(), w, h, notify)
if err != nil {
a.openInfo("Couldn't open terminal", []string{err.Error()})
return
}
a.tabs = append(a.tabs, tab)
a.activeTab = len(a.tabs) - 1
// A terminal has no file, so this clears the tree's highlight rather
// than leaving the previously active file looking selected.
a.syncActiveTreeFile()
a.flash("Terminal opened — Esc Esc for the menu")
}

// menuQuit exits the editor. When any tab has unsaved changes, opens the
// dirty-close modal so the user can pick Save (save all then quit),
// Discard (quit anyway), or Cancel. With no dirty tabs we exit straight
Expand Down Expand Up @@ -2396,6 +2528,12 @@ func (a *App) drawTabBar() {
name := tab.DisplayName()
glyph := icons.For(name, false, false)
gfg := icons.ColorFor(name, false, fg)
// Terminal tabs aren't files — give them the shell glyph
// instead of the generic "unknown file" one.
if tab.IsTerminal() {
glyph = icons.Terminal
gfg = fg
}
gst := tcell.StyleDefault.Background(bg).Foreground(gfg)
if active {
gst = gst.Bold(true)
Expand Down Expand Up @@ -2517,7 +2655,16 @@ func (a *App) drawStatusBar() {
if time.Now().Before(a.statusUntil) && a.statusMsg != "" {
left = " " + a.statusMsg
} else if tab := a.activeTabPtr(); tab != nil {
if tab.IsImage() && tab.Image != nil {
if tab.IsTerminal() {
// Terminals have no line/col to report. Show the shell's
// state instead, so an exited shell doesn't look like a
// frozen editor.
if exited, msg := tab.Term.Exited(); exited {
left = " terminal · " + msg
} else {
left = " terminal · shell running"
}
} else if tab.IsImage() && tab.Image != nil {
b := tab.Image.Bounds()
left = fmt.Sprintf(" %s · %d×%d · %s",
strings.ToUpper(tab.ImageFmt), b.Dx(), b.Dy(), filepath.Base(tab.Path))
Expand Down
Loading
Loading