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
37 changes: 30 additions & 7 deletions project.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,24 +213,47 @@ func validateTabs(label, source string, tabs []ProjectTab) error {
return nil
}

// promptDirSentinel is the working_dir value that makes a project ask for its
// directory when opened instead of pinning one in the TOML. One such project is
// a universal template: pick it, type a path, and any repo opens with the
// project's tabs.
const promptDirSentinel = "{prompt}"

// promptsForDir reports whether this project asks for its working directory at
// open time (working_dir = "{prompt}").
func (p Project) promptsForDir() bool {
return strings.TrimSpace(p.WorkingDir) == promptDirSentinel
}

// expandedWorkingDir resolves the project's working directory to an absolute
// path, expanding a leading ~ to the home directory and any $VARS in the path.
// An empty working_dir defaults to the user's home directory, so a minimal
// project still opens somewhere sensible.
func (p Project) expandedWorkingDir() (string, error) {
dir, err := expandPath(p.WorkingDir)
if err != nil {
return "", fmt.Errorf("resolve home directory for working_dir %q: %w", p.WorkingDir, err)
}
return dir, nil
}

// expandPath resolves a user-authored path the way working_dir is resolved: a
// leading ~ becomes the home directory, $VAR / ${VAR} expand (not Windows
// %VAR%), and an empty value defaults to home. Shared with the picker's path
// prompt so a typed path and a configured one behave identically.
//
// Home comes from os.UserHomeDir (USERPROFILE on Windows, HOME elsewhere), and
// the result is filepath.Clean'd so a config written with forward slashes lands
// on native separators — both so this works on Windows. Only $VAR / ${VAR}
// syntax is expanded (os.ExpandEnv), not Windows %VAR%; document that for users.
func (p Project) expandedWorkingDir() (string, error) {
dir := strings.TrimSpace(p.WorkingDir)
// the result is filepath.Clean'd so a path written with forward slashes lands
// on native separators — both so this works on Windows.
func expandPath(s string) (string, error) {
dir := strings.TrimSpace(s)

home, err := os.UserHomeDir()
needsHome := dir == "" || dir == "~" || strings.HasPrefix(dir, "~/")
if err != nil && needsHome {
// Only a fatal problem when the path actually references home; an absolute
// or relative working_dir resolves fine without it.
return "", fmt.Errorf("resolve home directory for working_dir %q: %w", p.WorkingDir, err)
// or relative path resolves fine without it.
return "", err
}

if dir == "" || dir == "~" {
Expand Down
160 changes: 154 additions & 6 deletions projectsmodel.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
package main

import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
Expand All @@ -33,6 +35,7 @@ type projectsMode int
const (
modeList projectsMode = iota
modeBranch
modePath
)

// Projects-browser styles. These build on the shared palette in styles.go
Expand Down Expand Up @@ -88,6 +91,13 @@ type projectsModel struct {
worktree bool
branch string
quitting bool

// Path-prompt state for projects with working_dir = "{prompt}": the input,
// the last validation error, and whether the worktree branch prompt should
// follow once a directory is accepted (the ctrl+g flow).
pathInput textinput.Model
pathErr string
branchAfterPath bool
}

// ungroupedHeading labels the catch-all bucket for projects that declare no
Expand All @@ -105,12 +115,16 @@ func newProjectsModel(projects []Project, projectsDir, branchPrefix string) proj
branchInput := textinput.New()
branchInput.Prompt = ""
branchInput.Placeholder = "empty → generated name"
pathInput := textinput.New()
pathInput.Prompt = ""
pathInput.Placeholder = "~/path/to/project"
return projectsModel{
projects: ordered,
list: newFuzzyList("Type to filter projects…", items),
projectsDir: projectsDir,
branchInput: branchInput,
branchPrefix: branchPrefix,
pathInput: pathInput,
}
}

Expand Down Expand Up @@ -207,6 +221,9 @@ func (m projectsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.mode == modeBranch {
return m.updateBranch(msg)
}
if m.mode == modePath {
return m.updatePath(msg)
}

switch msg := msg.(type) {
case tea.WindowSizeMsg:
Expand Down Expand Up @@ -314,28 +331,42 @@ func (m projectsModel) updateBranch(msg tea.Msg) (tea.Model, tea.Cmd) {
}

// activateProject records the highlighted project as the chosen one and signals
// a quit so its workspace gets built. Shared by the enter key and a left-click;
// activating with nothing selectable is a no-op.
// a quit so its workspace gets built — or, for a project that prompts for its
// directory, switches into path-entry mode first. Shared by the enter key and a
// left-click; activating with nothing selectable is a no-op.
func (m projectsModel) activateProject() (tea.Model, tea.Cmd) {
idx := m.list.selectedIndex()
if idx < 0 {
return m, nil
}
p := m.projects[idx]
m.chosen = &p
if p.promptsForDir() {
return m.promptProjectDir(false)
}
return m, tea.Quit
}

// promptWorktreeBranch switches the browser into its branch-entry mode for the
// highlighted project, resetting the input to empty and focusing it. It is the
// ctrl+g entry point; with nothing selectable it is a no-op.
// promptWorktreeBranch is the ctrl+g entry point: it records the highlighted
// project and switches into branch-entry mode — via the path prompt first when
// the project asks for its directory. With nothing selectable it is a no-op.
func (m projectsModel) promptWorktreeBranch() (tea.Model, tea.Cmd) {
idx := m.list.selectedIndex()
if idx < 0 {
return m, nil
}
p := m.projects[idx]
m.chosen = &p
if p.promptsForDir() {
return m.promptProjectDir(true)
}
return m.enterBranchMode()
}

// enterBranchMode resets and focuses the branch input and switches the browser
// into its branch-entry state. Shared by ctrl+g and the path prompt's
// continue-to-branch step, so both arrive with a clean input.
func (m projectsModel) enterBranchMode() (tea.Model, tea.Cmd) {
m.worktree = false
m.branch = ""
m.branchInput.SetValue("")
Expand All @@ -346,6 +377,84 @@ func (m projectsModel) promptWorktreeBranch() (tea.Model, tea.Cmd) {
return m, cmd
}

// promptProjectDir switches the browser into path-entry mode for the already
// chosen project (one with working_dir = "{prompt}"). branchAfter carries
// whether the worktree branch prompt should follow once a directory is
// accepted (the ctrl+g flow).
func (m projectsModel) promptProjectDir(branchAfter bool) (tea.Model, tea.Cmd) {
m.branchAfterPath = branchAfter
m.pathErr = ""
m.pathInput.SetValue("")
cmd := m.pathInput.Focus()
m.mode = modePath
return m, cmd
}

// updatePath handles input while the project-directory prompt is showing (the
// modePath state a "{prompt}" project enters when activated). Enter accepts the
// typed path when it names an existing directory: the path is stamped into the
// chosen project as its working directory and the project takes the
// directory's basename as its name, so the workspace label says what it holds.
// It then quits to open the workspace, or continues to the worktree branch
// prompt in the ctrl+g flow. An invalid path shows an inline error and keeps
// the prompt up. Esc backs out to the list, clearing the pending choice.
func (m projectsModel) updatePath(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil

case tea.KeyMsg:
switch msg.String() {
case "enter":
if m.chosen == nil {
m.mode = modeList
return m, nil
}
if strings.TrimSpace(m.pathInput.Value()) == "" {
m.pathErr = "enter a project path"
return m, nil
}
dir, err := expandPath(m.pathInput.Value())
if err != nil {
m.pathErr = err.Error()
return m, nil
}
if abs, err := filepath.Abs(dir); err == nil {
dir = abs
}
if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
m.pathErr = "not a directory: " + dir
return m, nil
}
m.chosen.WorkingDir = dir
m.chosen.Name = filepath.Base(dir)
if m.branchAfterPath {
return m.enterBranchMode()
}
return m, tea.Quit
case "esc":
m.mode = modeList
m.chosen = nil
m.pathErr = ""
m.branchAfterPath = false
return m, nil
}

var cmd tea.Cmd
m.pathInput, cmd = m.pathInput.Update(msg)
return m, cmd

case tea.MouseMsg:
return m, nil
}

var cmd tea.Cmd
m.pathInput, cmd = m.pathInput.Update(msg)
return m, cmd
}

// View renders the screen for the current state: the onboarding card when there
// are no projects, otherwise the header / list / detail-bar / footer layout.
func (m projectsModel) View() string {
Expand All @@ -368,6 +477,9 @@ func (m projectsModel) View() string {
if m.mode == modeBranch {
return m.branchView(w, h)
}
if m.mode == modePath {
return m.pathView(w, h)
}
return m.browserView(w, h)
}

Expand Down Expand Up @@ -418,6 +530,38 @@ func (m projectsModel) branchView(w, h int) string {
return top + strings.Repeat("\n", gap) + footer
}

// pathView renders the project-directory prompt for a "{prompt}" project: the
// chosen project's name above a single-line input for the path (~ and $VARS
// expand like working_dir), plus any validation error from the last attempt.
// It backs the modePath state.
func (m projectsModel) pathView(w, h int) string {
header := headerBarStyle.Width(w).Render(projectsTitle)

name := ""
if m.chosen != nil {
name = m.chosen.Name
}

body := nameStyle.Render(name) + "\n" +
dirIconStyle.Render("📁 ") + pathStyle.Render("directory to open (~ and $VARS expand)") + "\n\n" +
promptStyle.Render("❯ ") + m.pathInput.View()
if m.pathErr != "" {
body += "\n" + errorStyle.Render(truncate(m.pathErr, w-4))
}
action := "open"
if m.branchAfterPath {
action = "continue"
}
footer := footerStyle.Render(" enter " + action + " · esc back")

top := header + "\n\n" + body
gap := h - lipgloss.Height(top) - lipgloss.Height(footer)
if gap < 1 {
gap = 1
}
return top + strings.Repeat("\n", gap) + footer
}

// detailBar renders the bordered preview of the currently highlighted project:
// its working directory and the ordered list of tab names. It updates live as
// the cursor moves.
Expand All @@ -440,7 +584,11 @@ func (m projectsModel) detailBar(w int) string {
inner = 10
}

dirLine := dirIconStyle.Render("📁 ") + pathStyle.Render(truncate(p.displayWorkingDir(), inner-3))
dirText := p.displayWorkingDir()
if p.promptsForDir() {
dirText = "asks for a path"
}
dirLine := dirIconStyle.Render("📁 ") + pathStyle.Render(truncate(dirText, inner-3))

labels := p.tabLabels()
styled := make([]string, len(labels))
Expand Down
Loading