diff --git a/README.md b/README.md index 814be85..e8ed2ce 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,14 @@ make install # installs to /usr/local/bin/bontree ## Usage ```bash -bontree [path] +bontree [path] [focus-path] ``` Defaults to the current directory if no path is given. +- If `path` is a file, bontree opens its parent directory and focuses that file. +- `focus-path` can be absolute or relative to `path`. + ## Keybindings Every keybinding listed below is a default — all of them can be remapped or removed in the [config file](#configuration). diff --git a/go.mod b/go.mod index ff6c27e..d914722 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect diff --git a/go.sum b/go.sum index 050645d..109c920 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/main.go b/main.go index 4e0dde4..9c3b112 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "path/filepath" "github.com/almonk/bontree/config" "github.com/almonk/bontree/theme" @@ -19,21 +20,11 @@ func main() { os.Exit(0) } - path := "." - if len(os.Args) > 1 { - path = os.Args[1] - } - - // Verify path exists - info, err := os.Stat(path) + rootPath, focusPath, err := resolveLaunchPaths(os.Args[1:]) if err != nil { fmt.Fprintf(os.Stderr, "Error: %s\n", err) os.Exit(1) } - if !info.IsDir() { - fmt.Fprintf(os.Stderr, "Error: %s is not a directory\n", path) - os.Exit(1) - } cfg, err := config.Load() if err != nil { @@ -51,7 +42,7 @@ func main() { ui.ApplyTheme(t) } - model, err := ui.New(path, cfg) + model, err := ui.NewWithFocus(rootPath, focusPath, cfg) if err != nil { fmt.Fprintf(os.Stderr, "Error building tree: %s\n", err) os.Exit(1) @@ -63,3 +54,39 @@ func main() { os.Exit(1) } } + +func resolveLaunchPaths(args []string) (string, string, error) { + switch len(args) { + case 0: + return ".", "", nil + case 1: + info, err := os.Stat(args[0]) + if err != nil { + return "", "", err + } + if info.IsDir() { + return args[0], "", nil + } + return filepath.Dir(args[0]), args[0], nil + case 2: + rootPath := args[0] + info, err := os.Stat(rootPath) + if err != nil { + return "", "", err + } + if !info.IsDir() { + return "", "", fmt.Errorf("%s is not a directory", rootPath) + } + + focusPath := args[1] + if !filepath.IsAbs(focusPath) { + focusPath = filepath.Join(rootPath, focusPath) + } + if _, err := os.Stat(focusPath); err != nil { + return "", "", err + } + return rootPath, focusPath, nil + default: + return "", "", fmt.Errorf("usage: bontree [path] [focus-path]") + } +} diff --git a/ui/model.go b/ui/model.go index d990616..6b14863 100644 --- a/ui/model.go +++ b/ui/model.go @@ -1,6 +1,9 @@ package ui import ( + "fmt" + "path/filepath" + "strings" "time" "github.com/almonk/bontree/config" @@ -10,15 +13,15 @@ import ( // Model is the Bubble Tea model type Model struct { - root *tree.Node - flatNodes []*tree.Node - cursor int - width int - height int - rootPath string - flashMsg string - showHelp bool - scrollOff int + root *tree.Node + flatNodes []*tree.Node + cursor int + width int + height int + rootPath string + flashMsg string + showHelp bool + scrollOff int gitBranch string gitFiles map[string]gitFileStatus // relative path -> status showHidden bool @@ -45,6 +48,11 @@ type Model struct { // New creates a new Model with the given config. If cfg is nil, defaults are used. func New(rootPath string, cfg *config.Config) (Model, error) { + return NewWithFocus(rootPath, "", cfg) +} + +// NewWithFocus creates a new Model and optionally reveals/focuses focusPath. +func NewWithFocus(rootPath, focusPath string, cfg *config.Config) (Model, error) { if cfg == nil { cfg = config.DefaultConfig() } @@ -56,13 +64,95 @@ func New(rootPath string, cfg *config.Config) (Model, error) { return Model{}, err } - return Model{ + m := Model{ root: root, flatNodes: flattenTree(root), rootPath: rootPath, showHidden: cfg.ShowHidden, cfg: cfg, - }, nil + } + + if err := m.focusByPath(focusPath); err != nil { + return Model{}, err + } + + return m, nil +} + +// focusByPath expands ancestor directories and moves the cursor to the target path. +// targetPath may be absolute or relative to rootPath. +func (m *Model) focusByPath(targetPath string) error { + if strings.TrimSpace(targetPath) == "" { + return nil + } + + rootAbs, err := filepath.Abs(m.rootPath) + if err != nil { + return err + } + targetAbs, err := filepath.Abs(targetPath) + if err != nil { + return err + } + + relPath, err := filepath.Rel(rootAbs, targetAbs) + if err != nil { + return err + } + relPath = filepath.ToSlash(relPath) + if relPath == "." { + m.cursor = 0 + m.ensureVisible() + return nil + } + if relPath == ".." || strings.HasPrefix(relPath, "../") { + return fmt.Errorf("focus path %q is outside root %q", targetPath, m.rootPath) + } + + parts := strings.Split(relPath, "/") + current := m.root + for i, part := range parts { + if part == "" || part == "." { + continue + } + + if !current.Loaded { + if err := current.Expand(); err != nil { + return err + } + } + + var next *tree.Node + for _, child := range current.Children { + if child.Name == part { + next = child + break + } + } + if next == nil { + return fmt.Errorf("focus path %q not found", targetPath) + } + + if i < len(parts)-1 { + if !next.IsDir { + return fmt.Errorf("focus path %q is invalid", targetPath) + } + if err := next.Expand(); err != nil { + return err + } + } + current = next + } + + m.refreshFlatNodes() + for i, n := range m.flatNodes { + if n == current { + m.cursor = i + break + } + } + m.ensureVisible() + return nil } func (m Model) Init() tea.Cmd { @@ -174,6 +264,9 @@ func (m *Model) viewportHeight() int { } func (m *Model) ensureVisible() { + if m.height <= 0 { + return + } viewH := m.viewportHeight() if m.cursor < m.scrollOff { m.scrollOff = m.cursor diff --git a/ui/update.go b/ui/update.go index 8f4daff..ff7055e 100644 --- a/ui/update.go +++ b/ui/update.go @@ -12,6 +12,7 @@ import ( "github.com/almonk/bontree/tree" "github.com/atotto/clipboard" tea "github.com/charmbracelet/bubbletea" + "github.com/google/shlex" ) // editorFinishedMsg is sent when the external editor process exits. @@ -30,6 +31,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.ensureVisible() return m, nil case clearFlashMsg: @@ -181,10 +183,15 @@ func (m Model) dispatchAction(action config.Action) (tea.Model, tea.Cmd) { return m, nil } editor := os.Getenv("EDITOR") - if editor == "" { + if strings.TrimSpace(editor) == "" { return m, flash(&m, "✗ $EDITOR is not set") } - c := exec.Command(editor, node.AbsPath) + parts, err := shlex.Split(editor) + if err != nil || len(parts) == 0 { + return m, flash(&m, "✗ Invalid $EDITOR") + } + args := append(parts[1:], node.AbsPath) + c := exec.Command(parts[0], args...) return m, tea.ExecProcess(c, func(err error) tea.Msg { return editorFinishedMsg{err} })