From 78a7ec1de0d28cd3961a4bca27a66d5481d39b76 Mon Sep 17 00:00:00 2001 From: Spicer Matthews Date: Fri, 15 May 2026 16:55:41 -0700 Subject: [PATCH] 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)