diff --git a/README.md b/README.md index 363377a..99e2c25 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/cmd/root.go b/cmd/root.go index 9040d58..07d66ce 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,7 @@ import ( "path/filepath" "runtime" "runtime/pprof" + "sort" "strconv" "strings" "time" @@ -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", @@ -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) @@ -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()) @@ -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 { @@ -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. diff --git a/cmd/root_test.go b/cmd/root_test.go index 1fd22ae..84fcdce 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -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, "") @@ -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) @@ -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 // --------------------------------------------------------------------------- diff --git a/docs/configuration.md b/docs/configuration.md index 68dd05c..a7ced2c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 ``` diff --git a/internal/config/validate.go b/internal/config/validate.go index abd6d38..850b01b 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -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",