From 78a7ec1de0d28cd3961a4bca27a66d5481d39b76 Mon Sep 17 00:00:00 2001 From: Spicer Matthews Date: Fri, 15 May 2026 16:55:41 -0700 Subject: [PATCH 1/2] Add single-file mode for direct file invocations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When invoked as 'spiceedit somefile.md' the user is asking to look at one file, not to open a project. Skip the file-tree walk, the project-wide finder index, and the 10s background tree-refresh goroutine — none of them are visible to the user in that mode and on a large directory they're real CPU. main.go routes to a new app.NewSingleFile(path) constructor when an OpenFile is resolved; otherwise it stays on app.New(rootDir). The constructor leaves tree and finder nil, hides the sidebar, and opens the file as the first tab. The action menu's 'Show / Hide file explorer' row is filtered out via a new visibility predicate (hasTree) so the user can't try to show a sidebar that doesn't exist. The same machinery (visible func on menuItemDef) is general-purpose so future tree-dependent rows can opt in the same way. Tree-Refresh sites in fileops are routed through a small refreshTree() helper that no-ops when tree is nil. --- internal/app/app.go | 99 ++++++++++++++++++++++++++++++++++++++-- internal/app/app_test.go | 73 +++++++++++++++++++++++++++++ internal/app/fileops.go | 8 ++-- main.go | 20 ++++++-- 4 files changed, 188 insertions(+), 12 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index b37900e..ca61a1d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -159,6 +159,12 @@ type menuItemDef struct { action func(*App) enabled func(*App) bool labelFor func(*App) string + // visible, when non-nil, decides whether the item appears in the + // menu at all (returning false drops the row entirely — not the + // same as enabled, which renders the row greyed out). Used to + // hide the sidebar toggle in single-file mode, where there's no + // tree to show or hide. + visible func(*App) bool } // builtinMenuGroups returns the editor's built-in action groups in @@ -208,7 +214,7 @@ func builtinMenuGroups() [][]menuItemDef { }, // View toggle { - {action: (*App).menuToggleSidebar, enabled: alwaysTrue, labelFor: (*App).sidebarToggleLabel}, + {action: (*App).menuToggleSidebar, enabled: alwaysTrue, labelFor: (*App).sidebarToggleLabel, visible: (*App).hasTree}, }, // Quit { @@ -256,6 +262,26 @@ func (a *App) menuLayout() (items []menuItemDef, dividers []int, modalHeight int groups = append(groups[:len(groups)-1], ca, quit) } + // Drop items whose visibility predicate (if any) says they don't + // belong here right now — e.g. single-file mode hides the sidebar + // toggle because there's no tree to toggle. A group emptied by + // filtering vanishes too, so we don't leave a hanging divider + // between two surviving groups. + visibleGroups := make([][]menuItemDef, 0, len(groups)) + for _, g := range groups { + kept := make([]menuItemDef, 0, len(g)) + for _, it := range g { + if it.visible != nil && !it.visible(a) { + continue + } + kept = append(kept, it) + } + if len(kept) > 0 { + visibleGroups = append(visibleGroups, kept) + } + } + groups = visibleGroups + // Title at relY 1, divider under it at relY 2, first item at relY 3. dividers = []int{2} y := 3 @@ -275,6 +301,14 @@ func (a *App) menuLayout() (items []menuItemDef, dividers []int, modalHeight int return items, dividers, modalHeight } +// hasTree is the menu visibility predicate for tree-dependent rows. +// True when the file tree was built at startup; false in single-file +// mode, where we deliberately skipped tree construction to avoid +// indexing the working directory. +func (a *App) hasTree() bool { + return a.tree != nil +} + // App is the editor's top-level state holder and event-loop owner. type App struct { screen tcell.Screen @@ -492,6 +526,53 @@ func New(rootDir string) (*App, error) { return a, nil } +// NewSingleFile is the lean alternative to New for the "spiceedit +// somefile.md" invocation: no file tree, no project finder index, +// no background tree-refresh goroutine, sidebar hidden. The user +// asked for one file — we don't pay the cost of walking and watching +// the surrounding directory tree just to render a file they wanted +// to look at in isolation. The tree-toggle row in the action menu +// is filtered out via the hasTree visibility predicate so the user +// can't accidentally try to show a sidebar that doesn't exist. +// +// rootDir is still recorded (set to the file's parent) so file-level +// actions that need a base directory — Save As, New File, the +// relative/absolute path helpers — have somewhere to anchor. +func NewSingleFile(filePath string) (*App, error) { + scr, err := tcell.NewScreen() + if err != nil { + return nil, err + } + if err := scr.Init(); err != nil { + return nil, err + } + scr.EnableMouse(tcell.MouseButtonEvents | tcell.MouseDragEvents | tcell.MouseMotionEvents) + + th := theme.Default() + scr.SetStyle(tcell.StyleDefault.Background(th.BG).Foreground(th.Text)) + scr.Clear() + + rootDir := filepath.Dir(filePath) + if rootDir == "" { + rootDir = "." + } + + a := &App{ + screen: scr, + theme: th, + rootDir: rootDir, + tree: nil, + hoveredMenuRow: -1, + sidebarShown: false, + sidebarWidth: defaultSidebarWidth, + } + a.setActiveFolder(rootDir) + a.loadSpiceConfig() + a.loadCustomActions() + a.openFile(filePath) + return a, nil +} + // loadCustomActions reads the user's actions.json (if any) and stores // the parsed list on the App. Failures are surfaced as a status flash // so a typo in the config file isn't silently swallowed, but they @@ -523,6 +604,18 @@ func (a *App) loadSpiceConfig() { } } +// refreshTree calls tree.Refresh when the file tree exists, and is a +// no-op in single-file mode. File operations (create / rename / delete) +// call this after touching the disk so callers don't have to nil-check +// every site. The git-status and finder refreshes that usually +// accompany it already guard themselves internally. +func (a *App) refreshTree() { + if a.tree == nil { + return + } + a.tree.Refresh() +} + // refreshGitStatus re-runs `git status --porcelain` against the project // root and stamps the resulting dirty-paths sets onto the file tree, so // changed files render in the Modified color on the next draw. It's @@ -641,7 +734,7 @@ func (a *App) handleEvent(ev tcell.Event) { // path so a Copy-from-remote action's output is visible immediately // instead of after the next tick. func (a *App) refreshTreeNow() { - a.tree.Refresh() + a.refreshTree() a.reconcileOpenTabsWithDisk() a.refreshGitStatus() a.invalidateFinder() @@ -1998,7 +2091,7 @@ func (a *App) menuToggleLineComment() { // requires uncommenting one line. func (a *App) menuRefreshTree() { a.closeMenu() - a.tree.Refresh() + a.refreshTree() a.flash("File tree refreshed") } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 4a856b3..7e74392 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -2067,6 +2067,79 @@ func TestDrawTabBar_RendersIconWhenEnabled(t *testing.T) { } } +// TestHasTree_TrueAndFalse pins the visibility predicate that drives +// the single-file-mode menu filter: any app with a non-nil tree +// reports true; setting tree to nil flips it false. +func TestHasTree_TrueAndFalse(t *testing.T) { + a := newTestApp(t, t.TempDir()) + if !a.hasTree() { + t.Fatal("expected hasTree=true on a normal-mode app") + } + a.tree = nil + if a.hasTree() { + t.Fatal("expected hasTree=false when tree is nil") + } +} + +// TestMenuLayout_HidesSidebarToggleInSingleFileMode is the contract +// test for the single-file-mode feature: with no file tree, the +// 'Show / Hide file explorer' row must not appear in the action +// menu — it's nonsensical there because the sidebar isn't built. +// With a tree present (normal mode), the row appears. +func TestMenuLayout_HidesSidebarToggleInSingleFileMode(t *testing.T) { + a := newTestApp(t, t.TempDir()) + + // Sanity: the toggle row IS present in normal mode. + items, _, _ := a.menuLayout() + if !containsSidebarToggle(items, a) { + t.Fatal("expected sidebar-toggle row in normal mode") + } + + // Simulate single-file mode by clearing the tree. + a.tree = nil + items, _, _ = a.menuLayout() + if containsSidebarToggle(items, a) { + t.Fatal("expected sidebar-toggle row to be absent when tree is nil") + } +} + +// TestMenuLayout_NoEmptyDividerAfterFiltering guards against the +// regression where filtering the sole item of a group out would +// leave a dangling divider row, doubling the gap in the menu. +func TestMenuLayout_NoEmptyDividerAfterFiltering(t *testing.T) { + a := newTestApp(t, t.TempDir()) + a.tree = nil // collapses the View-toggle group to empty + + _, dividers, height := a.menuLayout() + // Dividers must all sit strictly below the title divider (row 2) + // and strictly above the modal's bottom border (height-1). Two + // adjacent dividers (gap == 1) would mean we kept a divider for + // a now-empty group. + for i := 1; i < len(dividers); i++ { + if dividers[i]-dividers[i-1] < 2 { + t.Fatalf("dividers too close: %v (height=%d)", dividers, height) + } + } +} + +// containsSidebarToggle is the menu-test helper that locates the +// dynamic-label row whose label flips between "Show file explorer" +// and "Hide file explorer". We match on labelFor's resolved string +// because the sidebar-toggle row is the only one that uses those +// exact labels. +func containsSidebarToggle(items []menuItemDef, a *App) bool { + for _, it := range items { + if it.labelFor == nil { + continue + } + l := it.labelFor(a) + if l == "Show file explorer" || l == "Hide file explorer" { + return true + } + } + return false +} + // TestDrawTabBar_NoIconWhenDisabled is the inverse of the above — // flipping IconsEnabled off must remove the glyph from the tab bar // (so terminals without a Nerd Font don't see tofu boxes in tabs). diff --git a/internal/app/fileops.go b/internal/app/fileops.go index 506a194..f3a4a0e 100644 --- a/internal/app/fileops.go +++ b/internal/app/fileops.go @@ -121,7 +121,7 @@ func (a *App) doCreateFile(parent, name string) { a.flash(fmt.Sprintf("Create failed: %v", err)) return } - a.tree.Refresh() + a.refreshTree() a.refreshGitStatus() a.invalidateFinder() a.openFile(target) @@ -157,7 +157,7 @@ func (a *App) doRenameFile(oldPath, newName string) { t.DiskGone = false } } - a.tree.Refresh() + a.refreshTree() a.refreshGitStatus() a.invalidateFinder() a.flash(fmt.Sprintf("Renamed to %s", newName)) @@ -182,7 +182,7 @@ func (a *App) doDeletePath(path string) { a.closeTab(i) } } - a.tree.Refresh() + a.refreshTree() a.refreshGitStatus() a.invalidateFinder() a.flash(fmt.Sprintf("Deleted %s", filepath.Base(path))) @@ -357,7 +357,7 @@ func (a *App) doRenameFolder(oldPath, newName string) { a.setActiveFolder(filepath.Join(newPath, a.activeFolder[len(prefix):])) } } - a.tree.Refresh() + a.refreshTree() a.refreshGitStatus() a.invalidateFinder() a.flash(fmt.Sprintf("Renamed to %s", newName)) diff --git a/main.go b/main.go index edf7181..aca8cdf 100644 --- a/main.go +++ b/main.go @@ -131,17 +131,27 @@ func main() { return } - a, err := app.New(res.RootDir) + // Single-file mode: when the user invoked `spiceedit somefile.md`, + // skip building the file tree and project file index entirely. + // They asked for one file — don't pay the CPU to walk the + // surrounding directory just so we can render a sidebar they + // didn't ask for. The action-menu sidebar toggle is filtered out + // in this mode too; see (*App).hasTree. + var ( + a *app.App + err error + ) + if res.OpenFile != "" { + a, err = app.NewSingleFile(res.OpenFile) + } else { + a, err = app.New(res.RootDir) + } if err != nil { fmt.Fprintln(os.Stderr, "spiceedit: failed to start:", err) os.Exit(1) } defer a.Close() - if res.OpenFile != "" { - a.OpenFile(res.OpenFile) - } - if err := a.Run(); err != nil { fmt.Fprintln(os.Stderr, "spiceedit:", err) os.Exit(1) From 54cf776cc4e367514c2f45f7646404e31d11f078 Mon Sep 17 00:00:00 2001 From: Spicer Matthews Date: Wed, 22 Jul 2026 17:06:18 -0700 Subject: [PATCH 2/2] fix: harden single-file mode against nil-tree panics and stale gutter Fresh-eyes review of the merged single-file-mode feature surfaced three issues where post-merge code assumes a file tree that single-file mode deliberately omits: - Esc-t (menuToggleSidebar) bypassed the menu's hasTree gate and flipped sidebarShown true, sending draw() into a.tree.Render on a nil tree and panicking. Now a no-op with a flash. - Esc-p (openFinder) similarly bypassed the gate and popped an always-empty finder modal. Now a no-op with a flash. - Saving in single-file mode routes through refreshGitStatus, which bailed on nil tree and left the git gutter markers stale. It now refreshes the per-line markers (a file-scoped git diff) even without a tree. Adds regression tests for all three. --- internal/app/app.go | 17 ++++++++++++ internal/app/app_test.go | 53 +++++++++++++++++++++++++++++++++++++ internal/app/finder.go | 9 +++++++ internal/app/finder_test.go | 20 ++++++++++++++ 4 files changed, 99 insertions(+) diff --git a/internal/app/app.go b/internal/app/app.go index 3b75b7e..c1fe605 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -571,6 +571,9 @@ func NewSingleFile(filePath string) (*App, error) { a.setActiveFolder(rootDir) a.loadSpiceConfig() a.loadCustomActions() + // openFile loads the file's git gutter markers itself (a file-scoped + // `git diff`), so single-file mode shows change bars on open without + // the whole-repo status or tree walk that New performs. a.openFile(filePath) return a, nil } @@ -627,6 +630,12 @@ func (a *App) refreshTree() { // maps empty, which the renderer treats as "everything clean". func (a *App) refreshGitStatus() { if a.tree == nil { + // Single-file mode has no tree to stamp dirty-path sets onto, + // but the open tab can still show per-line gutter markers — + // those come from a single `git diff` on the file itself and + // don't need the (deliberately skipped) directory walk. Skip + // the whole-repo `git status` and just refresh the gutter. + a.refreshGitLineChanges() return } st := loadGitStatus(a.rootDir) @@ -2182,6 +2191,14 @@ func (a *App) menuRefreshTree() { // snap back when it returns. func (a *App) menuToggleSidebar() { a.closeMenu() + // Single-file mode has no file tree, so there's nothing to show or + // hide. The menu row is hidden (hasTree), but the Esc-t leader reaches + // here directly — guard it so the toggle can't flip sidebarShown true + // and send draw() into a.tree.Render on a nil tree. + if a.tree == nil { + a.flash("No file explorer in single-file mode") + return + } a.sidebarShown = !a.sidebarShown } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index ce5b96c..d4059d3 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -2291,6 +2291,59 @@ func containsSidebarToggle(items []menuItemDef, a *App) bool { return false } +// TestMenuToggleSidebar_NoPanicInSingleFileMode is a regression guard for +// a crash: the Esc-t leader calls menuToggleSidebar directly, bypassing the +// menu row's hasTree gate. In single-file mode (tree == nil) flipping +// sidebarShown true would send draw() into a.tree.Render on a nil tree and +// panic. The toggle must stay a no-op so the sidebar can't be shown when +// there's no tree behind it. +func TestMenuToggleSidebar_NoPanicInSingleFileMode(t *testing.T) { + a := newTestApp(t, t.TempDir()) + a.tree = nil // single-file mode + a.sidebarShown = false + + a.menuToggleSidebar() // simulates the Esc-t leader + + if a.sidebarShown { + t.Fatal("sidebar must stay hidden in single-file mode — no tree to render") + } + a.draw() // would panic on nil a.tree.Render if the toggle flipped it on +} + +// TestRefreshGitStatus_RefreshesGutterInSingleFileMode pins the +// single-file-mode fix: with no file tree, refreshGitStatus must still +// reload the open tab's per-line gutter markers (a file-scoped git diff +// that doesn't need the tree). Without this, saving a file in +// single-file mode — which routes through refreshGitStatus — would +// leave the gutter markers frozen at their open-time state. +func TestRefreshGitStatus_RefreshesGutterInSingleFileMode(t *testing.T) { + requireGit(t) + repo := initRepo(t) + target := filepath.Join(repo, "f.go") + writeFileT(t, target, "package main\n\nfunc main() {}\n") + gitRun(t, repo, "add", "f.go") + gitRun(t, repo, "commit", "-m", "init") + + a := newTestApp(t, repo) + a.tree = nil // simulate single-file mode + a.openFile(target) + tab := a.activeTabPtr() + if tab == nil { + t.Fatal("expected an open tab") + } + + // Clean file → no markers yet. Now dirty the worktree and clear the + // tab's cached markers so we can prove refreshGitStatus repopulates + // them despite tree == nil. + writeFileT(t, target, "package main\n\nfunc main() { println(1) }\n") + tab.GitLines = nil + a.refreshGitStatus() + + if len(tab.GitLines) == 0 { + t.Fatal("expected gutter markers to be refreshed in single-file mode, got none") + } +} + // TestDrawTabBar_NoIconWhenDisabled is the inverse of the above — // flipping IconsEnabled off must remove the glyph from the tab bar // (so terminals without a Nerd Font don't see tofu boxes in tabs). diff --git a/internal/app/finder.go b/internal/app/finder.go index 8322823..f344c0b 100644 --- a/internal/app/finder.go +++ b/internal/app/finder.go @@ -56,6 +56,15 @@ func (e *finderRebuiltEvent) When() time.Time { return e.when } // (Building when the index is already StateReady is a no-op // inside the orchestrator's coalesce gate.) func (a *App) openFinder() { + // Single-file mode has no project index — the menu row is already + // hidden (hasTree), but the Esc-p leader reaches here directly, so + // guard it too rather than pop an always-empty modal. tree == nil is + // the single-file signal (see NewSingleFile); the finder is a + // project-scoped feature that's omitted alongside the tree. + if a.tree == nil { + a.flash("Find file isn't available in single-file mode") + return + } a.closeAllModals() a.finderOpen = true a.finderQuery = nil diff --git a/internal/app/finder_test.go b/internal/app/finder_test.go index 7e7d4ee..6f9d1a7 100644 --- a/internal/app/finder_test.go +++ b/internal/app/finder_test.go @@ -328,3 +328,23 @@ func endsWith(s, suffix string) bool { } return s[len(s)-len(suffix):] == suffix } + +// TestOpenFinder_NoOpInSingleFileMode pins the single-file-mode guard: the +// Esc-p leader reaches openFinder directly (unlike the menu row, which is +// gated by hasTree), so in single-file mode (tree == nil) openFinder must +// not pop an always-empty modal — it flashes an explanation and leaves +// finderOpen false instead. +func TestOpenFinder_NoOpInSingleFileMode(t *testing.T) { + a := newTestApp(t, t.TempDir()) + a.tree = nil // single-file mode: no project tree... + a.finder = nil // ...and no project index + + a.openFinder() + + if a.finderOpen { + t.Fatal("openFinder should not open the modal when finder is nil") + } + if a.statusMsg == "" { + t.Fatal("expected a flash explaining the finder is unavailable") + } +}