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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ grut version # Print the version
| `--version`, `-v` | Print the version and exit |
| `--demo` | Launch with a demo project to explore grut |
| `--no-ai` | Disable AI features for this session |
| `--layout NAME` | Start with a layout for this launch (`explorer`, `git`, `review`, `agent`, `full`) |
| `--cpu-profile FILE` | Write CPU profile to FILE (dev/debug) |
| `--mem-profile FILE` | Write memory profile to FILE (dev/debug) |
| `--pprof PORT` | Start pprof server on localhost:PORT (dev/debug) |
Expand Down
48 changes: 40 additions & 8 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"path/filepath"
"runtime"
"runtime/pprof"
"sort"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -56,6 +57,7 @@ func buildRootCommand() (rootCmd *cobra.Command, cleanup func()) {
// memstatsDone is closed by cleanup to stop the background memstats
// goroutine, preventing a goroutine leak when --pprof is used.
memstatsDone := make(chan struct{})
var startupLayout string

rootCmd = &cobra.Command{
Use: "grut",
Expand Down Expand Up @@ -178,7 +180,10 @@ Environment:

// Create session manager and attempt to restore previous session.
sessMgr := session.NewManager()
preset := restoreSessionOrDefault(sessMgr, cfg, cwd, gc)
preset, err := restoreSessionOrDefault(sessMgr, cfg, cwd, gc, startupLayout)
if err != nil {
return err
}

// Create the layout engine
engine, err := layout.NewEngine(reg, preset)
Expand Down Expand Up @@ -348,6 +353,7 @@ Environment:
rootCmd.PersistentFlags().Bool("no-ai", false, "Disable AI features for this operation")
rootCmd.PersistentFlags().Bool("demo", false, "Launch with a demo project to explore grut")
rootCmd.PersistentFlags().Bool("reset-welcome", false, "Reset first-run state so the welcome screen shows on next launch")
rootCmd.PersistentFlags().StringVar(&startupLayout, "layout", "", "Startup layout override (explorer, git, review, agent, full)")

// Register subcommands via constructors (no init() side effects).
rootCmd.AddCommand(newVersionCmd())
Expand Down Expand Up @@ -396,12 +402,12 @@ Environment:
// restoreSessionOrDefault attempts to load a saved session for the given
// working directory and returns the first tab's preset. Falls back to
// ExplorerPreset when sessions are disabled or no saved session exists.
func restoreSessionOrDefault(mgr *session.Manager, cfg *config.Config, workDir string, _ git.GitClient) layout.Preset {
defaultPreset := func() layout.Preset { //nolint:gocritic // closure needed for deferred evaluation
// v1: always default to ExplorerPreset. The filetree panel
// already shows git status indicators when .git exists.
// GitPreset and other presets are preserved for v2 multi-tab.
return layout.ExplorerPreset()
func restoreSessionOrDefault(mgr *session.Manager, cfg *config.Config, workDir string, _ git.GitClient, layoutOverride string) (layout.Preset, error) {
defaultPreset := func() (layout.Preset, error) { //nolint:gocritic // closure needed for deferred evaluation
return startupPreset(cfg, layoutOverride)
}
if strings.TrimSpace(layoutOverride) != "" {
return defaultPreset()
}

if !cfg.Session.Enabled {
Expand All @@ -423,11 +429,37 @@ func restoreSessionOrDefault(mgr *session.Manager, cfg *config.Config, workDir s
presetName = state.Tabs[0].Name
}
if p, ok := layout.Presets()[presetName]; ok {
return p
return p, nil
}
return defaultPreset()
}

func startupPreset(cfg *config.Config, layoutOverride string) (layout.Preset, error) {
name := strings.TrimSpace(layoutOverride)
if name == "" && cfg != nil {
name = strings.TrimSpace(cfg.General.DefaultLayout)
}
if name == "" {
name = "explorer"
}
presets := layout.Presets()
preset, ok := presets[name]
if ok {
return preset, nil
}
return layout.Preset{}, fmt.Errorf("unknown layout %q (valid: %s)", name, strings.Join(validLayoutNames(), ", "))
}

func validLayoutNames() []string {
presets := layout.Presets()
names := make([]string, 0, len(presets))
for name := range presets {
names = append(names, name)
}
sort.Strings(names)
return names
}

// addRestoredTabs adds any additional tabs (beyond the first) from a
// saved session to the engine. The first tab was already used to
// create the engine, so we start from index 1.
Expand Down
56 changes: 54 additions & 2 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ func TestNoAIFlagInheritedBySubcommands(t *testing.T) {
}
}

func TestLayoutFlagRegistered(t *testing.T) {
root, cleanup := newRootCommand()
defer cleanup()
flag := root.PersistentFlags().Lookup("layout")
require.NotNil(t, flag, "--layout persistent flag must be registered")
assert.Equal(t, "string", flag.Value.Type())
assert.Equal(t, "", flag.DefValue)
}

func TestApplyNoAIFlag_WhenSet(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().Bool("no-ai", false, "")
Expand Down Expand Up @@ -289,7 +298,8 @@ func TestRestoreSessionOrDefault_SessionsDisabled(t *testing.T) {
cfg := &config.Config{}
cfg.Session.Enabled = false

preset := restoreSessionOrDefault(mgr, cfg, t.TempDir(), nil)
preset, err := restoreSessionOrDefault(mgr, cfg, t.TempDir(), nil, "")
require.NoError(t, err)
// When sessions are disabled, should return the default ExplorerPreset.
expected := layout.ExplorerPreset()
assert.Equal(t, expected.Name, preset.Name)
Expand All @@ -301,11 +311,53 @@ func TestRestoreSessionOrDefault_NoSavedSession(t *testing.T) {
cfg.Session.Enabled = true

// Use a temp dir where no session file exists.
preset := restoreSessionOrDefault(mgr, cfg, t.TempDir(), nil)
preset, err := restoreSessionOrDefault(mgr, cfg, t.TempDir(), nil, "")
require.NoError(t, err)
expected := layout.ExplorerPreset()
assert.Equal(t, expected.Name, preset.Name)
}

func TestRestoreSessionOrDefault_UsesConfiguredDefaultLayout(t *testing.T) {
mgr := session.NewManager()
mgr.SetDataDir(t.TempDir())
cfg := &config.Config{}
cfg.Session.Enabled = true
cfg.General.DefaultLayout = "git"

preset, err := restoreSessionOrDefault(mgr, cfg, t.TempDir(), nil, "")
require.NoError(t, err)
assert.Equal(t, layout.GitPreset().Name, preset.Name)
}

func TestRestoreSessionOrDefault_LayoutOverrideWinsOverSavedSession(t *testing.T) {
mgr := session.NewManager()
mgr.SetDataDir(t.TempDir())
workDir := t.TempDir()
cfg := &config.Config{}
cfg.Session.Enabled = true
cfg.General.DefaultLayout = "explorer"
require.NoError(t, mgr.Save(session.SessionState{
WorkDir: workDir,
Tabs: []session.TabState{
{Name: "git", Preset: "git", FocusedPanel: "filetree"},
},
}))

preset, err := restoreSessionOrDefault(mgr, cfg, workDir, nil, "review")
require.NoError(t, err)
assert.Equal(t, layout.ReviewPreset().Name, preset.Name)
}

func TestStartupPreset_InvalidLayoutReturnsError(t *testing.T) {
cfg := &config.Config{}
cfg.General.DefaultLayout = "missing"

_, err := startupPreset(cfg, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown layout")
assert.Contains(t, err.Error(), "explorer")
}

// ---------------------------------------------------------------------------
// addRestoredTabs — single-tab mode guard
// ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ All paths support tilde (`~`) expansion. Only set values you want to change —
```toml
[general]
keybinding_scheme = "default" # "default", "vim", "classic", or path to .toml file
default_layout = "explorer" # "explorer", "git", "review", "agent"
default_layout = "explorer" # "explorer", "git", "review", "agent", "full"
auto_save_session = true # Save/restore tab layout on exit/start
show_first_run_help = true # Show help overlay on first launch
```
Expand Down
2 changes: 1 addition & 1 deletion internal/config/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func Validate(cfg *Config) error {
errs = appendEnumOrPathErr(errs, "general.keybinding_scheme",
cfg.General.KeybindingScheme, "default", "classic", "vim")
errs = appendEnumErr(errs, "general.default_layout",
cfg.General.DefaultLayout, "explorer", "git", "review", "agent")
cfg.General.DefaultLayout, "explorer", "git", "review", "agent", "full")

// --- file_tree ---
errs = appendEnumErr(errs, "file_tree.icon_mode",
Expand Down
Loading