diff --git a/project.go b/project.go index 3517ec1..5d60be4 100644 --- a/project.go +++ b/project.go @@ -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 == "~" { diff --git a/projectsmodel.go b/projectsmodel.go index 8ae7bfb..ce03bea 100644 --- a/projectsmodel.go +++ b/projectsmodel.go @@ -7,6 +7,8 @@ package main import ( + "os" + "path/filepath" "sort" "strconv" "strings" @@ -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 @@ -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 @@ -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, } } @@ -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: @@ -314,8 +331,9 @@ 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 { @@ -323,12 +341,15 @@ func (m projectsModel) activateProject() (tea.Model, tea.Cmd) { } 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 { @@ -336,6 +357,16 @@ func (m projectsModel) promptWorktreeBranch() (tea.Model, tea.Cmd) { } 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("") @@ -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 { @@ -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) } @@ -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. @@ -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)) diff --git a/projectsmodel_test.go b/projectsmodel_test.go index 8eaac5f..d59248b 100644 --- a/projectsmodel_test.go +++ b/projectsmodel_test.go @@ -301,6 +301,118 @@ func TestProjectsModelBranchEscReturnsToList(t *testing.T) { } } +// promptProjects returns a single directory-prompting project (working_dir = +// "{prompt}"), the shape of a generic any-repo template. +func promptProjects() []Project { + return []Project{{ + Name: "project", + WorkingDir: "{prompt}", + Tabs: []ProjectTab{{Name: "claude", Command: "claude"}, {Name: "terminal"}}, + }} +} + +// TestProjectsModelPromptDirEnterAsksForPath confirms activating a "{prompt}" +// project opens the path prompt instead of quitting straight to a workspace. +func TestProjectsModelPromptDirEnterAsksForPath(t *testing.T) { + m := newProjectsModel(promptProjects(), "/cfg/projects", "") + m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + + if m.mode != modePath { + t.Fatalf("mode = %v, want modePath", m.mode) + } + if m.chosen == nil || m.chosen.Name != "project" { + t.Fatalf("chosen = %v, want project", m.chosen) + } + if m.pathInput.Value() != "" || !m.pathInput.Focused() { + t.Fatal("path input should be empty and focused") + } +} + +// TestProjectsModelPromptDirOpensTypedPath confirms entering an existing +// directory stamps it into the chosen project, renaming it to the directory's +// basename so the workspace label says what it holds. +func TestProjectsModelPromptDirOpensTypedPath(t *testing.T) { + dir := t.TempDir() + m := newProjectsModel(promptProjects(), "/cfg/projects", "") + m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + m = step(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(dir)}) + m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + + if m.chosen == nil || m.chosen.WorkingDir != dir { + t.Fatalf("chosen working dir = %v, want %q", m.chosen, dir) + } + if m.chosen.Name != filepath.Base(dir) { + t.Fatalf("chosen name = %q, want %q", m.chosen.Name, filepath.Base(dir)) + } + if m.worktree { + t.Fatal("plain enter should not mark worktree") + } +} + +// TestProjectsModelPromptDirRejectsBadPath confirms a nonexistent path shows an +// inline error and keeps the prompt up instead of opening anything. +func TestProjectsModelPromptDirRejectsBadPath(t *testing.T) { + m := newProjectsModel(promptProjects(), "/cfg/projects", "") + m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + m = step(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/no/such/dir-for-this-test")}) + m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + + if m.mode != modePath { + t.Fatalf("mode = %v, want modePath still", m.mode) + } + if m.pathErr == "" { + t.Fatal("expected a validation error") + } + if m.chosen == nil || m.chosen.WorkingDir != "{prompt}" { + t.Fatalf("chosen = %v, want untouched sentinel", m.chosen) + } +} + +// TestProjectsModelPromptDirCtrlGThenBranch confirms the ctrl+g flow on a +// "{prompt}" project asks for the path first, then the branch, and ends marked +// as a worktree of the typed directory. +func TestProjectsModelPromptDirCtrlGThenBranch(t *testing.T) { + dir := t.TempDir() + m := newProjectsModel(promptProjects(), "/cfg/projects", "dvic/") + m = step(t, m, tea.KeyMsg{Type: tea.KeyCtrlG}) + + if m.mode != modePath { + t.Fatalf("mode = %v, want modePath first", m.mode) + } + + m = step(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(dir)}) + m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + + if m.mode != modeBranch { + t.Fatalf("mode = %v, want modeBranch after path", m.mode) + } + + m = step(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("feature")}) + m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + + if !m.worktree || m.branch != "dvic/feature" { + t.Fatalf("worktree = %v branch = %q, want true dvic/feature", m.worktree, m.branch) + } + if m.chosen == nil || m.chosen.WorkingDir != dir { + t.Fatalf("chosen working dir = %v, want %q", m.chosen, dir) + } +} + +// TestProjectsModelPromptDirEscReturnsToList confirms escape backs out of the +// path prompt without choosing anything. +func TestProjectsModelPromptDirEscReturnsToList(t *testing.T) { + m := newProjectsModel(promptProjects(), "/cfg/projects", "") + m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + m = step(t, m, tea.KeyMsg{Type: tea.KeyEsc}) + + if m.mode != modeList { + t.Fatalf("mode = %v, want modeList", m.mode) + } + if m.chosen != nil { + t.Fatalf("chosen = %v, want nil", m.chosen) + } +} + // TestProjectsModelCtrlGOnHeadingNoops confirms ctrl+g only opens branch mode // when the highlighted row maps to a project. func TestProjectsModelCtrlGOnHeadingNoops(t *testing.T) { diff --git a/styles.go b/styles.go index 229c8f4..c8fbe40 100644 --- a/styles.go +++ b/styles.go @@ -31,4 +31,5 @@ var ( barStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Bold(true) footerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) headingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Bold(true) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) )