From 140030328a991fb0ae41fe6cdb366aaba88595c7 Mon Sep 17 00:00:00 2001 From: Soleone Date: Sat, 19 Sep 2026 00:18:43 -0400 Subject: [PATCH] feat(graduate): support multiple project parents - Add configurable TRY_PROJECTS parent lists with cycling in the graduation dialog - Polish destination display, path selection, and controls - Document graduation behavior and expand end-to-end coverage --- AGENTS.md | 4 +- README.md | 7 +- spec/command_line.md | 3 + spec/graduate_spec.md | 50 ++++++++++ spec/tests/test_37_graduate.sh | 69 ++++++++++++- spec/tui_spec.md | 2 + try.rb | 177 +++++++++++++++++++++++++++------ 7 files changed, 280 insertions(+), 32 deletions(-) create mode 100644 spec/graduate_spec.md diff --git a/AGENTS.md b/AGENTS.md index 3ac917a..38f98d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,8 +13,8 @@ - `try worktree dir [name]`: Same as above but explicit CLI, useful without the shell wrapper. - `try clone [name]`: Clones into the root. Default name is `YYYY-MM-DD-user-repo` (strips `.git`). Optional `name` overrides. - Flags: `--path PATH` (for `cd`/`clone`) overrides the root for that call; `--help` prints global help. -- Environment: `TRY_PATH` sets the default root when not using `--path`. `TRY_PROJECTS` sets the graduate destination (default: parent of `TRY_PATH`). -- UI keys: `↑/↓` or `Ctrl-P/N` navigate, `Enter` select, `Backspace` delete char, `Ctrl-D` delete dir (requires typing `YES`), `Ctrl-G` graduate (promote try to project), `Ctrl-R` rename, `ESC` cancel. +- Environment: `TRY_PATH` sets the default root when not using `--path`. `TRY_PROJECTS` sets one or more graduate parents, separated by `:` (default: parent of `TRY_PATH`). +- UI keys: `↑/↓` or `Ctrl-P/N` navigate, `Enter` select, `Backspace` delete char, `Ctrl-D` delete dir (requires typing `YES`), `Ctrl-G` graduate (promote try to project), `Ctrl-R` rename, `ESC` cancel. In the graduation dialog, `↑/↓` cycle configured parents. ### Shorthands and Worktrees - `try .`: Creates a new date-prefixed directory using the current working directory’s basename. If inside a Git repo, a detached worktree is added; otherwise this is a plain directory. diff --git a/README.md b/README.md index 4eb0e66..f101d80 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ automatically removed from URLs when generating directory names. - `Enter` - Select or create - `Backspace` - Delete character - `Ctrl-D` - Delete directory (with confirmation) +- `Ctrl-G` - Graduate a try into a project - `ESC` - Cancel - Just type to filter @@ -206,7 +207,11 @@ Set `TRY_PATH` to change where experiments are stored: export TRY_PATH=~/code/sketches ``` -Default: `~/src/tries` +`TRY_PROJECTS` optionally sets colon-separated graduation parent directories, +for example `$HOME/src/projects:$HOME/src/client-work`. It defaults to the +parent of `TRY_PATH`. + +Default `TRY_PATH`: `~/src/tries` ## Nix diff --git a/spec/command_line.md b/spec/command_line.md index 34f2de9..7f05eb4 100644 --- a/spec/command_line.md +++ b/spec/command_line.md @@ -192,6 +192,8 @@ When `HERDR_ENV=1` and `HERDR_PANE_ID` are present, the script includes a guarde | Variable | Description | |----------|-------------| | `HOME` | Used to resolve default tries path (`$HOME/src/tries`) | +| `TRY_PATH` | Overrides the tries directory | +| `TRY_PROJECTS` | One or more graduation parent directories, separated by the platform path separator | | `SHELL` | Used by `init` to detect shell type | | `NO_COLOR` | If set, disables colors (equivalent to `--no-colors`) | | `HERDR_ENV`, `HERDR_PANE_ID`, `HERDR_WORKSPACE_ID` | Identify the current Herdr panel and workspace for renaming | @@ -202,6 +204,7 @@ When `HERDR_ENV=1` and `HERDR_PANE_ID` are present, the script includes a guarde - **Tries directory**: `~/src/tries` - **Date format**: `YYYY-MM-DD` - **Directory naming**: `YYYY-MM-DD-` +- **Graduation parent**: first `TRY_PROJECTS` entry, or the parent of `TRY_PATH` ## Color Output diff --git a/spec/graduate_spec.md b/spec/graduate_spec.md new file mode 100644 index 0000000..0558679 --- /dev/null +++ b/spec/graduate_spec.md @@ -0,0 +1,50 @@ +# Graduation Specification + +`Ctrl-G` opens a confirmation dialog for promoting the selected try to a +permanent project directory. + +## Destination defaults + +The project name is the try name with a leading `YYYY-MM-DD-` removed. The +default destination is: + +1. the first path in `TRY_PROJECTS`, when it is set; +2. otherwise the parent directory of `TRY_PATH`. + +`TRY_PROJECTS` may contain one or more absolute or `~`-relative parent paths, +separated by the platform path separator (`:` on Unix-like systems). Existing +single-path configurations remain valid. Parent paths are expanded and empty +entries are ignored. + +## Graduation dialog + +The dialog shows the destination path in an editable input field. Paths +inside the user's home directory are displayed with a `~` prefix when +possible, while the actual expanded path is used for graduation. + +- Left/right arrows and the line-editing keys move and edit the destination. +- With multiple configured parents, Up/Down arrows cycle through them. +- Cycling parents preserves the current project basename, including a basename + manually typed into the destination field. +- Parent selection wraps at the beginning and end of the configured list. +- Enter confirms; Esc or Ctrl-C cancels. + +When multiple parents are configured, the dialog lists every parent in a +centered, aligned block whose path columns line up with the destination field. +The shared directory prefix is dimmed and the +non-shared suffix is bold for each entry; the active entry is marked with `→`. +Below the list, contextual help shows bold `↑↓` and `←→` icons with the labels +`select parent, edit`. Enter/Esc confirmation help is shown separately in the +footer. The destination parent must already exist and the complete destination +must not already exist. + +## Result + +On confirmation, the shell script: + +1. moves the try to the selected destination; +2. leaves a symlink at the original try path; +3. prints a graduation message; and +4. changes the shell to the new project directory. + +Git worktrees use `git worktree move`; regular directories use `mv`. diff --git a/spec/tests/test_37_graduate.sh b/spec/tests/test_37_graduate.sh index e81aba4..2652123 100644 --- a/spec/tests/test_37_graduate.sh +++ b/spec/tests/test_37_graduate.sh @@ -222,4 +222,71 @@ else fail "Should strip YYYY-MM-DD- prefix from various dates" "year-end" "$output" "graduate" fi -rm -rf "$GRAD_DIR" "$PROJ_DIR" "$NO_DATE_DIR" "$WT_DIR" "$EDIT_DIR" "$E2E_DIR" "$E2E_DEST" "$REG_DIR" "$MULTI_DIR" +# Test: Arrow keys cycle through a configured TRY_PROJECTS path list +PARENT_ONE=$(mktemp -d) +PARENT_TWO=$(mktemp -d) +LIST_DIR=$(mktemp -d) +mkdir -p "$LIST_DIR/2025-06-01-list-selection" +output=$(TRY_PROJECTS="$PARENT_ONE:$PARENT_TWO" try_run --path="$LIST_DIR" --and-keys='CTRL-G,DOWN,ENTER' exec 2>/dev/null) +if echo "$output" | grep -q "$PARENT_TWO/list-selection"; then + pass +else + fail "Down arrow should select the next configured graduation parent" "$PARENT_TWO/list-selection" "$output" "graduate" +fi + +# Test: Graduation dialog lists all configured parents +ui_output=$(TRY_PROJECTS="$PARENT_ONE:$PARENT_TWO" try_run --path="$LIST_DIR" --and-keys='CTRL-G,ESC' exec 2>&1) +if echo "$ui_output" | grep -Fq "$(basename "$PARENT_ONE")" && echo "$ui_output" | grep -Fq "$(basename "$PARENT_TWO")"; then + pass +else + fail "Graduation dialog should show every configured parent" "$PARENT_ONE and $PARENT_TWO" "$ui_output" "graduate" +fi + +# Test: The non-shared parent path portion is bold +if printf '%s' "$ui_output" | grep -q $'\033\\[1m'; then + pass +else + fail "Graduation dialog should bold the distinct parent path portion" "ANSI bold sequence" "$ui_output" "graduate" +fi + +# Test: Contextual parent help uses bold arrow icons +if echo "$ui_output" | grep -q "↑↓.*select parent,.*←→.*edit"; then + pass +else + fail "Graduation dialog should show parent selection and editing help" "↑↓ select parent, ←→ edit" "$ui_output" "graduate" +fi + +# Test: Old parent label and counter wording are gone +if echo "$ui_output" | grep -qE "Project parents|Parent [0-9]+/[0-9]+|↑/↓: Parent|←/→: Edit"; then + fail "Graduation dialog should not show the old parent label or counters" "no old parent wording" "$ui_output" "graduate" +else + pass +fi + +# Test: Confirm/cancel help remains in the footer +if echo "$ui_output" | grep -q "Enter: Confirm.*Esc: Cancel"; then + pass +else + fail "Graduation dialog should keep confirm and cancel help" "Enter: Confirm Esc: Cancel" "$ui_output" "graduate" +fi + +# Test: Paths under HOME use a compact ~ display form +HOME_TEST=$(mktemp -d) +HOME_LIST_DIR=$(mktemp -d) +mkdir -p "$HOME_TEST/products" "$HOME_TEST/js" "$HOME_LIST_DIR/2025-06-01-home-display" +home_ui=$(HOME="$HOME_TEST" TRY_PROJECTS="$HOME_TEST/products:$HOME_TEST/js" try_run --path="$HOME_LIST_DIR" --and-keys='CTRL-G,ESC' exec 2>&1) +if echo "$home_ui" | grep -Fq "products" && echo "$home_ui" | grep -Fq "js"; then + pass +else + fail "Graduation dialog should abbreviate HOME paths" "~/products and ~/js" "$home_ui" "graduate" +fi + +# Test: Parent cycling preserves a manually edited project name +output=$(TRY_PROJECTS="$PARENT_ONE:$PARENT_TWO" try_run --path="$LIST_DIR" --and-keys="CTRL-G,CTRL-A,CTRL-K,TYPE=custom-name,DOWN,ENTER" exec 2>/dev/null) +if echo "$output" | grep -q "$PARENT_TWO/custom-name"; then + pass +else + fail "Changing parent should preserve the edited project name" "$PARENT_TWO/custom-name" "$output" "graduate" +fi + +rm -rf "$GRAD_DIR" "$PROJ_DIR" "$NO_DATE_DIR" "$WT_DIR" "$EDIT_DIR" "$E2E_DIR" "$E2E_DEST" "$REG_DIR" "$MULTI_DIR" "$PARENT_ONE" "$PARENT_TWO" "$LIST_DIR" "$HOME_TEST" "$HOME_LIST_DIR" diff --git a/spec/tui_spec.md b/spec/tui_spec.md index 8ae787e..4f14450 100644 --- a/spec/tui_spec.md +++ b/spec/tui_spec.md @@ -213,6 +213,7 @@ Tokens are preserved intact - never split a `{b}...{/b}` pair. | Enter | Select current entry | | Esc / Ctrl-C | Cancel selection | | Ctrl-D | Delete selected directory | +| Ctrl-G | Open the graduation dialog | ### Line Editing (in search input) | Key | Action | @@ -243,6 +244,7 @@ Selection can result in three action types: | CD | Select existing directory | Navigate to directory | | MKDIR | Select "[new]" entry | Create and navigate to new directory | | DELETE | Press Ctrl-D on entry | Show delete confirmation dialog | +| GRADUATE | Press Ctrl-G | Move the selected try to a project directory and leave a symlink | | CANCEL | Press Esc | Exit without action | ## New Directory Creation diff --git a/try.rb b/try.rb index c3b3c0a..fcb3de5 100755 --- a/try.rb +++ b/try.rb @@ -745,21 +745,32 @@ def run_ascend_dialog(entry) # Strip date prefix for the default project name project_name = current_name.sub(/^\d{4}-\d{2}-\d{2}-/, '') - # Compute default destination directory - projects_dir = if TRY_PROJECTS - File.expand_path(TRY_PROJECTS) - else - File.dirname(@base_path) - end - - input = Tui::InputField.new(placeholder: "", text: File.join(projects_dir, project_name)) + # TRY_PROJECTS is a platform-separated list of graduation parents. + parent_paths = graduation_parent_paths + parent_index = 0 + input_width = graduation_input_width(parent_paths, project_name) + input = Tui::InputField.new( + placeholder: "", + text: display_graduation_destination(parent_paths.first, project_name) + ) ascend_error = nil loop do - render_ascend_dialog(current_name, input.text, input.cursor, ascend_error, projects_dir) + render_ascend_dialog(current_name, input, ascend_error, + parent_paths, parent_index, input_width) ch = read_key next unless ch + + parent_delta = graduation_parent_delta(ch) + if parent_delta && parent_paths.length > 1 + parent_index = switch_graduation_parent( + input, parent_paths, parent_index, parent_delta, project_name + ) + ascend_error = nil + next + end + before = input.text if input.handle_key(ch) ascend_error = nil if input.text != before @@ -781,50 +792,160 @@ def run_ascend_dialog(entry) @needs_redraw = true end - def render_ascend_dialog(current_name, ascend_buffer, ascend_cursor, ascend_error, projects_dir) + def graduation_parent_paths + separator = TryCompat.win_platform? ? ';' : ':' + parents = TRY_PROJECTS.to_s.split(separator).map do |path| + path = path.strip + File.expand_path(path) unless path.empty? + end.compact.uniq + parents.empty? ? [File.expand_path(File.dirname(@base_path))] : parents + end + + def graduation_parent_delta(key) + case key + when "\e[A" then -1 # Up arrow + when "\e[B" then 1 # Down arrow + end + end + + def switch_graduation_parent(input, parent_paths, index, delta, fallback_name) + index = (index + delta) % parent_paths.length + name = File.basename(input.text) + name = fallback_name if name.empty? + input.text = display_graduation_destination(parent_paths[index], name) + input.cursor = input.text.length + index + end + + def display_graduation_destination(parent, name) + display_graduation_path(File.join(parent, name)) + end + + def display_graduation_path(path) + expanded = File.expand_path(path) + home = File.expand_path("~") + return "~" if expanded == home + + home_prefix = home.end_with?("/", "\\") ? home : "#{home}#{File::SEPARATOR}" + expanded.start_with?(home_prefix) ? "~#{expanded[home.length..]}" : expanded + end + + def graduation_input_width(parent_paths, name) + parent_paths.map do |parent| + path = display_graduation_destination(parent, name) + Tui::Metrics.visible_width(path) + 1 + end.max + end + + def render_ascend_dialog(current_name, input, ascend_error, + parent_paths, parent_index, input_width) screen = Tui::Screen.new(io: STDERR) + multiple_parents = parent_paths.length > 1 + parent_path = parent_paths[parent_index] line = screen.header.add_line line.center.write(emoji("🚀")).write(Tui::Text.accent(" Graduate try to project")) line = screen.header.add_line - line.write.write_dim(fill("─")) + line.write.write_dim(fill("─")) line = screen.body.add_line line.write.write(emoji("📁")).write(" #{current_name}") screen.body.add_line - env_hint = TRY_PROJECTS ? "$TRY_PROJECTS" : "parent of $TRY_PATH" - line = screen.body.add_line - line.center.write_dim("Destination (#{env_hint}: #{projects_dir})") + unless multiple_parents + env_hint = TRY_PROJECTS.to_s.empty? ? "parent of $TRY_PATH" : "$TRY_PROJECTS" + line = screen.body.add_line + line.center.write_dim("Destination (#{env_hint}: #{parent_path})") + end - line = screen.body.add_line - prefix = "Move to: " - line.center.write_dim(prefix) - line.center.write(screen.input("", value: ascend_buffer, cursor: ascend_cursor).to_s) - input_width = [ascend_buffer.length, ascend_cursor + 1].max - prefix_width = Tui::Metrics.visible_width(prefix) - max_content = screen.width - 1 - center_start = (max_content - prefix_width - input_width) / 2 - line.mark_has_input(center_start + prefix_width) + destination_column = render_graduation_input(screen, input, input_width) + + if multiple_parents + render_graduation_parent_controls( + screen, parent_paths, parent_index, destination_column + ) + else + screen.body.add_line + end - screen.body.add_line line = screen.body.add_line line.center.write_dim("A symlink will be left in the tries directory") if ascend_error screen.body.add_line line = screen.body.add_line - line.center.write_bold(ascend_error) + line.center.write_bold(ascend_error) end line = screen.footer.add_line - line.write.write_dim(fill("─")) + line.write.write_dim(fill("─")) line = screen.footer.add_line - line.center.write_dim("Enter: Confirm Esc: Cancel") + line.center.write_dim("Enter: Confirm Esc: Cancel") screen.flush end + def render_graduation_input(screen, input, input_width) + line = screen.body.add_line + prefix = "Move to: " + prefix_width = Tui::Metrics.visible_width(prefix) + max_content = screen.width - 1 + row_start = [(max_content - prefix_width - input_width) / 2, 0].max + line.write.write(" " * row_start) + line.write.write_dim(prefix) + + rendered_input = screen.input("", value: input.text, cursor: input.cursor).to_s + line.write.write(rendered_input) + padding = [input_width - Tui::Metrics.visible_width(rendered_input), 0].max + line.write.write(" " * padding) + line.mark_has_input(row_start + prefix_width) + row_start + prefix_width + end + + def render_graduation_parent_controls(screen, parent_paths, parent_index, destination_column) + render_graduation_parent_list(screen, parent_paths, parent_index, destination_column) + screen.body.add_line + render_graduation_parent_help(screen) + screen.body.add_line + end + + def render_graduation_parent_help(screen) + line = screen.body.add_line + line.center.write_bold("↑↓").write_dim(" select parent, ").write_bold("←→").write_dim(" edit") + end + + def render_graduation_parent_list(screen, parent_paths, parent_index, destination_column) + display_paths = parent_paths.map { |path| display_graduation_path(path) } + common_prefix = graduation_common_path_prefix(display_paths) + max_path_width = display_paths.map { |path| Tui::Metrics.visible_width(path) }.max + row_start = [destination_column - 2, 0].max + + display_paths.each_with_index do |path, index| + selected = index == parent_index + background = selected ? Tui::Palette::SELECTED_BG + Tui::Palette::SELECTED_FG : nil + line = screen.body.add_line(background) + line.write.write(" " * row_start) + line.write.write(selected ? Tui::Text.highlight("→ ") + selected_foreground : " ") + + unique_part = path[common_prefix.length..] + shared_part = path[0...common_prefix.length] + line.write.write(selected ? shared_part : Tui::Text.dim(shared_part)) + line.write.write_bold(unique_part) + line.write.write(" " * [max_path_width - Tui::Metrics.visible_width(path), 0].max) + end + end + + def graduation_common_path_prefix(paths) + return "" if paths.empty? + + prefix = paths.first + paths.drop(1).each do |path| + prefix = prefix[0, prefix.length - 1] until path.start_with?(prefix) + end + separator = [prefix.rindex("/"), prefix.rindex("\\")].compact.max + separator ? prefix[0..separator] : "" + end + def finalize_ascend(entry, ascend_buffer) dest = ascend_buffer.strip dest = File.expand_path(dest) @@ -1051,7 +1172,7 @@ def print_global_help Environment: TRY_PATH Tries directory (default: ~/src/tries) - TRY_PROJECTS Graduate destination (default: parent of TRY_PATH) + TRY_PROJECTS Graduate parent(s), separated by : (default: parent of TRY_PATH) Keyboard: ↑/↓, Ctrl-P/N Navigate