class Hiiro::TaskManager
def app_path(app_name = nil)
def app_path(app_name = nil) task = current_task tree_root = if task tree = environment.find_tree(task.tree_name) tree&.path || File.join(Hiiro::WORK_DIR, task.tree_name) else Hiiro::Git.new(nil, Dir.pwd).root end if app_name.nil? print tree_root return end result = environment.app_matcher.find_all(app_name) case result.count when 0 puts "ERROR: No matches found" puts puts "Possible Apps:" environment.all_apps.each { |a| puts format(" %-20s => %s", a.name, a.relative_path) } when 1 print result.first.item.resolve(tree_root) else puts "Multiple matches found:" result.matches.each { |m| puts format(" %-20s => %s", m.item.name, m.item.relative_path) } end end
def apply_sparse_checkout(path, group_names)
def apply_sparse_checkout(path, group_names) dirs = SparseGroups.dirs_for_groups(group_names) if dirs.empty? puts "WARNING: No directories found for sparse groups: #{group_names.join(', ')}" return end puts "Applying sparse checkout (groups: #{group_names.join(', ')})..." Hiiro::Git.new(nil, path).sparse_checkout(path, dirs) end
def branch(task_name = nil)
def branch(task_name = nil) if task_name.nil? branch = select_branch_interactive return unless branch print branch return end task = task_by_name(task_name) unless task puts "Task not found: #{task_name}" return end if task.branch print task.branch elsif task.tree&.detached? puts "(detached HEAD)" else puts "(no branch)" end end
def capture_tmux_windows(session)
def capture_tmux_windows(session) output = `tmux list-windows -t #{session} -F '\#{window_index}:\#{window_name}:\#{pane_current_path}' 2>/dev/null` output.lines.map(&:strip).map { |line| idx, name, path = line.split(':') { 'index' => idx, 'name' => name, 'path' => path } } end
def cd_to_app(app_name = nil)
def cd_to_app(app_name = nil) task = current_task unless task puts "ERROR: Not currently in a task session" return end if app_name.nil? || app_name.empty? tree = environment.find_tree(task.tree_name) send_cd(tree&.path || File.join(Hiiro::WORK_DIR, task.tree_name)) return end result = resolve_app(app_name, task) return unless result _resolved_name, app_path = result send_cd(app_path) end
def cd_to_task(task)
def cd_to_task(task) unless task puts "Task not found" return end tree = environment.find_tree(task.tree_name) path = tree ? tree.path : File.join(Hiiro::WORK_DIR, task.tree_name) send_cd(path) end
def config
def config environment.config end
def current_parent_task
def current_parent_task task = current_task return nil unless task if task.subtask? environment.find_task(task.parent_name) else task end end
def current_session
def current_session environment.session end
def current_task
def current_task environment.task end
def current_tree
def current_tree environment.tree end
def find_available_tree
def find_available_tree assigned_tree_names = environment.all_tasks.map(&:tree_name) environment.all_trees.find { |tree| !assigned_tree_names.include?(tree.name) } end
def initialize(hiiro, scope: :task, environment: nil)
def initialize(hiiro, scope: :task, environment: nil) @hiiro = hiiro @scope = scope @environment = environment || Environment.current end
def list
def list items = tasks if items.empty? puts scope == :subtask ? "No subtasks found" : "No tasks found" puts "Use 'h #{scope} start NAME' to create one." return end current = current_task label = scope == :subtask ? "Subtasks" : "Tasks" if scope == :subtask && current parent = current_parent_task label = "Subtasks of '#{parent&.name}'" if parent end puts "#{label}:" puts client_map = Hiiro::Tmux::Session.client_map # Collect rows as {prefix, name, tree, branch, session} so we can # compute max column widths before rendering. rows = [] items.each do |task| marker = (current && current.name == task.name) ? "*" : " " attach = client_map.key?(task.session_name) ? "@" : " " rows << { prefix: "#{marker}#{attach} ", **task.display_data(scope: scope, environment: environment) } if scope == :task subtasks(task).each do |st| sub_marker = (current && current.name == st.name) ? "*" : " " sub_attach = client_map.key?(st.session_name) ? "@" : " " rows << { prefix: "#{sub_marker}#{sub_attach} - ", **st.display_data(scope: :subtask, environment: environment) } end end end # Column widths: the name column absorbs the variable-length prefix so # that tree/branch/session always start at the same position. name_col = rows.map { |r| r[:prefix].length + r[:name].length }.max tree_col = rows.map { |r| r[:tree].length }.max branch_col = rows.map { |r| r[:branch].length }.max rows.each do |r| name_pad = name_col - r[:prefix].length print r[:prefix] puts format("%-#{name_pad}s %-#{tree_col}s %-#{branch_col}s %s", r[:name], r[:tree], r[:branch], r[:session]) end available = environment.all_trees.reject { |t| environment.all_tasks.any? { |task| task.tree_name == t.name } } if available.any? puts avail_name_col = [available.map { |t| t.name.length }.max, name_col].max available.each do |tree| branch_str = tree.branch ? "[#{tree.branch}]" : tree.detached? ? "[(detached)]" : "" puts format(" %-#{avail_name_col}s (available) %s", tree.name, branch_str).rstrip end end if scope == :task task_session_names = environment.all_tasks.map(&:session_name) extra_sessions = environment.all_sessions.reject { |s| task_session_names.include?(s.name) } if extra_sessions.any? puts extra_name_col = [extra_sessions.map { |s| s.name.length }.max, name_col].max extra_sessions.sort_by(&:name).each do |session| attach = client_map.key?(session.name) ? "@" : " " puts format(" %s %-#{extra_name_col}s (tmux session)", attach, session.name) end end end end
def list_apps
def list_apps apps = environment.all_apps if apps.any? puts "Configured apps:" puts apps.each do |app| puts format(" %-20s => %s", app.name, app.relative_path) end else puts "No apps configured." puts "Create #{APPS_FILE} with format:" puts " app_name: relative/path/from/repo" end end
def open_app(app_name)
def open_app(app_name) task = current_task unless task puts "ERROR: Not currently in a task session" return end result = resolve_app(app_name, task) return unless result resolved_name, app_path = result system('tmux', 'new-window', '-n', resolved_name, '-c', app_path) puts "Opened '#{resolved_name}' in new window (#{app_path})" end
def resolve_app(app_name, task)
def resolve_app(app_name, task) tree = environment.find_tree(task.tree_name) tree_root = tree ? tree.path : File.join(Hiiro::WORK_DIR, task.tree_name) result = environment.app_matcher.find_all(app_name) case result.count when 0 exact = File.join(tree_root, app_name) return [app_name, exact] if Dir.exist?(exact) nested = File.join(tree_root, app_name, app_name) return [app_name, nested] if Dir.exist?(nested) puts "ERROR: App '#{app_name}' not found" list_apps nil when 1 app = result.first.item [app.name, app.resolve(tree_root)] else exact = result.matches.find { |m| m.item.name == app_name } if exact [exact.item.name, exact.item.resolve(tree_root)] else puts "ERROR: '#{app_name}' matches multiple apps:" result.matches.each { |m| puts " #{m.item.name}" } nil end end end
def save
def save task = current_task unless task puts "ERROR: Not currently in a task session" return end windows = capture_tmux_windows(task.session_name) puts "Saved task '#{task.name}' state (#{windows.count} windows)" end
def select_branch_interactive(prompt = nil)
def select_branch_interactive(prompt = nil) name_map = if scope == :subtask tasks.sort_by(&:short_name).each_with_object({}) { |t, h| h[format('%-25s | %s', t.short_name, t.branch)] = t.branch } else environment.all_tasks.sort_by(&:name).each_with_object({}) { |t, h| h[format('%-25s | %s', t.name, t.branch)] = t.branch } end return nil if name_map.empty? hiiro.fuzzyfind_from_map(name_map) end
def select_task_interactive(prompt = nil)
def select_task_interactive(prompt = nil) task_list = if scope == :subtask tasks.sort_by(&:short_name) else environment.all_tasks.sort_by(&:name) end mapping = {} all_data = task_list.map { |t| [t, t.display_data(scope: scope, environment: environment)] } name_col = all_data.map { |_, d| d[:name].length }.max || 0 tree_col = all_data.map { |_, d| d[:tree].length }.max || 0 branch_col = all_data.map { |_, d| d[:branch].length }.max || 0 all_data.each do |task, d| line = format("%-#{name_col}s %-#{tree_col}s %-#{branch_col}s %s", d[:name], d[:tree], d[:branch], d[:session]) mapping[line] = task end # Add non-task tmux sessions (exclude sessions that belong to tasks) if scope == :task task_session_names = environment.all_tasks.map(&:session_name) extra_sessions = environment.all_sessions.reject { |s| task_session_names.include?(s.name) } extra_sessions.sort_by(&:name).each do |session| line = format("%-25s (tmux session)", session.name) mapping[line] = session end end return nil if mapping.empty? selected = hiiro.fuzzyfind_from_map(mapping) selected end
def send_cd(path)
def send_cd(path) pane = ENV['TMUX_PANE'] if pane system('tmux', 'send-keys', '-t', pane, "cd #{path}\n") else system('tmux', 'send-keys', "cd #{path}\n") end end
def slash_lookup(input)
def slash_lookup(input) environment.find_task(input) end
def start_task(name, app_name: nil, sparse_groups: [])
def start_task(name, app_name: nil, sparse_groups: []) existing = task_by_name(name) if existing puts "Task '#{existing.name}' already exists. Switching..." tree_path = existing.tree&.path if tree_path if sparse_groups.any? apply_sparse_checkout(tree_path, sparse_groups) else Hiiro::Git.new(nil, tree_path).disable_sparse_checkout(tree_path) end end switch_to_task(existing, app_name: app_name) return end task_name = scope == :subtask ? "#{current_parent_task.name}/#{name}" : name subtree_name = scope == :subtask ? "#{current_parent_task.name}/#{name}" : "#{name}/main" target_path = File.join(Hiiro::WORK_DIR, subtree_name) git = Hiiro::Git.new(nil, Hiiro::REPO_PATH) available = find_available_tree if available puts "Renaming worktree '#{available.name}' to '#{subtree_name}'..." FileUtils.mkdir_p(File.dirname(target_path)) unless git.move_worktree(available.path, target_path, repo_path: Hiiro::REPO_PATH) puts "ERROR: Failed to rename worktree" return end else puts "Creating new worktree '#{subtree_name}'..." FileUtils.mkdir_p(File.dirname(target_path)) unless git.add_worktree_detached(target_path, repo_path: Hiiro::REPO_PATH) puts "ERROR: Failed to create worktree" return end end apply_sparse_checkout(target_path, sparse_groups) if sparse_groups.any? session_name = task_name task = Task.new(name: task_name, tree: subtree_name, session: session_name) config.save_task(task) base_dir = target_path if app_name app = environment.find_app(app_name) base_dir = app.resolve(target_path) if app end Dir.chdir(base_dir) hiiro.start_tmux_session(session_name) puts "Started task '#{task_name}' in worktree '#{subtree_name}'" end
def status
def status task = current_task unless task puts "Not currently in a task session" return end puts "Task: #{task.name}" puts "Worktree: #{task.tree_name}" tree = environment.find_tree(task.tree_name) puts "Path: #{tree&.path || '(unknown)'}" puts "Session: #{task.session_name}" puts "Parent: #{task.parent_name}" if task.subtask? end
def stop_task(task)
def stop_task(task) unless task puts "Task not found" return end config.remove_task(task.name) subtasks(task).each { |st| config.remove_task(st.name) } puts "Stopped task '#{task.name}' (worktree available for reuse)" end
def subtasks(task)
def subtasks(task) environment.all_tasks.select { |t| t.parent_name == task.name } end
def switch_to_task(task, app_name: nil)
def switch_to_task(task, app_name: nil) unless task puts "Task not found" return end tree = environment.find_tree(task.tree_name) tree_path = tree ? tree.path : File.join(Hiiro::WORK_DIR, task.tree_name) session_name = task.session_name session_exists = system('tmux', 'has-session', '-t', session_name, err: File::NULL) if session_exists hiiro.start_tmux_session(session_name) else base_dir = tree_path if app_name app = environment.find_app(app_name) base_dir = app.resolve(tree_path) if app end if Dir.exist?(base_dir) Dir.chdir(base_dir) hiiro.start_tmux_session(session_name) else puts "ERROR: Path '#{base_dir}' does not exist" return end end puts "Switched to '#{task.name}'" end
def task_by_name(name)
def task_by_name(name) return slash_lookup(name) if name.include?('/') key = (scope == :subtask) ? :short_name : :name Hiiro::Matcher.new(tasks, key).by_prefix(name).first&.item end
def task_by_service_info(info)
def task_by_service_info(info) if name = info['task'] task_by_name(name) elsif session = info['tmux_session'] task_by_session(session) elsif tree = info['tree'] task_by_tree(tree) end end
def task_by_session(session_name)
def task_by_session(session_name) environment.task_matcher.resolve(session_name, :session_name).resolved&.item end
def task_by_tree(tree_name)
def task_by_tree(tree_name) environment.task_matcher.resolve(tree_name, :tree_name).resolved&.item end
def tasks
def tasks if scope == :subtask parent = current_parent_task return [] unless parent main_task = Task.new(name: "#{parent.name}/main", tree: parent.tree_name, session: parent.session_name) subtask_list = environment.all_tasks.select { |t| t.parent_name == parent.name } [main_task, *subtask_list] else environment.all_tasks.select(&:top_level?) end end
def value_for_task(task_name = nil, &block)
def value_for_task(task_name = nil, &block) if task_name task = task_by_name(task_name) return block.call(task) if task end task_list = scope == :subtask ? tasks.sort_by(&:short_name) : environment.all_tasks.sort_by(&:name) mapping = task_list.each_with_object({}) do |task, h| name = scope == :subtask ? task.short_name : task.name val = block.call(task)&.to_s line = format("%-25s | %s", name, val) h[line] = val end hiiro.fuzzyfind_from_map(mapping) end