Skip to content
Merged
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
116 changes: 113 additions & 3 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")
}

Expand All @@ -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
}

Expand Down
126 changes: 126 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
8 changes: 4 additions & 4 deletions internal/app/fileops.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand All @@ -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)))
Expand Down Expand Up @@ -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))
Expand Down
9 changes: 9 additions & 0 deletions internal/app/finder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions internal/app/finder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading
Loading