diff --git a/internal/app/app.go b/internal/app/app.go index 4683927..c1fe605 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -160,6 +160,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 @@ -209,7 +215,7 @@ func builtinMenuGroups() [][]menuItemDef { }, // View toggle { - {shortcut: "Esc t", action: (*App).menuToggleSidebar, enabled: alwaysTrue, labelFor: (*App).sidebarToggleLabel}, + {shortcut: "Esc t", action: (*App).menuToggleSidebar, enabled: alwaysTrue, labelFor: (*App).sidebarToggleLabel, visible: (*App).hasTree}, }, // Quit { @@ -257,6 +263,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 @@ -276,6 +302,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 @@ -494,6 +528,56 @@ 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() + // 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 +} + // 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 @@ -525,6 +609,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 @@ -534,6 +630,12 @@ func (a *App) loadSpiceConfig() { // 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) @@ -656,7 +758,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() @@ -2080,7 +2182,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") } @@ -2089,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 0175361..d4059d3 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -2218,6 +2218,132 @@ 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 +} + +// 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/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/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") + } +} 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)