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

Expand Down
73 changes: 73 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
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
20 changes: 15 additions & 5 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading