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
25 changes: 23 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}))
}

// ---------------------------------------------------------------------------
Expand Down
84 changes: 84 additions & 0 deletions cmd/startup_target.go
Original file line number Diff line number Diff line change
@@ -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 ":<positive-int>" 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
}
131 changes: 131 additions & 0 deletions cmd/startup_target_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
13 changes: 10 additions & 3 deletions internal/panels/filetree/filetree.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
Expand All @@ -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() {
Expand Down
35 changes: 35 additions & 0 deletions internal/panels/filetree/filetree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions internal/panels/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,15 @@ 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
// move the cursor to the specified file path. Emitted by the fuzzy finder
// 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.
Expand Down
Loading
Loading