diff --git a/cmd/root.go b/cmd/root.go index 9040d58..f73ae34 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -58,8 +58,9 @@ func buildRootCommand() (rootCmd *cobra.Command, cleanup func()) { memstatsDone := make(chan struct{}) rootCmd = &cobra.Command{ - Use: "grut", + Use: "grut [path]", Short: "AI-native terminal file explorer, git client, and agent orchestrator", + Args: cobra.MaximumNArgs(1), Long: `grut is an AI-native terminal file explorer, git client, and agent orchestrator built for developers who work alongside AI coding agents. @@ -97,6 +98,25 @@ Environment: fmt.Fprintf(os.Stderr, "Demo project created at %s\n", dir) } + // Handle a positional file or directory argument. A directory roots + // grut there; a file (optionally suffixed with ":line") roots grut at + // the file's directory, selects the file, and scrolls to the line. + // A path that does not exist is ignored, so grut opens in the current + // directory as it did before path arguments were supported. Resolved + // before stderr is redirected so a chdir error reaches the console. + var initialFile string + var initialLine int + if len(args) > 0 { + target := resolveStartupTarget(args[0], os.Stat) + if target.chdir != "" { + if err := os.Chdir(target.chdir); err != nil { + return fmt.Errorf("chdir to %s: %w", target.chdir, err) + } + } + initialFile = target.file + initialLine = target.line + } + // Capture original stderr BEFORE redirection so error messages // (and the update notification) still reach the real console. origStderr := captureOriginalStderr() @@ -235,7 +255,8 @@ Environment: WithUndoManager(undoMgr). WithGitClient(gitClient). WithConfig(cfg). - WithSessionManager(sessMgr) + WithSessionManager(sessMgr). + WithInitialFile(initialFile, initialLine) if chatModel != nil { model = model.WithChat(chatModel) diff --git a/cmd/root_test.go b/cmd/root_test.go index 1fd22ae..39cbedd 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -275,9 +275,12 @@ func TestMemProfile_WritesFile(t *testing.T) { func TestNewRootCommand_UseAndShort(t *testing.T) { cmd, cleanup := newRootCommand() defer cleanup() - assert.Equal(t, "grut", cmd.Use) + assert.Equal(t, "grut [path]", cmd.Use) assert.NotEmpty(t, cmd.Short) assert.NotEmpty(t, cmd.Long) + // The root command accepts at most one positional path argument. + assert.NoError(t, cmd.Args(cmd, []string{"somefile.go"})) + assert.Error(t, cmd.Args(cmd, []string{"a", "b"})) } // --------------------------------------------------------------------------- diff --git a/cmd/startup_target.go b/cmd/startup_target.go new file mode 100644 index 0000000..5051eaf --- /dev/null +++ b/cmd/startup_target.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "os" + "path/filepath" + "strconv" + "strings" +) + +// startupTarget describes how a positional command-line argument to grut is +// interpreted: an optional directory to change into, an optional file to open +// in the preview panel, and an optional 1-based line to scroll that file to. +type startupTarget struct { + chdir string // directory to change into before starting (empty = stay in cwd) + file string // absolute path of the file to preview (empty = none) + line int // 1-based line to scroll the preview to (0 = none) +} + +// resolveStartupTarget interprets a positional CLI argument as a directory, a +// file, or a "file:line" reference. statFn is injected so the logic can be unit +// tested without touching the filesystem; callers pass os.Stat. +// +// Resolution order: +// 1. If arg names an existing path: a directory roots grut there; a file roots +// grut at the file's directory and selects the file. +// 2. Otherwise, if arg ends in ":" and the text before the last +// colon names an existing file, that file is opened at the given line. +// 3. Otherwise the argument is left unresolved (empty target): the caller keeps +// the current directory, matching grut's behavior before file arguments were +// supported. +// +// Returned paths are absolute so a later os.Chdir does not invalidate them. +func resolveStartupTarget(arg string, statFn func(string) (os.FileInfo, error)) startupTarget { + if arg == "" { + return startupTarget{} + } + if t, ok := targetForExistingPath(arg, statFn); ok { + return t + } + if t, ok := targetForFileLine(arg, statFn); ok { + return t + } + return startupTarget{} +} + +// targetForExistingPath resolves arg when it names a path that exists on disk. +func targetForExistingPath(arg string, statFn func(string) (os.FileInfo, error)) (startupTarget, bool) { + info, err := statFn(arg) + if err != nil { + return startupTarget{}, false + } + abs, err := filepath.Abs(arg) + if err != nil { + return startupTarget{}, false + } + if info.IsDir() { + return startupTarget{chdir: abs}, true + } + return startupTarget{chdir: filepath.Dir(abs), file: abs}, true +} + +// targetForFileLine resolves arg of the form "file:line" where file exists and +// line is a positive integer. The last colon is used as the separator so that +// Windows drive letters (for example C:\path) are not mistaken for a suffix. +func targetForFileLine(arg string, statFn func(string) (os.FileInfo, error)) (startupTarget, bool) { + idx := strings.LastIndex(arg, ":") + if idx <= 0 || idx == len(arg)-1 { + return startupTarget{}, false + } + line, err := strconv.Atoi(arg[idx+1:]) + if err != nil || line <= 0 { + return startupTarget{}, false + } + base := arg[:idx] + info, err := statFn(base) + if err != nil || info.IsDir() { + return startupTarget{}, false + } + abs, err := filepath.Abs(base) + if err != nil { + return startupTarget{}, false + } + return startupTarget{chdir: filepath.Dir(abs), file: abs, line: line}, true +} diff --git a/cmd/startup_target_test.go b/cmd/startup_target_test.go new file mode 100644 index 0000000..46bc6cb --- /dev/null +++ b/cmd/startup_target_test.go @@ -0,0 +1,131 @@ +package cmd + +import ( + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// fakeFileInfo is a minimal os.FileInfo used to stub statFn in tests. +type fakeFileInfo struct { + name string + isDir bool +} + +func (f fakeFileInfo) Name() string { return f.name } +func (f fakeFileInfo) Size() int64 { return 0 } + +func (f fakeFileInfo) Mode() os.FileMode { + if f.isDir { + return os.ModeDir + } + return 0 +} + +func (f fakeFileInfo) ModTime() time.Time { return time.Time{} } +func (f fakeFileInfo) IsDir() bool { return f.isDir } +func (f fakeFileInfo) Sys() any { return nil } + +// statStub returns a statFn that reports the paths in existing (mapped to a +// directory flag) as present and everything else as not found. +func statStub(existing map[string]bool) func(string) (os.FileInfo, error) { + return func(name string) (os.FileInfo, error) { + isDir, ok := existing[name] + if !ok { + return nil, os.ErrNotExist + } + return fakeFileInfo{name: filepath.Base(name), isDir: isDir}, nil + } +} + +func TestResolveStartupTarget(t *testing.T) { + fileRel := filepath.Join("pkg", "file.go") + fileAbs, err := filepath.Abs(fileRel) + assert.NoError(t, err) + dirRel := "mydir" + dirAbs, err := filepath.Abs(dirRel) + assert.NoError(t, err) + + tests := []struct { + name string + arg string + existing map[string]bool + want startupTarget + }{ + { + name: "existing directory roots there", + arg: dirRel, + existing: map[string]bool{dirRel: true}, + want: startupTarget{chdir: dirAbs}, + }, + { + name: "existing file selects file at its directory", + arg: fileRel, + existing: map[string]bool{fileRel: false}, + want: startupTarget{chdir: filepath.Dir(fileAbs), file: fileAbs}, + }, + { + name: "file with line suffix scrolls to line", + arg: fileRel + ":42", + existing: map[string]bool{fileRel: false}, + want: startupTarget{chdir: filepath.Dir(fileAbs), file: fileAbs, line: 42}, + }, + { + name: "line suffix on a missing file is ignored", + arg: fileRel + ":42", + existing: map[string]bool{}, + want: startupTarget{}, + }, + { + name: "nonexistent path is ignored", + arg: "nope", + existing: map[string]bool{}, + want: startupTarget{}, + }, + { + name: "zero line is not treated as a jump", + arg: fileRel + ":0", + existing: map[string]bool{fileRel: false}, + want: startupTarget{}, + }, + { + name: "directory with a line suffix is ignored", + arg: dirRel + ":10", + existing: map[string]bool{dirRel: true}, + want: startupTarget{}, + }, + { + name: "empty arg yields empty target", + arg: "", + existing: map[string]bool{}, + want: startupTarget{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveStartupTarget(tt.arg, statStub(tt.existing)) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestResolveStartupTargetWindowsDriveColon(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("drive-letter paths are Windows-specific") + } + fileAbs := `C:\repo\file.go` + existing := map[string]bool{fileAbs: false} + + // An existing drive path resolves via the exists check, so the drive colon + // is never treated as a line separator. + got := resolveStartupTarget(fileAbs, statStub(existing)) + assert.Equal(t, startupTarget{chdir: `C:\repo`, file: fileAbs}, got) + + // A drive path with a trailing line suffix splits on the last colon only. + got = resolveStartupTarget(fileAbs+":88", statStub(existing)) + assert.Equal(t, startupTarget{chdir: `C:\repo`, file: fileAbs, line: 88}, got) +} diff --git a/internal/panels/filetree/filetree.go b/internal/panels/filetree/filetree.go index 21412ec..0f0c445 100644 --- a/internal/panels/filetree/filetree.go +++ b/internal/panels/filetree/filetree.go @@ -452,7 +452,7 @@ func (ft *FileTree) Update(msg tea.Msg) (panels.Panel, tea.Cmd) { return ft, tea.Batch(cmds...) case panels.RevealFileMsg: ft.revealFile(msg.Path) - return ft, ft.emitCursorFileSelected() + return ft, ft.emitCursorFileSelectedAtLine(msg.Line) case panels.CommitSelectedMsg: return ft.handleCommitSelected(msg) case panels.CommitDeselectedMsg: @@ -1151,6 +1151,13 @@ func (ft *FileTree) moveCursorUp() { // In branch-files mode, it emits ShowDiffMsg with ref comparison context so // the diff panel shows the branch comparison instead of working tree diff. func (ft *FileTree) emitCursorFileSelected() tea.Cmd { + return ft.emitCursorFileSelectedAtLine(0) +} + +// emitCursorFileSelectedAtLine emits a FileSelectedMsg for the file under the +// cursor, carrying an optional 1-based line for the preview to scroll to. +// A line of 0 leaves the preview at the top, matching normal selection. +func (ft *FileTree) emitCursorFileSelectedAtLine(line int) tea.Cmd { if ft.viewport.cursor < 0 || ft.viewport.cursor >= len(ft.visible) { return nil } @@ -1195,7 +1202,7 @@ func (ft *FileTree) emitCursorFileSelected() tea.Cmd { relPath = filepath.ToSlash(relPath) baseRef := ft.filter.branchBaseRef return tea.Batch( - func() tea.Msg { return panels.FileSelectedMsg{Path: path, DiffContext: dc} }, + func() tea.Msg { return panels.FileSelectedMsg{Path: path, DiffContext: dc, Line: line} }, func() tea.Msg { return panels.ShowDiffMsg{ Path: relPath, @@ -1206,7 +1213,7 @@ func (ft *FileTree) emitCursorFileSelected() tea.Cmd { }, ) } - return func() tea.Msg { return panels.FileSelectedMsg{Path: path, DiffContext: dc} } + return func() tea.Msg { return panels.FileSelectedMsg{Path: path, DiffContext: dc, Line: line} } } func (ft *FileTree) goToTop() { diff --git a/internal/panels/filetree/filetree_test.go b/internal/panels/filetree/filetree_test.go index 41d4e42..5e9b57f 100644 --- a/internal/panels/filetree/filetree_test.go +++ b/internal/panels/filetree/filetree_test.go @@ -1789,6 +1789,41 @@ func TestFileSelectedMsg_EmittedFromFile(t *testing.T) { assert.True(t, ok, "expected FileSelectedMsg for file, got %T", msg) } +func TestEmitCursorFileSelectedAtLine_SetsLine(t *testing.T) { + dir := createTestTree(t) + ft := newTestFT(t, defaultCfg(), dir) + + fileIdx := -1 + for i, n := range ft.visible { + if !n.isDir { + fileIdx = i + break + } + } + require.GreaterOrEqual(t, fileIdx, 0, "should have at least one file in visible") + + ft.viewport.cursor = fileIdx + cmd := ft.emitCursorFileSelectedAtLine(42) + require.NotNil(t, cmd) + + fsm, ok := cmd().(panels.FileSelectedMsg) + require.True(t, ok, "expected FileSelectedMsg, got %T", cmd()) + assert.Equal(t, 42, fsm.Line, "line should be threaded onto FileSelectedMsg") +} + +func TestRevealFileMsg_ThreadsLine(t *testing.T) { + dir := createTestTree(t) + ft := newTestFT(t, defaultCfg(), dir) + + _, cmd := ft.Update(panels.RevealFileMsg{Path: filepath.Join(dir, "main.go"), Line: 7}) + require.NotNil(t, cmd, "revealing a file should produce a command") + + fsm, ok := cmd().(panels.FileSelectedMsg) + require.True(t, ok, "expected FileSelectedMsg, got %T", cmd()) + assert.Equal(t, filepath.Join(dir, "main.go"), fsm.Path) + assert.Equal(t, 7, fsm.Line, "reveal should carry the requested line") +} + // --------------------------------------------------------------------------- // Additional coverage: Mouse click handling // --------------------------------------------------------------------------- diff --git a/internal/panels/messages.go b/internal/panels/messages.go index c7c7809..ad834ed 100644 --- a/internal/panels/messages.go +++ b/internal/panels/messages.go @@ -49,6 +49,7 @@ type DiffContext struct { type FileSelectedMsg struct { Path string DiffContext *DiffContext // nil = working tree diff (backward compatible) + Line int // 1-based line to scroll the preview to (0 = none) } // RevealFileMsg requests the filetree to expand parent directories and @@ -56,6 +57,7 @@ type FileSelectedMsg struct { // when the user selects a file, so the file tree highlights it. type RevealFileMsg struct { Path string + Line int // 1-based line to scroll the preview to after selecting (0 = none) } // ToggleFuzzyFinderMsg is sent to show or hide the fuzzy finder overlay. diff --git a/internal/panels/preview/preview.go b/internal/panels/preview/preview.go index 9ef8dd3..9ae88e6 100644 --- a/internal/panels/preview/preview.go +++ b/internal/panels/preview/preview.go @@ -64,6 +64,12 @@ type Preview struct { // as a line-number entry and scrolls to the entered line on Enter. gotoLineActive bool gotoLineInput string + // pendingGotoLine holds a 1-based line to scroll to once the current + // file finishes loading. Set from FileSelectedMsg.Line (e.g. the CLI + // opening "file.go:42") and applied in the fileLoadedMsg handler, since + // the file content is not available synchronously when the file is + // selected. Zero means no pending jump. + pendingGotoLine int // GitHub content mode – when a GitHub item (issue/PR/action run) is // selected, the preview shows the item detail instead of a file. ghMode bool // true when showing GitHub content instead of file @@ -263,6 +269,7 @@ func (p *Preview) Update(msg tea.Msg) (panels.Panel, tea.Cmd) { p.blameMode = false p.blameLines = nil p.diffLines = nil + p.pendingGotoLine = msg.Line cmds := []tea.Cmd{p.loadFileCmd(msg.Path)} if p.gitClient != nil { cmds = append(cmds, p.loadContextDiffCmd(msg.Path, p.diffContext)) @@ -276,6 +283,13 @@ func (p *Preview) Update(msg tea.Msg) (panels.Panel, tea.Cmd) { p.err = msg.err p.isBinary = msg.isBinary p.isLarge = msg.isLarge + // Apply a pending line jump requested before the content was + // available (e.g. the CLI opening "file.go:42"). gotoLine clamps + // and no-ops on binary/large files where line counts are 0. + if p.pendingGotoLine > 0 { + p.gotoLine(p.pendingGotoLine) + p.pendingGotoLine = 0 + } } return p, nil case diffLoadedMsg: diff --git a/internal/panels/preview/preview_test.go b/internal/panels/preview/preview_test.go index e2b4040..6d0445c 100644 --- a/internal/panels/preview/preview_test.go +++ b/internal/panels/preview/preview_test.go @@ -1701,3 +1701,45 @@ func TestActionRunSelectedMsg_ANSIInjection(t *testing.T) { assert.NotContains(t, p.ghTitle, "\x1b", "ANSI in workflow name should be stripped") assert.Contains(t, p.ghTitle, "CI ") } + +func TestFileSelectedLineScrollsPreviewOnLoad(t *testing.T) { + dir := t.TempDir() + lines := make([]string, 100) + for i := range lines { + lines[i] = strings.Repeat("x", 20) + } + path := writeFile(t, dir, "long.txt", strings.Join(lines, "\n")) + + cfg := defaultCfg() + cfg.SyntaxHighlighting = false + p := New(cfg, defaultEditorCfg(), nil) + p.SetSize(60, 20) + + // Select the file with a target line, then drive the async load to + // completion, mirroring the Bubble Tea runtime loop. + _, cmd := p.Update(panels.FileSelectedMsg{Path: path, Line: 60}) + require.NotNil(t, cmd) + msg := cmd() + require.NotNil(t, msg) + p.Update(msg) + + assert.Positive(t, p.scrollY, "preview should scroll to the requested line once loaded") + assert.Zero(t, p.pendingGotoLine, "pending line resets after it is applied") +} + +func TestFileSelectedWithoutLineStaysAtTop(t *testing.T) { + dir := t.TempDir() + lines := make([]string, 100) + for i := range lines { + lines[i] = strings.Repeat("x", 20) + } + path := writeFile(t, dir, "long.txt", strings.Join(lines, "\n")) + + cfg := defaultCfg() + cfg.SyntaxHighlighting = false + p := New(cfg, defaultEditorCfg(), nil) + p.SetSize(60, 20) + + loadFile(t, p, path) + assert.Zero(t, p.scrollY, "selecting without a line should leave the preview at the top") +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 510b121..e503471 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -68,10 +68,12 @@ type Model struct { pendingDiscardPath string // file path for pending discard confirmation currentBranch string // cached git branch name for status bar cwdEditValue string // editable path text + initialFile string // file to open + focus in preview at startup (empty = none) branchAhead int // commits ahead of upstream (needs push) branchBehind int // commits behind upstream (needs pull) width int height int + initialLine int // 1-based line to scroll the preview to at startup (0 = none) cwdEditCursor int // cursor position (rune index) within cwdEditValue branchInfoGen uint64 // generation counter - invalidates stale branchLoadedMsg bookmarksShown bool // whether bookmark overlay is visible @@ -137,6 +139,16 @@ func (m Model) WithChat(c *chat.Model) Model { return m } +// WithInitialFile returns a copy of the model configured to open the given +// file in the preview panel at startup, focusing the preview and scrolling +// to the given 1-based line (0 = top). Used when grut is launched with a +// file path argument, e.g. "grut main.go:42". An empty path is a no-op. +func (m Model) WithInitialFile(path string, line int) Model { + m.initialFile = path + m.initialLine = line + return m +} + // branchLoadedMsg carries the initial branch name and tracking info for the status bar. type branchLoadedMsg struct { Name string @@ -152,6 +164,18 @@ type gitDirtyMsg struct{ dirty bool } // auto-fetch timer if configured. func (m Model) Init() tea.Cmd { cmds := []tea.Cmd{m.engine.Init(m.ctx)} + // Open a file passed on the command line (e.g. "grut main.go:42"): + // focus the preview panel and ask the filetree to reveal + select the + // file, carrying the optional line so the preview scrolls to it once the + // content loads. engine.Init has already focused the default panel above, + // so this FocusByName call takes precedence for the initial view. + if m.initialFile != "" { + m.engine.FocusByName("preview") + file, line := m.initialFile, m.initialLine + cmds = append(cmds, func() tea.Msg { + return panels.RevealFileMsg{Path: file, Line: line} + }) + } if tick := m.autoFetchTickCmd(); tick != nil { cmds = append(cmds, tick) } diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 31262cc..75a666f 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -59,6 +59,21 @@ func TestModelViewBeforeResize(t *testing.T) { assert.True(t, view.AltScreen) } +func TestInitWithInitialFileFocusesPreview(t *testing.T) { + m := newTestModel(t).WithInitialFile("internal/git/client.go", 42) + cmd := m.Init() + require.NotNil(t, cmd) + assert.Equal(t, "preview", m.engine.FocusedName(), + "an initial file argument should focus the preview panel at startup") +} + +func TestInitWithoutInitialFileFocusesFiletree(t *testing.T) { + m := newTestModel(t) + m.Init() + assert.Equal(t, "filetree", m.engine.FocusedName(), + "without a file argument the filetree keeps default focus") +} + func TestModelViewAfterResize(t *testing.T) { m := newTestModel(t)