From 3b8a674fe5c193012f0bc8303c751e3707bdcf44 Mon Sep 17 00:00:00 2001 From: Emma <817422+Pajn@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:00:59 +0100 Subject: [PATCH 1/2] Add worktree picker mode --- README.md | 5 + crates/wisp-bin/src/git.rs | 370 ++++++++++++++++++ crates/wisp-bin/src/main.rs | 486 ++++++++++++++++++------ crates/wisp-config/src/lib.rs | 9 + crates/wisp-core/src/candidate.rs | 34 ++ crates/wisp-core/src/lib.rs | 42 +- crates/wisp-core/src/preview.rs | 33 ++ crates/wisp-core/src/view.rs | 307 ++++++++++++++- crates/wisp-ui/src/lib.rs | 151 +++++++- crates/wisp-zoxide/tests/integration.rs | 34 +- docs/config.schema.toml | 2 + docs/configuration.md | 3 +- 12 files changed, 1318 insertions(+), 158 deletions(-) create mode 100644 crates/wisp-bin/src/git.rs diff --git a/README.md b/README.md index e8411d5..4bd81aa 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Wisp is a native Rust tmux navigation tool inspired by `tmux-sessionx`. It share - tmux-aware session discovery, switching, and attachment - sidebar pane and sidebar popup surfaces in addition to the main picker +- git worktree-aware picker: see only sessions for the current repo, or browse all worktrees - zoxide-backed directory discovery - fuzzy filtering and session previews - configurable behavior through TOML config plus environment overrides @@ -49,12 +50,16 @@ Common commands after install: ```bash wisp doctor wisp popup +wisp popup --worktree wisp fullscreen +wisp fullscreen --worktree wisp sidebar-popup wisp sidebar-pane wisp statusline install ``` +Use `--worktree` (or `-w`) to start the picker in worktree mode, which shows only sessions belonging to worktrees of the current repo alongside worktrees that don't yet have sessions. + Example tmux binding: Add this to `~/.tmux.conf` to open Wisp with `prefix + o`: diff --git a/crates/wisp-bin/src/git.rs b/crates/wisp-bin/src/git.rs new file mode 100644 index 0000000..58c37e6 --- /dev/null +++ b/crates/wisp-bin/src/git.rs @@ -0,0 +1,370 @@ +use std::{fs, path::Path, path::PathBuf, process::Command}; + +use wisp_core::{DomainState, GitBranchSync, WorktreeInfo}; + +/// Runs `git worktree list --porcelain` and returns all worktrees. +pub fn git_worktree_list(cwd: &Path) -> Vec { + let output = match Command::new("git") + .args(["worktree", "list", "--porcelain"]) + .current_dir(cwd) + .output() + { + Ok(o) => o, + Err(_) => return Vec::new(), + }; + + if !output.status.success() { + return Vec::new(); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut worktrees = Vec::new(); + let mut current_path: Option = None; + let mut current_branch: Option = None; + let mut current_locked = false; + + for line in stdout.lines() { + if let Some(path) = line.strip_prefix("worktree ") { + // Save the previous worktree if we were tracking one + if let Some(p) = current_path.take() { + worktrees.push(WorktreeInfo { + path: p, + branch: current_branch.take(), + is_locked: current_locked, + }); + current_locked = false; + } + current_path = Some(PathBuf::from(path)); + } else if let Some(branch) = line.strip_prefix("branch refs/heads/") { + current_branch = Some(branch.to_string()); + } else if line.strip_prefix("locked").is_some() { + current_locked = true; + } else if line.strip_prefix("prunable").is_some() || line == "bare" { + // Skip bare repos + current_path = None; + current_branch = None; + current_locked = false; + } + } + + // Don't forget the last worktree + if let Some(p) = current_path { + worktrees.push(WorktreeInfo { + path: p, + branch: current_branch, + is_locked: current_locked, + }); + } + + worktrees +} + +/// Finds the git repository root from a given path. +pub fn git_repo_root(path: &Path) -> Option { + path.ancestors().find_map(repo_root_at) +} + +fn repo_root_at(path: &Path) -> Option { + let dot_git = path.join(".git"); + + if dot_git.is_dir() { + return resolve_git_dir(path)?.parent().map(Path::to_path_buf); + } + + if !dot_git.is_file() { + return None; + } + + let pointer = fs::read_to_string(&dot_git).ok()?; + let target = pointer.lines().next()?.trim().strip_prefix("gitdir: ")?; + let git_dir = Path::new(target); + let resolved_git_dir = if git_dir.is_absolute() { + git_dir.to_path_buf() + } else { + dot_git.parent()?.join(git_dir) + }; + + if resolved_git_dir.exists() { + Some(path.to_path_buf()) + } else { + None + } +} + +/// Returns the branch name for the given directory, or None if not on a branch. +pub fn branch_name_for_directory(path: &Path) -> Option { + path.ancestors().find_map(branch_name_for_git_root) +} + +fn branch_name_for_git_root(path: &Path) -> Option { + let git_dir = resolve_git_dir(path)?; + let head = fs::read_to_string(git_dir.join("HEAD")).ok()?; + let head = head.trim(); + + if let Some(reference) = head.strip_prefix("ref: ") { + return Some( + reference + .strip_prefix("refs/heads/") + .unwrap_or(reference) + .to_string(), + ); + } + + Some(head.chars().take(7).collect()) +} + +/// Resolves the .git directory for a path, handling both regular dirs and gitdir: pointers. +pub fn resolve_git_dir(path: &Path) -> Option { + let dot_git = path.join(".git"); + if dot_git.is_dir() { + return Some(dot_git); + } + + if !dot_git.is_file() { + return None; + } + + let pointer = fs::read_to_string(&dot_git).ok()?; + let target = pointer.trim().strip_prefix("gitdir: ")?; + let git_dir = Path::new(target); + if git_dir.is_absolute() { + Some(git_dir.to_path_buf()) + } else { + Some(path.join(git_dir)) + } +} + +/// Returns the sync status and dirty flag for a given directory. +/// +/// This only detects whether the local branch still needs to be pushed. Branches that are only +/// behind their upstream are still reported as [`GitBranchSync::Pushed`]. +pub fn branch_status_for_directory(path: &Path) -> Option<(GitBranchSync, bool)> { + let output = Command::new("git") + .arg("-C") + .arg(path) + .args(["status", "--porcelain=2", "--branch"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut upstream = None; + let mut ahead = 0usize; + let mut dirty = false; + + for line in stdout.lines() { + if let Some(remote) = line.strip_prefix("# branch.upstream ") { + upstream = Some(remote.to_string()); + } else if let Some(ab) = line.strip_prefix("# branch.ab ") { + let mut parts = ab.split_whitespace(); + let ahead_raw = parts.next().and_then(|part| part.strip_prefix('+')); + ahead = ahead_raw + .and_then(|part| part.parse::().ok()) + .unwrap_or(0); + } else if !line.starts_with("# ") && !line.is_empty() { + dirty = true; + } + } + + let sync = if upstream.is_none() || ahead > 0 { + GitBranchSync::NotPushed + } else { + GitBranchSync::Pushed + }; + + Some((sync, dirty)) +} + +/// Gets the git repository root based on the current tmux state. +/// Finds the current session's focused window path and resolves it to a git repo root. +pub fn worktree_repo_root(state: &DomainState, client_id: Option<&str>) -> Option { + // Get the current session + let current_session_id = state.current_session_id(client_id)?; + + // Get the session record + let session = state.sessions.get(current_session_id)?; + + // Find the active window + let active_window = session + .windows + .values() + .find(|window| window.active) + .or_else(|| session.windows.values().next())?; + + // Get the current path from the window + let current_path = active_window.current_path.as_ref()?; + + // Find the git repo root + git_repo_root(current_path) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + path::{Path, PathBuf}, + process::Command, + time::{SystemTime, UNIX_EPOCH}, + }; + + use wisp_core::GitBranchSync; + + use super::{branch_name_for_directory, branch_status_for_directory, git_repo_root}; + + fn unique_root() -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(); + std::env::temp_dir().join(format!("wisp-git-test-{nonce}")) + } + + fn run_git(cwd: &Path, args: &[&str]) { + let output = Command::new("git") + .current_dir(cwd) + .args(args) + .output() + .expect("git command"); + + assert!( + output.status.success(), + "git {:?} failed: stdout={} stderr={}", + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + fn write_file(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("parent dirs"); + } + fs::write(path, contents).expect("write file"); + } + + fn init_synced_repo(root: &Path) -> (PathBuf, PathBuf) { + let remote = root.join("remote.git"); + let local = root.join("local"); + + run_git( + root, + &["init", "--bare", remote.to_str().expect("remote path")], + ); + run_git( + root, + &[ + "clone", + remote.to_str().expect("remote path"), + local.to_str().expect("local path"), + ], + ); + run_git(&local, &["config", "user.name", "Wisp Tests"]); + run_git(&local, &["config", "user.email", "wisp-tests@example.com"]); + + write_file(&local.join("README.md"), "seed\n"); + run_git(&local, &["add", "README.md"]); + run_git(&local, &["commit", "-m", "seed"]); + run_git(&local, &["push", "-u", "origin", "HEAD"]); + + (remote, local) + } + + #[test] + fn resolves_repo_root_for_nested_git_directory_paths() { + let root = unique_root(); + let repo = root.join("repo"); + let nested = repo.join("src/module"); + fs::create_dir_all(repo.join(".git")).expect("git dir"); + fs::create_dir_all(&nested).expect("nested dir"); + + assert_eq!(git_repo_root(&nested), Some(repo.clone())); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn resolves_repo_root_for_worktree_git_files() { + let root = unique_root(); + let repo = root.join("repo"); + let worktree = root.join("worktree"); + let nested = worktree.join("src/module"); + let worktree_git_dir = repo.join(".git/worktrees/feature"); + fs::create_dir_all(&worktree_git_dir).expect("worktree git dir"); + fs::create_dir_all(&nested).expect("nested worktree dir"); + fs::write( + worktree.join(".git"), + format!("gitdir: {}\n", worktree_git_dir.display()), + ) + .expect("git pointer"); + + assert_eq!(git_repo_root(&nested), Some(worktree.clone())); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn branch_name_for_directory_strips_heads_prefix() { + let root = unique_root(); + let repo = root.join("repo"); + fs::create_dir_all(repo.join(".git")).expect("git dir"); + fs::write(repo.join(".git/HEAD"), "ref: refs/heads/feature/demo\n").expect("head file"); + + assert_eq!( + branch_name_for_directory(&repo), + Some("feature/demo".to_string()) + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn branch_status_marks_ahead_branches_as_not_pushed() { + let root = unique_root(); + fs::create_dir_all(&root).expect("root dir"); + let (_remote, local) = init_synced_repo(&root); + + write_file(&local.join("local.txt"), "ahead\n"); + run_git(&local, &["add", "local.txt"]); + run_git(&local, &["commit", "-m", "local change"]); + + assert_eq!( + branch_status_for_directory(&local), + Some((GitBranchSync::NotPushed, false)) + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn branch_status_treats_behind_only_branches_as_pushed() { + let root = unique_root(); + fs::create_dir_all(&root).expect("root dir"); + let (remote, local) = init_synced_repo(&root); + let peer = root.join("peer"); + + run_git( + &root, + &[ + "clone", + remote.to_str().expect("remote path"), + peer.to_str().expect("peer path"), + ], + ); + run_git(&peer, &["config", "user.name", "Wisp Tests"]); + run_git(&peer, &["config", "user.email", "wisp-tests@example.com"]); + write_file(&peer.join("peer.txt"), "behind\n"); + run_git(&peer, &["add", "peer.txt"]); + run_git(&peer, &["commit", "-m", "peer change"]); + run_git(&peer, &["push"]); + run_git(&local, &["fetch", "origin"]); + + assert_eq!( + branch_status_for_directory(&local), + Some((GitBranchSync::Pushed, false)) + ); + + let _ = fs::remove_dir_all(root); + } +} diff --git a/crates/wisp-bin/src/main.rs b/crates/wisp-bin/src/main.rs index b60e28d..5a7cf01 100644 --- a/crates/wisp-bin/src/main.rs +++ b/crates/wisp-bin/src/main.rs @@ -6,7 +6,6 @@ use std::{ fs, io::stdout, path::{Path, PathBuf}, - process::Command as ProcessCommand, process::ExitCode, sync::{Arc, Mutex, mpsc}, thread, @@ -26,8 +25,9 @@ use wisp_config::{ CliOverrides, KeyAction, LoadOptions, ResolvedConfig, SessionSortMode, load_config, }; use wisp_core::{ - DomainState, GitBranchStatus, GitBranchSync, PreviewKey, PreviewRequest, SessionListItem, - SessionListSortMode, derive_session_list, derive_status_items, sort_session_list_items, + DomainState, GitBranchStatus, GitBranchSync, PickerMode, PreviewKey, PreviewRequest, + SessionListItem, SessionListSortMode, derive_session_list, derive_session_list_with_worktrees, + derive_status_items, sanitize_session_name, sort_session_list_items, }; use wisp_fuzzy::{MatchItem, Matcher, SimpleMatcher}; use wisp_preview::{ActivePanePreviewProvider, PreviewProvider, SessionDetailsPreviewProvider}; @@ -39,7 +39,10 @@ use wisp_tmux::{ use wisp_ui::{KeyBindings, SurfaceKind, SurfaceModel, UiIntent, render_surface, translate_key}; use wisp_zoxide::{CommandZoxideProvider, ZoxideProvider}; +mod git; + const PREVIEW_REFRESH_DEBOUNCE: Duration = Duration::from_millis(400); +const DEFAULT_CLIENT_ID: &str = "default"; const SIDEBAR_PANE_TITLE: &str = "Wisp Sidebar"; const SIDEBAR_PANE_WIDTH: u16 = 36; const STATUSLINE_REFRESH_HOOKS: &[&str] = &[ @@ -85,12 +88,20 @@ struct PrintConfigCommand {} #[derive(Debug, FromArgs, PartialEq)] #[argh(subcommand, name = "fullscreen")] /// Open the main picker fullscreen in tmux. -struct FullscreenCommand {} +pub struct FullscreenCommand { + #[argh(switch, short = 'w', long = "worktree")] + /// start in worktree mode, showing only worktrees from the current repo + pub worktree: bool, +} #[derive(Debug, FromArgs, PartialEq)] #[argh(subcommand, name = "popup")] /// Open the main picker in a tmux popup. -struct PopupCommandCli {} +pub struct PopupCommandCli { + #[argh(switch, short = 'w', long = "worktree")] + /// start in worktree mode, showing only worktrees from the current repo + pub worktree: bool, +} #[derive(Debug, FromArgs, PartialEq)] #[argh(subcommand, name = "sidebar-popup")] @@ -151,7 +162,7 @@ struct StatuslineUninstallCommand { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum UiMode { - Picker, + Picker(PickerMode), SidebarCompact, SidebarExpanded, } @@ -159,11 +170,12 @@ enum UiMode { impl FromArgValue for UiMode { fn from_arg_value(value: &str) -> Result { match value { - "picker" => Ok(Self::Picker), + "picker" => Ok(Self::Picker(PickerMode::AllSessions)), + "picker-worktree" => Ok(Self::Picker(PickerMode::Worktree)), "sidebar-compact" => Ok(Self::SidebarCompact), "sidebar-expanded" => Ok(Self::SidebarExpanded), other => Err(format!( - "expected one of \"picker\", \"sidebar-compact\", or \"sidebar-expanded\", got `{other}`" + "expected one of \"picker\", \"picker-worktree\", \"sidebar-compact\", or \"sidebar-expanded\", got `{other}`" )), } } @@ -172,11 +184,18 @@ impl FromArgValue for UiMode { impl UiMode { fn surface_kind(self) -> SurfaceKind { match self { - Self::Picker => SurfaceKind::Picker, + Self::Picker(_) => SurfaceKind::Picker, Self::SidebarCompact => SurfaceKind::SidebarCompact, Self::SidebarExpanded => SurfaceKind::SidebarExpanded, } } + + fn picker_mode(self) -> PickerMode { + match self { + Self::Picker(mode) => mode, + Self::SidebarCompact | Self::SidebarExpanded => PickerMode::AllSessions, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -254,7 +273,7 @@ enum ParsedCli { fn ui_help_early_exit(command_name: &str) -> argh::EarlyExit { argh::EarlyExit { output: format!( - "Usage: {command_name} ui \n\n Internal helper for launching a specific surface.\n\n Modes:\n picker Open the picker surface.\n sidebar-compact Open the compact sidebar surface.\n sidebar-expanded Open the expanded sidebar surface.\n" + "Usage: {command_name} ui \n\n Internal helper for launching a specific surface.\n\n Modes:\n picker Open the picker surface.\n picker-worktree Open the picker surface in worktree mode.\n sidebar-compact Open the compact sidebar surface.\n sidebar-expanded Open the expanded sidebar surface.\n" ), status: Ok(()), } @@ -273,7 +292,7 @@ fn parse_cli_args(args: &[String]) -> Result { Some(mode) => { if cli_args.len() > 2 { return Err(ui_parse_error( - "ui accepts exactly one surface mode: picker, sidebar-compact, or sidebar-expanded", + "ui accepts exactly one surface mode: picker, picker-worktree, sidebar-compact, or sidebar-expanded", )); } @@ -322,7 +341,7 @@ fn execute_cli(cli: ParsedCli) -> Result<(), Box> { match cli { ParsedCli::Ui(mode) => { let config = load_runtime_config()?; - run_surface(mode.surface_kind(), &config) + run_surface(mode.surface_kind(), &config, mode.picker_mode()) } ParsedCli::Public(cli) => match cli.command { Command::Doctor(_) => { @@ -334,13 +353,23 @@ fn execute_cli(cli: ParsedCli) -> Result<(), Box> { println!("{config:#?}"); Ok(()) } - Command::Fullscreen(_) => { + Command::Fullscreen(fullscreen_cmd) => { let config = load_runtime_config()?; - run_surface(SurfaceKind::Picker, &config) + let mode = if fullscreen_cmd.worktree { + PickerMode::Worktree + } else { + PickerMode::AllSessions + }; + run_surface(SurfaceKind::Picker, &config, mode) } - Command::Popup(_) => { + Command::Popup(popup_cmd) => { let config = load_runtime_config()?; - open_popup_or_run_inline(SurfaceKind::Picker, &config) + let mode = if popup_cmd.worktree { + PickerMode::Worktree + } else { + PickerMode::AllSessions + }; + open_popup_or_run_inline(SurfaceKind::Picker, &config, mode) } Command::SidebarPopup(_) => { let config = load_runtime_config()?; @@ -582,6 +611,67 @@ fn create_session_from_query( Ok(true) } +fn create_session_from_worktree_path( + tmux: &impl TmuxClient, + worktree_path: &Path, +) -> Result> { + let normalized_path = worktree_path + .canonicalize() + .unwrap_or_else(|_| worktree_path.to_path_buf()); + let session_name = format!( + "{}-{:08x}", + sanitize_session_name(&normalized_path), + stable_path_hash(&normalized_path) as u32 + ); + tmux.create_or_switch_session(&session_name, worktree_path)?; + Ok(true) +} + +fn session_items_for_picker_mode( + state: &DomainState, + client_id: Option<&str>, + picker_mode: PickerMode, +) -> Vec { + if picker_mode == PickerMode::Worktree { + if let Some(repo_root) = git::worktree_repo_root(state, client_id) { + let worktrees = git::git_worktree_list(&repo_root); + derive_session_list_with_worktrees(state, client_id, &worktrees) + } else { + vec![picker_info_item("not in a git repository")] + } + } else { + derive_session_list(state, client_id) + } +} + +fn rebuild_session_items_for_picker_mode( + state: &DomainState, + client_id: Option<&str>, + picker_mode: PickerMode, + session_sort: SessionSortMode, +) -> ( + Vec, + VecDeque, + mpsc::Receiver, +) { + let mut session_items = session_items_for_picker_mode(state, client_id, picker_mode); + let pending_branch_names = if picker_mode == PickerMode::Worktree { + VecDeque::new() + } else { + git_work_items(state) + }; + let status_work_items = if picker_mode == PickerMode::Worktree { + git_work_items_for_worktree_items(&session_items) + .into_iter() + .collect() + } else { + pending_branch_names.iter().cloned().collect() + }; + apply_session_sort(&mut session_items, session_sort); + let branch_status_updates = spawn_git_status_workers(status_work_items); + (session_items, pending_branch_names, branch_status_updates) +} + fn activate_filter_selection( tmux: &impl TmuxClient, zoxide: &impl ZoxideProvider, @@ -596,6 +686,17 @@ fn activate_filter_selection( } if let Some(item) = filtered.get(selected) { + match item.kind { + wisp_core::SessionListItemKind::Info => return Ok(false), + wisp_core::SessionListItemKind::Worktree => { + if let Some(worktree_path) = &item.worktree_path { + return create_session_from_worktree_path(tmux, worktree_path); + } + return Ok(false); + } + _ => {} + } + tmux.switch_or_attach_session(&item.session_id)?; return Ok(true); } @@ -606,11 +707,18 @@ fn activate_filter_selection( fn open_popup_or_run_inline( kind: SurfaceKind, config: &ResolvedConfig, + mode: PickerMode, ) -> Result<(), Box> { let backend = PollingTmuxBackend::new(CommandTmuxClient::new()); let command = PopupCommand { program: env::current_exe()?, - args: vec!["ui".to_string(), "picker".to_string()], + args: vec![ + "ui".to_string(), + match mode { + PickerMode::AllSessions => "picker".to_string(), + PickerMode::Worktree => "picker-worktree".to_string(), + }, + ], }; match backend.open_popup(&PopupSpec { command, @@ -618,7 +726,7 @@ fn open_popup_or_run_inline( }) { Ok(()) => Ok(()), Err(TmuxError::PopupUnavailable { .. }) | Err(TmuxError::CommandFailed { .. }) => { - run_surface(kind, config) + run_surface(kind, config, mode) } Err(error) => Err(Box::new(error)), } @@ -640,7 +748,7 @@ fn open_sidebar_popup_or_run_inline(config: &ResolvedConfig) -> Result<(), Box Ok(()), Err(TmuxError::PopupUnavailable { .. }) | Err(TmuxError::CommandFailed { .. }) => { - run_surface(SurfaceKind::SidebarCompact, config) + run_surface(SurfaceKind::SidebarCompact, config, PickerMode::AllSessions) } Err(error) => Err(Box::new(error)), } @@ -656,10 +764,15 @@ fn open_sidebar_pane() -> Result<(), Box> { Ok(()) } -fn run_surface(kind: SurfaceKind, config: &ResolvedConfig) -> Result<(), Box> { +fn run_surface( + kind: SurfaceKind, + config: &ResolvedConfig, + mode: PickerMode, +) -> Result<(), Box> { let state = load_domain_state()?; - let mut session_items = derive_session_list(&state, Some("default")); let mut session_sort = config.ui.session_sort; + let (mut session_items, mut pending_branch_names, mut branch_status_updates) = + rebuild_session_items_for_picker_mode(&state, Some(DEFAULT_CLIENT_ID), mode, session_sort); let mut pane_preview_provider = ActivePanePreviewProvider::new(CommandTmuxClient::new()); let mut details_preview_provider = SessionDetailsPreviewProvider { state: state.clone(), @@ -673,12 +786,18 @@ fn run_surface(kind: SurfaceKind, config: &ResolvedConfig) -> Result<(), Box load_sidebar_ui_state(&runtime.session_name)?, None => None, }; - if let Some(state) = &saved_sidebar_state - && let Some(saved_sort_mode) = state.sort_mode + if let Some(saved_state) = &saved_sidebar_state + && let Some(saved_sort_mode) = saved_state.sort_mode { session_sort = saved_sort_mode; + (session_items, pending_branch_names, branch_status_updates) = + rebuild_session_items_for_picker_mode( + &state, + Some(DEFAULT_CLIENT_ID), + mode, + session_sort, + ); } - apply_session_sort(&mut session_items, session_sort); let mut query = saved_sidebar_state .as_ref() .map(|state| state.query.clone()) @@ -704,10 +823,8 @@ fn run_surface(kind: SurfaceKind, config: &ResolvedConfig) -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box { if matches!(input_mode, InputMode::Filter) && let Some(item) = filtered.get(selected) + && matches!( + item.kind, + wisp_core::SessionListItemKind::Session + | wisp_core::SessionListItemKind::WorktreeSession + ) { input_mode = InputMode::Rename { session_id: item.session_id.clone(), @@ -1013,10 +1140,24 @@ fn run_surface(kind: SurfaceKind, config: &ResolvedConfig) -> Result<(), Box { if matches!(input_mode, InputMode::Filter) && let Some(item) = filtered.get(selected) + && matches!( + item.kind, + wisp_core::SessionListItemKind::Session + | wisp_core::SessionListItemKind::WorktreeSession + ) { let session_id = item.session_id.clone(); tmux.kill_session(&session_id)?; - session_items.retain(|session| session.session_id != session_id); + let reloaded_state = load_domain_state()?; + (session_items, pending_branch_names, branch_status_updates) = + rebuild_session_items_for_picker_mode( + &reloaded_state, + Some(DEFAULT_CLIENT_ID), + picker_mode, + session_sort, + ); + details_preview_provider.state = reloaded_state.clone(); + deferred_branch_status.clear(); preview_session_id = None; preview_refreshed_at = None; if preview_enabled { @@ -1043,6 +1184,28 @@ fn run_surface(kind: SurfaceKind, config: &ResolvedConfig) -> Result<(), Box { + if matches!(input_mode, InputMode::Filter) { + picker_mode = match picker_mode { + PickerMode::AllSessions => PickerMode::Worktree, + PickerMode::Worktree => PickerMode::AllSessions, + }; + + let reloaded_state = load_domain_state()?; + (session_items, pending_branch_names, branch_status_updates) = + rebuild_session_items_for_picker_mode( + &reloaded_state, + Some(DEFAULT_CLIENT_ID), + picker_mode, + session_sort, + ); + details_preview_provider.state = reloaded_state.clone(); + deferred_branch_status.clear(); + selected = 0; + preview_session_id = None; + preview_refreshed_at = None; + } + } } if let Some(runtime) = &sidebar_runtime @@ -1084,6 +1247,7 @@ fn picker_bindings(config: &ResolvedConfig) -> KeyBindings { ctrl_m: ui_intent_for_action(config.actions.ctrl_m), esc: ui_intent_for_action(config.actions.esc), ctrl_c: ui_intent_for_action(config.actions.ctrl_c), + ctrl_w: ui_intent_for_action(config.actions.ctrl_w), } } @@ -1101,6 +1265,7 @@ fn ui_intent_for_action(action: KeyAction) -> UiIntent { KeyAction::ToggleDetails => UiIntent::ToggleDetails, KeyAction::ToggleCompactSidebar => UiIntent::ToggleCompactSidebar, KeyAction::Close => UiIntent::Close, + KeyAction::ToggleWorktreeMode => UiIntent::ToggleWorktreeMode, } } @@ -1114,13 +1279,43 @@ fn preview_line_budget( } fn generate_preview(provider: &dyn PreviewProvider, item: &SessionListItem) -> Vec { - provider - .generate(&PreviewRequest::SessionSummary { - key: PreviewKey::Session(item.session_id.clone()), - session_name: item.session_id.clone(), - }) - .map(|content| content.body) - .unwrap_or_else(|error| vec![error.to_string()]) + match item.kind { + wisp_core::SessionListItemKind::Info => vec![item.label.clone()], + wisp_core::SessionListItemKind::Worktree => { + // For worktrees without sessions, show directory listing or "not an active session" + if let Some(path) = &item.worktree_path { + provider + .generate(&PreviewRequest::Directory { + key: PreviewKey::Directory(path.clone()), + path: path.clone(), + }) + .map(|content| content.body) + .unwrap_or_else(|_| vec!["not an active session".to_string()]) + } else { + vec!["not an active session".to_string()] + } + } + wisp_core::SessionListItemKind::WorktreeSession => { + // For sessions in worktrees, show the session preview (tmux capture) + provider + .generate(&PreviewRequest::SessionSummary { + key: PreviewKey::Session(item.session_id.clone()), + session_name: item.session_id.clone(), + }) + .map(|content| content.body) + .unwrap_or_else(|error| vec![error.to_string()]) + } + _ => { + // For regular sessions + provider + .generate(&PreviewRequest::SessionSummary { + key: PreviewKey::Session(item.session_id.clone()), + session_name: item.session_id.clone(), + }) + .map(|content| content.body) + .unwrap_or_else(|error| vec![error.to_string()]) + } + } } fn sidebar_surface_command(program: PathBuf) -> PopupCommand { @@ -1399,6 +1594,45 @@ fn git_work_items(state: &DomainState) -> VecDeque { .collect() } +fn git_work_items_for_worktree_items(items: &[SessionListItem]) -> VecDeque { + items + .iter() + .filter_map(|item| { + matches!( + item.kind, + wisp_core::SessionListItemKind::Worktree + | wisp_core::SessionListItemKind::WorktreeSession + ) + .then(|| item.worktree_path.as_ref()) + .flatten() + .map(|path| GitWorkItem { + session_id: item.session_id.clone(), + path: path.clone(), + }) + }) + .collect() +} + +fn picker_info_item(message: &str) -> SessionListItem { + SessionListItem { + session_id: format!("info:{message}"), + label: message.to_string(), + kind: wisp_core::SessionListItemKind::Info, + is_current: false, + is_previous: false, + last_activity: None, + attached: false, + attention: wisp_core::AttentionBadge::None, + attention_count: 0, + active_window_label: None, + path_hint: None, + command_hint: None, + git_branch: None, + worktree_path: None, + worktree_branch: None, + } +} + fn spawn_git_status_workers(work_items: Vec) -> mpsc::Receiver { let (sender, receiver) = mpsc::channel(); let queue = Arc::new(Mutex::new(VecDeque::from(work_items))); @@ -1417,7 +1651,7 @@ fn spawn_git_status_workers(work_items: Vec) -> mpsc::Receiver) -> mpsc::Receiver u64 { + const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325; + const FNV_PRIME: u64 = 0x100000001b3; + + path.to_string_lossy() + .as_bytes() + .iter() + .fold(FNV_OFFSET_BASIS, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(FNV_PRIME) + }) +} + fn update_branch_name(items: &mut [SessionListItem], session_id: &str, branch_name: String) { if let Some(item) = items.iter_mut().find(|item| item.session_id == session_id) { item.git_branch = Some(GitBranchStatus { @@ -1468,81 +1714,6 @@ fn update_branch_status( } } -fn branch_status_for_directory(path: &Path) -> Option<(GitBranchSync, bool)> { - let output = ProcessCommand::new("git") - .arg("-C") - .arg(path) - .args(["status", "--porcelain=2", "--branch"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let mut upstream = None; - let mut ahead = 0usize; - let mut dirty = false; - - for line in stdout.lines() { - if let Some(remote) = line.strip_prefix("# branch.upstream ") { - upstream = Some(remote.to_string()); - } else if let Some(ab) = line.strip_prefix("# branch.ab ") { - let mut parts = ab.split_whitespace(); - let ahead_raw = parts.next().and_then(|part| part.strip_prefix('+')); - ahead = ahead_raw - .and_then(|part| part.parse::().ok()) - .unwrap_or(0); - } else if !line.starts_with("# ") && !line.is_empty() { - dirty = true; - } - } - - let sync = if upstream.is_none() || ahead > 0 { - GitBranchSync::NotPushed - } else { - GitBranchSync::Pushed - }; - - Some((sync, dirty)) -} - -fn branch_name_for_directory(path: &Path) -> Option { - path.ancestors().find_map(branch_name_for_git_root) -} - -fn branch_name_for_git_root(path: &Path) -> Option { - let git_dir = resolve_git_dir(path)?; - let head = fs::read_to_string(git_dir.join("HEAD")).ok()?; - let head = head.trim(); - - if let Some(reference) = head.strip_prefix("ref: ") { - return reference.rsplit('/').next().map(ToOwned::to_owned); - } - - Some(head.chars().take(7).collect()) -} - -fn resolve_git_dir(path: &Path) -> Option { - let dot_git = path.join(".git"); - if dot_git.is_dir() { - return Some(dot_git); - } - - if !dot_git.is_file() { - return None; - } - - let pointer = fs::read_to_string(&dot_git).ok()?; - let target = pointer.trim().strip_prefix("gitdir: ")?; - let git_dir = Path::new(target); - if git_dir.is_absolute() { - Some(git_dir.to_path_buf()) - } else { - Some(path.join(git_dir)) - } -} - fn filter_items(items: &[SessionListItem], query: &str) -> Vec { let mut matcher = SimpleMatcher::default(); matcher.set_items( @@ -2232,6 +2403,75 @@ mod tests { assert_eq!(tmux.created_sessions.borrow().len(), 2); } + #[test] + fn activate_filter_selection_creates_session_for_worktree_rows() { + let tmux = StubTmuxClient::default(); + let zoxide = StubZoxideProvider::default(); + let filtered = vec![wisp_core::SessionListItem { + session_id: "worktree:/tmp/project".to_string(), + label: "project".to_string(), + kind: wisp_core::SessionListItemKind::Worktree, + is_current: false, + is_previous: false, + last_activity: None, + attached: false, + attention: wisp_core::AttentionBadge::None, + attention_count: 0, + active_window_label: None, + path_hint: Some("/tmp/project".to_string()), + command_hint: None, + git_branch: None, + worktree_path: Some(PathBuf::from("/tmp/project")), + worktree_branch: Some("feature/demo".to_string()), + }]; + + assert!( + activate_filter_selection( + &tmux, + &zoxide, + &filtered, + 0, + "", + Path::new("/fallback"), + false, + ) + .expect("worktree selection should create or switch") + ); + assert!(tmux.switched_sessions.borrow().is_empty()); + let created = tmux.created_sessions.borrow(); + assert_eq!(created.len(), 1); + assert!(created[0].0.starts_with("project-")); + assert_eq!(created[0].1, PathBuf::from("/tmp/project")); + } + + #[test] + fn create_session_from_worktree_path_uses_unique_names_for_same_leaf_directories() { + let tmux = StubTmuxClient::default(); + + crate::create_session_from_worktree_path(&tmux, Path::new("/tmp/repo-a/project")) + .expect("first worktree should create"); + crate::create_session_from_worktree_path(&tmux, Path::new("/var/repo-b/project")) + .expect("second worktree should create"); + + let created = tmux.created_sessions.borrow(); + assert_eq!(created.len(), 2); + assert_ne!(created[0].0, created[1].0); + assert!(created[0].0.starts_with("project-")); + assert!(created[1].0.starts_with("project-")); + } + + #[test] + fn stable_path_hash_is_deterministic() { + assert_eq!( + crate::stable_path_hash(Path::new("/tmp/repo-a/project")), + crate::stable_path_hash(Path::new("/tmp/repo-a/project")) + ); + assert_ne!( + crate::stable_path_hash(Path::new("/tmp/repo-a/project")), + crate::stable_path_hash(Path::new("/tmp/repo-b/project")) + ); + } + #[test] fn applies_session_sort_modes_and_keeps_current_discoverable() { let mut items = vec![ @@ -2270,6 +2510,19 @@ mod tests { assert_eq!(filtered[0].session_id, "alpha"); } + #[test] + fn session_items_for_picker_mode_returns_info_item_when_not_in_git_repo() { + let items = crate::session_items_for_picker_mode( + &wisp_core::DomainState::default(), + Some(crate::DEFAULT_CLIENT_ID), + wisp_core::PickerMode::Worktree, + ); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].kind, wisp_core::SessionListItemKind::Info); + assert_eq!(items[0].label, "not in a git repository"); + } + fn session_item(session_id: &str) -> wisp_core::SessionListItem { session_item_with_flags(session_id, false, false, None) } @@ -2283,6 +2536,7 @@ mod tests { wisp_core::SessionListItem { session_id: session_id.to_string(), label: session_id.to_string(), + kind: wisp_core::SessionListItemKind::Session, is_current, is_previous, last_activity, @@ -2293,6 +2547,8 @@ mod tests { path_hint: None, command_hint: None, git_branch: None, + worktree_path: None, + worktree_branch: None, } } diff --git a/crates/wisp-config/src/lib.rs b/crates/wisp-config/src/lib.rs index 92702a5..3d7dcc7 100644 --- a/crates/wisp-config/src/lib.rs +++ b/crates/wisp-config/src/lib.rs @@ -78,6 +78,7 @@ impl Default for ResolvedConfig { ctrl_p: KeyAction::TogglePreview, ctrl_d: KeyAction::ToggleDetails, ctrl_m: KeyAction::ToggleCompactSidebar, + ctrl_w: KeyAction::ToggleWorktreeMode, esc: KeyAction::Close, ctrl_c: KeyAction::Close, }, @@ -159,6 +160,7 @@ pub struct ActionsConfig { pub ctrl_p: KeyAction, pub ctrl_d: KeyAction, pub ctrl_m: KeyAction, + pub ctrl_w: KeyAction, pub esc: KeyAction, pub ctrl_c: KeyAction, } @@ -271,6 +273,7 @@ pub enum KeyAction { TogglePreview, ToggleDetails, ToggleCompactSidebar, + ToggleWorktreeMode, Close, } @@ -749,6 +752,9 @@ impl PartialConfig { if let Some(ctrl_m) = self.actions.ctrl_m { config.actions.ctrl_m = ctrl_m; } + if let Some(ctrl_w) = self.actions.ctrl_w { + config.actions.ctrl_w = ctrl_w; + } if let Some(esc) = self.actions.esc { config.actions.esc = esc; } @@ -912,6 +918,7 @@ struct PartialActionsConfig { ctrl_p: Option, ctrl_d: Option, ctrl_m: Option, + ctrl_w: Option, esc: Option, ctrl_c: Option, } @@ -931,6 +938,7 @@ impl PartialActionsConfig { merge_option(&mut self.ctrl_p, other.ctrl_p); merge_option(&mut self.ctrl_d, other.ctrl_d); merge_option(&mut self.ctrl_m, other.ctrl_m); + merge_option(&mut self.ctrl_w, other.ctrl_w); merge_option(&mut self.esc, other.esc); merge_option(&mut self.ctrl_c, other.ctrl_c); } @@ -1049,6 +1057,7 @@ mod tests { ); assert_eq!(config.actions.backspace, KeyAction::Backspace); assert_eq!(config.actions.ctrl_s, KeyAction::ToggleSort); + assert_eq!(config.actions.ctrl_w, KeyAction::ToggleWorktreeMode); } #[test] diff --git a/crates/wisp-core/src/candidate.rs b/crates/wisp-core/src/candidate.rs index ed740f3..ad2f8c4 100644 --- a/crates/wisp-core/src/candidate.rs +++ b/crates/wisp-core/src/candidate.rs @@ -12,6 +12,7 @@ pub enum CandidateKind { TmuxWindow, Directory, Project, + Worktree, } #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -19,6 +20,7 @@ pub enum CandidateId { Session(String), Window { session: String, index: u32 }, Directory(PathBuf), + Worktree(PathBuf), Project(String), } @@ -82,6 +84,30 @@ impl Candidate { } } + #[must_use] + pub fn worktree(metadata: WorktreeMetadata) -> Self { + let primary_text = metadata.display_path.clone(); + let mut search_terms = vec![ + metadata.display_path.clone(), + metadata.full_path.display().to_string(), + ]; + if let Some(branch) = &metadata.branch { + search_terms.push(branch.clone()); + } + + Self { + id: CandidateId::Worktree(metadata.full_path.clone()), + kind: CandidateKind::Worktree, + search_terms, + preview_key: PreviewKey::Directory(metadata.full_path.clone()), + score_hints: ScoreHints::default(), + action: CandidateAction::CreateOrSwitchSession, + metadata: CandidateMetadata::Worktree(metadata), + primary_text, + secondary_text: Some("worktree".to_string()), + } + } + #[must_use] pub fn searchable_text(&self) -> String { self.search_terms.join(" ") @@ -107,6 +133,7 @@ pub enum CandidateMetadata { Window(WindowMetadata), Directory(DirectoryMetadata), Project(ProjectMetadata), + Worktree(WorktreeMetadata), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -141,6 +168,13 @@ pub struct ProjectMetadata { pub root: PathBuf, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeMetadata { + pub full_path: PathBuf, + pub display_path: String, + pub branch: Option, +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ScoreHints { pub recency: Option, diff --git a/crates/wisp-core/src/lib.rs b/crates/wisp-core/src/lib.rs index b857ba2..70d4bdb 100644 --- a/crates/wisp-core/src/lib.rs +++ b/crates/wisp-core/src/lib.rs @@ -8,8 +8,8 @@ mod view; pub use action::{Action, CandidateAction, ResolvedAction, resolve_action, sanitize_session_name}; pub use candidate::{ Candidate, CandidateId, CandidateKind, CandidateMetadata, DirectoryMetadata, ScoreHints, - SessionMetadata, WindowMetadata, deduplicate_candidates, normalize_display_path, - sort_candidates, + SessionMetadata, WindowMetadata, WorktreeMetadata, deduplicate_candidates, + normalize_display_path, sort_candidates, }; pub use domain::{ AlertAggregate, AlertState, AttentionBadge, ClientFocus, ClientId, DirectoryRecord, @@ -21,17 +21,19 @@ pub use preview::{ }; pub use reduce::{DomainEvent, reduce_domain_event}; pub use view::{ - GitBranchStatus, GitBranchSync, SessionListItem, SessionListSortMode, StatusSessionItem, - derive_candidates, derive_session_list, derive_status_items, sort_session_list_items, + GitBranchStatus, GitBranchSync, PickerMode, SessionListItem, SessionListItemKind, + SessionListSortMode, StatusSessionItem, WorktreeInfo, derive_candidates, derive_session_list, + derive_session_list_with_worktrees, derive_status_items, sort_session_list_items, }; #[cfg(test)] mod tests { use std::path::PathBuf; + use crate::candidate::WorktreeMetadata; use crate::{ - Candidate, CandidateAction, CandidateMetadata, DirectoryMetadata, PreviewKey, PreviewKind, - ResolvedAction, ScoreHints, SessionMetadata, deduplicate_candidates, + Candidate, CandidateAction, CandidateId, CandidateMetadata, DirectoryMetadata, PreviewKey, + PreviewKind, ResolvedAction, ScoreHints, SessionMetadata, deduplicate_candidates, normalize_display_path, preview_request_for_candidate, resolve_action, sort_candidates, }; @@ -68,6 +70,34 @@ mod tests { assert_eq!(deduplicated[0].score_hints.source_score, Some(9)); } + #[test] + fn keeps_worktrees_distinct_from_directories_when_deduplicating() { + let directory = Candidate::directory(DirectoryMetadata { + full_path: PathBuf::from("/tmp/wisp"), + display_path: "/tmp/wisp".to_string(), + zoxide_score: Some(5.0), + git_root_hint: None, + exists: true, + }); + let worktree = Candidate::worktree(WorktreeMetadata { + full_path: PathBuf::from("/tmp/wisp"), + display_path: "/tmp/wisp".to_string(), + branch: Some("feature/demo".to_string()), + }); + + let deduplicated = deduplicate_candidates([directory, worktree]); + + assert_eq!(deduplicated.len(), 2); + assert!(deduplicated.iter().any(|candidate| matches!( + candidate.id, + CandidateId::Directory(ref path) if path == &PathBuf::from("/tmp/wisp") + ))); + assert!(deduplicated.iter().any(|candidate| matches!( + candidate.id, + CandidateId::Worktree(ref path) if path == &PathBuf::from("/tmp/wisp") + ))); + } + #[test] fn resolves_actions_from_candidate_metadata() { let session = Candidate::session(SessionMetadata { diff --git a/crates/wisp-core/src/preview.rs b/crates/wisp-core/src/preview.rs index 2b6d2fb..731bd8d 100644 --- a/crates/wisp-core/src/preview.rs +++ b/crates/wisp-core/src/preview.rs @@ -135,5 +135,38 @@ pub fn preview_request_for_candidate(candidate: &Candidate) -> PreviewRequest { key: candidate.preview_key.clone(), path: metadata.root.clone(), }, + CandidateMetadata::Worktree(metadata) => PreviewRequest::Directory { + key: candidate.preview_key.clone(), + path: metadata.full_path.clone(), + }, + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::{ + Candidate, PreviewKey, PreviewRequest, WorktreeMetadata, preview_request_for_candidate, + }; + + #[test] + fn maps_worktree_candidates_to_directory_previews_using_full_path() { + let full_path = PathBuf::from("/tmp/demo-worktree"); + let candidate = Candidate::worktree(WorktreeMetadata { + full_path: full_path.clone(), + display_path: "~/demo-worktree".to_string(), + branch: Some("feature/demo".to_string()), + }); + + let request = preview_request_for_candidate(&candidate); + + assert_eq!( + request, + PreviewRequest::Directory { + key: PreviewKey::Directory(full_path.clone()), + path: full_path, + } + ); } } diff --git a/crates/wisp-core/src/view.rs b/crates/wisp-core/src/view.rs index 2559cd2..23bc5df 100644 --- a/crates/wisp-core/src/view.rs +++ b/crates/wisp-core/src/view.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::{ AttentionBadge, Candidate, DirectoryMetadata, DirectoryRecord, DomainState, SessionMetadata, @@ -9,6 +9,7 @@ use crate::{ pub struct SessionListItem { pub session_id: String, pub label: String, + pub kind: SessionListItemKind, pub is_current: bool, pub is_previous: bool, pub last_activity: Option, @@ -19,6 +20,8 @@ pub struct SessionListItem { pub path_hint: Option, pub command_hint: Option, pub git_branch: Option, + pub worktree_path: Option, + pub worktree_branch: Option, } impl SessionListItem { @@ -27,8 +30,10 @@ impl SessionListItem { [ Some(self.label.as_str()), self.active_window_label.as_deref(), + self.path_hint.as_deref(), self.command_hint.as_deref(), self.git_branch.as_ref().map(|branch| branch.name.as_str()), + self.worktree_branch.as_deref(), ] .into_iter() .flatten() @@ -45,6 +50,24 @@ pub struct GitBranchStatus { pub dirty: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeInfo { + pub path: PathBuf, + pub branch: Option, + pub is_locked: bool, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum PickerMode { + #[default] + AllSessions, + Worktree, +} + +/// Synchronization state for a git branch in the UI. +/// +/// The current status pipeline only distinguishes whether the branch still needs to be pushed. +/// Branches that are only behind their upstream remain [`GitBranchSync::Pushed`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GitBranchSync { Unknown, @@ -52,6 +75,14 @@ pub enum GitBranchSync { NotPushed, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionListItemKind { + Info, // informational row, not an actionable session + Session, // regular tmux session (not in a worktree) + WorktreeSession, // session running in a worktree + Worktree, // worktree with no session +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct StatusSessionItem { pub session_id: String, @@ -118,6 +149,7 @@ pub fn derive_session_list(state: &DomainState, client_id: Option<&str>) -> Vec< SessionListItem { session_id: session_id.clone(), label: session.name.clone(), + kind: SessionListItemKind::Session, is_current: current == Some(session_id), is_previous: previous == Some(session_id), last_activity: session.sort_key.last_activity, @@ -133,6 +165,8 @@ pub fn derive_session_list(state: &DomainState, client_id: Option<&str>) -> Vec< }), command_hint: active_window.and_then(|window| window.active_command.clone()), git_branch: None, + worktree_path: None, + worktree_branch: None, } }) .collect::>(); @@ -141,6 +175,138 @@ pub fn derive_session_list(state: &DomainState, client_id: Option<&str>) -> Vec< items } +/// Derives a session list that shows only worktree-related items. +/// - Sessions in worktrees are shown as WorktreeSession +/// - Worktrees without sessions are shown as Worktree +/// - Regular sessions (not in any worktree) are excluded +pub fn derive_session_list_with_worktrees( + state: &DomainState, + client_id: Option<&str>, + worktrees: &[WorktreeInfo], +) -> Vec { + use std::collections::{BTreeMap, BTreeSet}; + + let current = state.current_session_id(client_id); + let previous = state.previous_session_id(client_id); + + // Build a map of worktree path -> worktree info for matching + let worktree_map: BTreeMap<&Path, &WorktreeInfo> = + worktrees.iter().map(|w| (w.path.as_path(), w)).collect(); + + // Find which worktree a path belongs to (if any) + fn find_worktree_for_path<'a>( + path: &Path, + worktree_map: &'a BTreeMap<&Path, &WorktreeInfo>, + ) -> Option<&'a WorktreeInfo> { + worktree_map + .iter() + .filter(|(wt_path, _)| path == **wt_path || path.starts_with(*wt_path)) + .max_by_key(|(wt_path, _)| wt_path.as_os_str().len()) + .map(|(_, wt)| *wt) + } + + // Process sessions: only include if they match a worktree + let mut items: Vec = state + .sessions + .iter() + .filter_map(|(session_id, session)| { + let active_window = session + .windows + .values() + .find(|window| window.active) + .or_else(|| session.windows.values().next()); + + let current_path = active_window.and_then(|w| w.current_path.as_deref()); + + let worktree = current_path.and_then(|p| find_worktree_for_path(p, &worktree_map))?; + + Some(SessionListItem { + session_id: session_id.clone(), + label: session.name.clone(), + kind: SessionListItemKind::WorktreeSession, + is_current: current == Some(session_id), + is_previous: previous == Some(session_id), + last_activity: session.sort_key.last_activity, + attached: session.attached, + attention: session.aggregate_alerts.highest_priority, + attention_count: session.aggregate_alerts.attention_count, + active_window_label: active_window.map(|window| window.name.clone()), + path_hint: active_window.and_then(|window| { + window + .current_path + .as_deref() + .map(|path| normalize_display_path(path, None)) + }), + command_hint: active_window.and_then(|window| window.active_command.clone()), + git_branch: Some(GitBranchStatus { + name: worktree + .branch + .clone() + .unwrap_or_else(|| "(detached)".to_string()), + sync: GitBranchSync::Unknown, + dirty: false, + }), + worktree_path: Some(worktree.path.clone()), + worktree_branch: worktree.branch.clone(), + }) + }) + .collect(); + + // Add worktrees that don't have matching sessions + let session_paths: BTreeSet<&Path> = state + .sessions + .iter() + .filter_map(|(_, session)| { + session + .windows + .values() + .find(|window| window.active) + .or_else(|| session.windows.values().next()) + .and_then(|w| w.current_path.as_deref()) + }) + .collect(); + + for worktree in worktrees { + let matched_session = session_paths.iter().any(|path| { + *path == worktree.path.as_path() || path.starts_with(worktree.path.as_path()) + }); + + if !matched_session { + let basename = worktree + .path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + items.push(SessionListItem { + session_id: format!("worktree:{}", worktree.path.display()), + label: basename, + kind: SessionListItemKind::Worktree, + is_current: false, + is_previous: false, + last_activity: None, + attached: false, + attention: AttentionBadge::None, + attention_count: 0, + active_window_label: None, + path_hint: Some(normalize_display_path(&worktree.path, None)), + command_hint: None, + git_branch: Some(GitBranchStatus { + name: worktree.branch.clone().unwrap_or_default(), + sync: GitBranchSync::Unknown, + dirty: false, + }), + worktree_path: Some(worktree.path.clone()), + worktree_branch: worktree.branch.clone(), + }); + } + } + + sort_session_list_items(&mut items, SessionListSortMode::Recent); + items +} + #[must_use] pub fn derive_status_items(state: &DomainState, client_id: Option<&str>) -> Vec { let current = state.current_session_id(client_id); @@ -200,9 +366,9 @@ mod tests { use crate::{ AlertAggregate, AttentionBadge, ClientFocus, DirectoryRecord, DomainState, GitBranchStatus, - GitBranchSync, SessionListItem, SessionListSortMode, SessionRecord, SessionSortKey, - WindowRecord, derive_candidates, derive_session_list, derive_status_items, - sort_session_list_items, + GitBranchSync, SessionListItem, SessionListItemKind, SessionListSortMode, SessionRecord, + SessionSortKey, WindowRecord, WorktreeInfo, derive_candidates, derive_session_list, + derive_session_list_with_worktrees, derive_status_items, sort_session_list_items, }; fn seeded_state() -> DomainState { @@ -343,6 +509,7 @@ mod tests { SessionListItem { session_id: "beta".to_string(), label: "beta".to_string(), + kind: SessionListItemKind::Session, is_current: false, is_previous: true, last_activity: Some(2), @@ -353,10 +520,13 @@ mod tests { path_hint: None, command_hint: None, git_branch: None, + worktree_path: None, + worktree_branch: None, }, SessionListItem { session_id: "alpha".to_string(), label: "alpha".to_string(), + kind: SessionListItemKind::Session, is_current: true, is_previous: false, last_activity: Some(3), @@ -367,10 +537,13 @@ mod tests { path_hint: None, command_hint: None, git_branch: None, + worktree_path: None, + worktree_branch: None, }, SessionListItem { session_id: "aardvark".to_string(), label: "aardvark".to_string(), + kind: SessionListItemKind::Session, is_current: false, is_previous: false, last_activity: Some(1), @@ -381,6 +554,8 @@ mod tests { path_hint: None, command_hint: None, git_branch: None, + worktree_path: None, + worktree_branch: None, }, ]; @@ -408,6 +583,7 @@ mod tests { let item = SessionListItem { session_id: "alpha".to_string(), label: "alpha".to_string(), + kind: SessionListItemKind::Session, is_current: false, is_previous: false, last_activity: None, @@ -422,6 +598,8 @@ mod tests { sync: GitBranchSync::Unknown, dirty: false, }), + worktree_path: None, + worktree_branch: None, }; assert_eq!( @@ -429,4 +607,125 @@ mod tests { "alpha editor nvim feature/picker-branches" ); } + + #[test] + fn picker_search_text_includes_path_hint() { + let item = SessionListItem { + session_id: "worktree:/tmp/demo/app".to_string(), + label: "app".to_string(), + kind: SessionListItemKind::Worktree, + is_current: false, + is_previous: false, + last_activity: None, + attached: false, + attention: AttentionBadge::None, + attention_count: 0, + active_window_label: None, + path_hint: Some("~/src/demo/app".to_string()), + command_hint: None, + git_branch: None, + worktree_path: Some(PathBuf::from("/tmp/demo/app")), + worktree_branch: Some("feature/demo".to_string()), + }; + + assert_eq!(item.picker_search_text(), "app ~/src/demo/app feature/demo"); + } + + #[test] + fn omits_worktree_rows_when_a_session_path_is_nested_inside_the_worktree() { + let state = seeded_state(); + let worktrees = vec![WorktreeInfo { + path: PathBuf::from("/tmp"), + branch: Some("main".to_string()), + is_locked: false, + }]; + + let items = derive_session_list_with_worktrees(&state, Some("client-1"), &worktrees); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].kind, SessionListItemKind::WorktreeSession); + assert_eq!( + items[0].worktree_path.as_deref(), + Some(std::path::Path::new("/tmp")) + ); + } + + #[test] + fn picks_the_deepest_matching_worktree_for_nested_paths() { + let state = seeded_state(); + let worktrees = vec![ + WorktreeInfo { + path: PathBuf::from("/tmp"), + branch: Some("root".to_string()), + is_locked: false, + }, + WorktreeInfo { + path: PathBuf::from("/tmp/alpha"), + branch: Some("nested".to_string()), + is_locked: false, + }, + ]; + + let items = derive_session_list_with_worktrees(&state, Some("client-1"), &worktrees); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].kind, SessionListItemKind::WorktreeSession); + assert_eq!( + items[0].worktree_path.as_deref(), + Some(std::path::Path::new("/tmp/alpha")) + ); + assert_eq!(items[0].worktree_branch.as_deref(), Some("nested")); + } + + #[test] + fn detached_worktrees_still_get_git_branch_status_placeholders() { + let state = seeded_state(); + let worktrees = vec![WorktreeInfo { + path: PathBuf::from("/tmp/detached"), + branch: None, + is_locked: false, + }]; + + let items = derive_session_list_with_worktrees(&state, Some("client-1"), &worktrees); + let detached = items + .into_iter() + .find(|item| item.kind == SessionListItemKind::Worktree) + .expect("detached worktree row"); + + assert_eq!( + detached.git_branch, + Some(GitBranchStatus { + name: String::new(), + sync: GitBranchSync::Unknown, + dirty: false, + }) + ); + } + + #[test] + fn sorts_worktree_projection_with_recent_ordering() { + let state = DomainState::default(); + let worktrees = vec![ + WorktreeInfo { + path: PathBuf::from("/tmp/zeta"), + branch: Some("zeta".to_string()), + is_locked: false, + }, + WorktreeInfo { + path: PathBuf::from("/tmp/alpha"), + branch: Some("alpha".to_string()), + is_locked: false, + }, + ]; + + let items = derive_session_list_with_worktrees(&state, None, &worktrees); + + assert_eq!( + items + .iter() + .map(|item| item.label.as_str()) + .collect::>(), + vec!["alpha", "zeta"] + ); + } } diff --git a/crates/wisp-ui/src/lib.rs b/crates/wisp-ui/src/lib.rs index ea82457..87d1518 100644 --- a/crates/wisp-ui/src/lib.rs +++ b/crates/wisp-ui/src/lib.rs @@ -7,7 +7,7 @@ use ratatui::{ text::{Line, Span, Text}, widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Widget}, }; -use wisp_core::{GitBranchStatus, GitBranchSync, SessionListItem}; +use wisp_core::{GitBranchStatus, GitBranchSync, PickerMode, SessionListItem, SessionListItemKind}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SurfaceKind { @@ -26,6 +26,7 @@ pub struct SurfaceModel { pub preview: Option>, pub kind: SurfaceKind, pub bindings: KeyBindings, + pub mode: PickerMode, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -42,6 +43,7 @@ pub enum UiIntent { ToggleCompactSidebar, TogglePreview, ToggleDetails, + ToggleWorktreeMode, Close, } @@ -62,6 +64,7 @@ pub struct KeyBindings { pub ctrl_m: UiIntent, pub esc: UiIntent, pub ctrl_c: UiIntent, + pub ctrl_w: UiIntent, } impl Default for KeyBindings { @@ -82,6 +85,7 @@ impl Default for KeyBindings { ctrl_m: UiIntent::ToggleCompactSidebar, esc: UiIntent::Close, ctrl_c: UiIntent::Close, + ctrl_w: UiIntent::ToggleWorktreeMode, } } } @@ -132,6 +136,9 @@ pub fn translate_key(key: KeyEvent, bindings: &KeyBindings) -> Option KeyCode::Char('m') if key.modifiers.contains(KeyModifiers::CONTROL) => { Some(bindings.ctrl_m.clone()) } + KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => { + Some(bindings.ctrl_w.clone()) + } KeyCode::Backspace => Some(bindings.backspace.clone()), KeyCode::Char(character) if !key @@ -159,6 +166,7 @@ fn render_picker(area: Rect, buffer: &mut Buffer, model: &SurfaceModel) { buffer, model.title.as_str(), Text::from(model.query.as_str()), + false, ); let body_chunks = if model.preview.is_some() { @@ -181,6 +189,7 @@ fn render_picker(area: Rect, buffer: &mut Buffer, model: &SurfaceModel) { buffer, "Preview", ansi_preview_text(preview), + true, ); } @@ -202,6 +211,7 @@ fn render_sidebar(area: Rect, buffer: &mut Buffer, model: &SurfaceModel) { buffer, model.title.as_str(), Text::from(model.query.as_str()), + false, ); render_list( @@ -256,7 +266,9 @@ fn render_list(area: Rect, buffer: &mut Buffer, model: &SurfaceModel, compact: b .iter() .enumerate() .map(|(index, item)| { - let marker = if item.is_current { + let marker = if matches!(item.kind, SessionListItemKind::Worktree) { + "W" + } else if item.is_current { "•" } else if item.is_previous { "‹›" @@ -290,13 +302,12 @@ fn render_list(area: Rect, buffer: &mut Buffer, model: &SurfaceModel, compact: b let title_width = available_width .saturating_sub(marker_width + session_width + gap_width + branch_space); let session = pad_text(&truncate_text(&item.label, session_width), session_width); - let title = pad_text( - &truncate_text( - item.active_window_label.as_deref().unwrap_or_default(), - title_width, - ), - title_width, - ); + let title_source = item + .active_window_label + .as_deref() + .or(item.path_hint.as_deref()) + .unwrap_or_default(); + let title = pad_text(&truncate_text(title_source, title_width), title_width); let prefix = if branch_width == 0 { format!("{icon} {session} {title}") } else { @@ -346,12 +357,47 @@ fn render_footer(area: Rect, buffer: &mut Buffer, model: &SurfaceModel) { Paragraph::new(text).render(inner, buffer); } -fn render_boxed_paragraph(area: Rect, buffer: &mut Buffer, title: &str, text: Text<'_>) { +fn render_boxed_paragraph( + area: Rect, + buffer: &mut Buffer, + title: &str, + text: Text<'_>, + center_single_line: bool, +) { let block = rounded_block(title); let inner = block.inner(area); block.render(area, buffer); Clear.render(inner, buffer); - Paragraph::new(text).render(inner, buffer); + + // Center single-line content both vertically and horizontally. + let lines = text.lines.len(); + if center_single_line && lines == 1 { + let line = &text.lines[0]; + let line_width: usize = line.spans.iter().map(|s| s.content.chars().count()).sum(); + if line_width < usize::from(inner.width) { + let horizontal_pad = (usize::from(inner.width) - line_width) / 2; + let vertical_pad = if inner.height > 1 { + usize::from(inner.height) / 2 + } else { + 0 + }; + + let mut centered_spans = vec![Span::raw(" ".repeat(horizontal_pad))]; + centered_spans.extend(line.spans.iter().cloned()); + + let mut centered_text = Vec::with_capacity(vertical_pad + 1); + for _ in 0..vertical_pad { + centered_text.push(Line::from("")); + } + centered_text.push(Line::from(centered_spans)); + + Paragraph::new(Text::from(centered_text)).render(inner, buffer); + } else { + Paragraph::new(text).render(inner, buffer); + } + } else { + Paragraph::new(text).render(inner, buffer); + } } fn rounded_block(title: &str) -> Block<'_> { @@ -363,7 +409,7 @@ fn rounded_block(title: &str) -> Block<'_> { fn bindings_help_text(bindings: &KeyBindings) -> String { format!( - "down {} up {} ^j {} ^k {} enter {} S-enter {} backspace {} ^r {} ^s {} ^x {} ^p {} ^d {} ^m {} esc {} ^c {}", + "down {} up {} ^j {} ^k {} enter {} S-enter {} backspace {} ^r {} ^s {} ^x {} ^p {} ^d {} ^m {} ^w {} esc {} ^c {}", intent_label(&bindings.down), intent_label(&bindings.up), intent_label(&bindings.ctrl_j), @@ -377,6 +423,7 @@ fn bindings_help_text(bindings: &KeyBindings) -> String { intent_label(&bindings.ctrl_p), intent_label(&bindings.ctrl_d), intent_label(&bindings.ctrl_m), + intent_label(&bindings.ctrl_w), intent_label(&bindings.esc), intent_label(&bindings.ctrl_c), ) @@ -405,6 +452,7 @@ fn intent_label(intent: &UiIntent) -> &'static str { UiIntent::SelectPrev => "move up", UiIntent::FilterChanged(_) => "filter", UiIntent::Backspace => "backspace", + UiIntent::ToggleWorktreeMode => "worktree", } } @@ -656,8 +704,9 @@ fn ansi_named_color(code: u16) -> Color { mod tests { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::buffer::Buffer; + use ratatui::layout::Rect; use ratatui::style::Color; - use wisp_core::{AttentionBadge, SessionListItem}; + use wisp_core::{AttentionBadge, PickerMode, SessionListItem, SessionListItemKind}; use crate::{ KeyBindings, SurfaceKind, SurfaceModel, UiIntent, ansi_preview_text, render_surface, @@ -668,6 +717,7 @@ mod tests { SessionListItem { session_id: label.to_string(), label: label.to_string(), + kind: SessionListItemKind::Session, is_current: false, is_previous: false, last_activity: None, @@ -678,12 +728,14 @@ mod tests { path_hint: None, command_hint: None, git_branch: None, + worktree_path: None, + worktree_branch: None, } } #[test] fn renders_picker_with_preview() { - let mut buffer = Buffer::empty(ratatui::layout::Rect::new(0, 0, 60, 12)); + let mut buffer = Buffer::empty(Rect::new(0, 0, 60, 12)); let model = SurfaceModel { title: "Wisp Picker".to_string(), query: "alp".to_string(), @@ -693,6 +745,7 @@ mod tests { preview: Some(vec!["preview line".to_string()]), kind: SurfaceKind::Picker, bindings: KeyBindings::default(), + mode: PickerMode::AllSessions, }; render_surface(buffer.area, &mut buffer, &model); @@ -725,7 +778,7 @@ mod tests { #[test] fn renders_compact_sidebar() { - let mut buffer = Buffer::empty(ratatui::layout::Rect::new(0, 0, 30, 10)); + let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 10)); let mut current = item("alpha"); current.is_current = true; current.attention = AttentionBadge::Bell; @@ -738,6 +791,7 @@ mod tests { preview: None, kind: SurfaceKind::SidebarCompact, bindings: KeyBindings::default(), + mode: PickerMode::AllSessions, }; render_surface(buffer.area, &mut buffer, &model); @@ -751,6 +805,66 @@ mod tests { assert!(rendered.contains("•! alpha")); } + #[test] + fn renders_worktree_rows_with_path_hint_in_sidebar() { + let mut buffer = Buffer::empty(Rect::new(0, 0, 50, 10)); + let model = SurfaceModel { + title: "Sidebar".to_string(), + query: String::new(), + items: vec![SessionListItem { + session_id: "worktree:/tmp/demo/app".to_string(), + label: "app".to_string(), + kind: SessionListItemKind::Worktree, + is_current: false, + is_previous: false, + last_activity: None, + attached: false, + attention: AttentionBadge::None, + attention_count: 0, + active_window_label: None, + path_hint: Some("~/src/demo/app".to_string()), + command_hint: None, + git_branch: None, + worktree_path: Some(std::path::PathBuf::from("/tmp/demo/app")), + worktree_branch: Some("feature/demo".to_string()), + }], + selected: 0, + show_help: false, + preview: None, + kind: SurfaceKind::SidebarExpanded, + bindings: KeyBindings::default(), + mode: PickerMode::Worktree, + }; + + render_surface(buffer.area, &mut buffer, &model); + + let rendered = buffer + .content + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(rendered.contains("app")); + assert!(rendered.contains("~/src/demo/app")); + } + + #[test] + fn centers_single_line_boxed_paragraph_horizontally_when_inner_height_is_one() { + let mut buffer = Buffer::empty(Rect::new(0, 0, 12, 3)); + + super::render_boxed_paragraph( + buffer.area, + &mut buffer, + "", + ratatui::text::Text::from("hi"), + true, + ); + + let row = (0..usize::from(buffer.area.width)) + .map(|x| buffer[(x as u16, 1)].symbol()) + .collect::(); + assert!(row.contains(" hi")); + } + #[test] fn translates_supported_keys() { assert_eq!( @@ -816,6 +930,13 @@ mod tests { ), Some(UiIntent::TogglePreview) ); + assert_eq!( + translate_key( + KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), + &KeyBindings::default(), + ), + Some(UiIntent::ToggleWorktreeMode) + ); assert_eq!( translate_key( KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), diff --git a/crates/wisp-zoxide/tests/integration.rs b/crates/wisp-zoxide/tests/integration.rs index 9db564c..f44660d 100644 --- a/crates/wisp-zoxide/tests/integration.rs +++ b/crates/wisp-zoxide/tests/integration.rs @@ -1,7 +1,7 @@ use std::{ fs, os::unix::fs::PermissionsExt, - path::PathBuf, + path::{Path, PathBuf}, time::{SystemTime, UNIX_EPOCH}, }; @@ -15,6 +15,18 @@ fn unique_root() -> PathBuf { std::env::temp_dir().join(format!("wisp-zoxide-test-{nonce}")) } +fn write_fake_zoxide_script(script: &Path, contents: String) { + let staging = script.with_extension("tmp"); + fs::write(&staging, contents).expect("fake zoxide script"); + + let mut permissions = fs::metadata(&staging) + .expect("script metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&staging, permissions).expect("executable fake zoxide"); + fs::rename(&staging, script).expect("publish fake zoxide"); +} + #[test] fn loads_entries_from_a_fake_zoxide_binary() { let root = unique_root(); @@ -24,20 +36,14 @@ fn loads_entries_from_a_fake_zoxide_binary() { fs::create_dir_all(&workspace).expect("workspace directory"); let script = bin_dir.join("zoxide"); - fs::write( + write_fake_zoxide_script( &script, format!( "#!/bin/sh\nprintf '12.5 {workspace}\\n4.0 {workspace}/../workspace\\n99.0 {root}/missing\\n'\n", workspace = workspace.display(), root = root.display(), ), - ) - .expect("fake zoxide script"); - let mut permissions = fs::metadata(&script) - .expect("script metadata") - .permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&script, permissions).expect("executable fake zoxide"); + ); let entries = CommandZoxideProvider::new() .with_binary(&script) @@ -61,20 +67,14 @@ fn queries_the_best_matching_directory() { fs::create_dir_all(&nested).expect("nested workspace directory"); let script = bin_dir.join("zoxide"); - fs::write( + write_fake_zoxide_script( &script, format!( "#!/bin/sh\nif [ \"$1\" = \"query\" ] && [ \"$4\" = \"dev\" ] && [ \"$5\" = \"shell\" ]; then\n printf '90.0 {nested}\\n12.0 {workspace}\\n'\nelse\n exit 1\nfi\n", nested = nested.display(), workspace = workspace.display(), ), - ) - .expect("fake zoxide script"); - let mut permissions = fs::metadata(&script) - .expect("script metadata") - .permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&script, permissions).expect("executable fake zoxide"); + ); let entry = CommandZoxideProvider::new() .with_binary(&script) diff --git a/docs/config.schema.toml b/docs/config.schema.toml index af822f1..e7dc89f 100644 --- a/docs/config.schema.toml +++ b/docs/config.schema.toml @@ -109,6 +109,7 @@ truncate_long_lines = true # "toggle-details" # "toggle-compact-sidebar" # "toggle-sort" +# "toggle-worktree-mode" # "close" # Defaults for the supported special keys: down = "move-down" @@ -126,6 +127,7 @@ ctrl_m = "toggle-compact-sidebar" ctrl_s = "toggle-sort" esc = "close" ctrl_c = "close" +ctrl_w = "toggle-worktree-mode" [logging] # Log verbosity: "error", "warn", "info", "debug", or "trace". diff --git a/docs/configuration.md b/docs/configuration.md index f83d205..1a10bfc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -97,7 +97,7 @@ The config library supports a strict mode that rejects unknown TOML keys using ` | Key | Type | Default | Valid values | Notes | | --- | --- | --- | --- | --- | -| `down` | string | `"move-down"` | `move-down`, `move-up`, `open`, `create-session-from-query`, `backspace`, `rename-session`, `close-session`, `toggle-preview`, `toggle-details`, `toggle-compact-sidebar`, `toggle-sort`, `close` | Action bound to Down Arrow. | +| `down` | string | `"move-down"` | `move-down`, `move-up`, `open`, `create-session-from-query`, `backspace`, `rename-session`, `close-session`, `toggle-preview`, `toggle-details`, `toggle-compact-sidebar`, `toggle-sort`, `toggle-worktree-mode`, `close` | Action bound to Down Arrow. | | `up` | string | `"move-up"` | same as above | Action bound to Up Arrow. | | `ctrl_j` | string | `"move-down"` | same as above | Action bound to Ctrl-J. | | `ctrl_k` | string | `"move-up"` | same as above | Action bound to Ctrl-K. | @@ -112,6 +112,7 @@ The config library supports a strict mode that rejects unknown TOML keys using ` | `ctrl_s` | string | `"toggle-sort"` | same as above | Action bound to Ctrl-S. | | `esc` | string | `"close"` | same as above | Action bound to Escape. | | `ctrl_c` | string | `"close"` | same as above | Action bound to Ctrl-C. | +| `ctrl_w` | string | `"toggle-worktree-mode"` | same as above | Action bound to Ctrl-W. | These bindings control every special picker shortcut Wisp shows in its inline help footer. Plain character input still appends to the filter query, but navigation, backspace, creation, preview toggles, sort toggles, and close keys are all config-backed. The default `Ctrl-S` binding toggles between the recent picker order and stable alphabetical order without losing the current selection. When rename mode is active, the input box edits the selected session name, `Enter` commits, and `Esc` or `Ctrl-C` cancels. From 109b0878e68835139107960d95a15b7cf852020d Mon Sep 17 00:00:00 2001 From: Emma <817422+Pajn@users.noreply.github.com> Date: Sat, 21 Mar 2026 11:13:32 +0100 Subject: [PATCH 2/2] Prepare for publishing --- .github/workflows/release.yml | 67 ++++++++++++++++++++++++++++++++++ AGENTS.md | 4 +- Cargo.lock | 20 +++++----- Cargo.toml | 10 +++++ LICENSE | 21 +++++++++++ README.md | 16 +++++++- crates/wisp-app/Cargo.toml | 16 +++++--- crates/wisp-bin/Cargo.toml | 37 +++++++++++++------ crates/wisp-config/Cargo.toml | 8 +++- crates/wisp-core/Cargo.toml | 8 +++- crates/wisp-fuzzy/Cargo.toml | 8 +++- crates/wisp-preview/Cargo.toml | 12 ++++-- crates/wisp-status/Cargo.toml | 5 ++- crates/wisp-tmux/Cargo.toml | 8 +++- crates/wisp-ui/Cargo.toml | 12 ++++-- crates/wisp-zoxide/Cargo.toml | 8 +++- docs/architecture.md | 4 +- 17 files changed, 210 insertions(+), 54 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 LICENSE diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cee0c5a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,67 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + create-release: + name: Create Release + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Create GitHub release + uses: taiki-e/create-gh-release-action@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + upload-binaries: + name: Build ${{ matrix.target }} + if: startsWith(github.ref, 'refs/tags/v') + needs: create-release + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + build-tool: cargo-zigbuild + - os: macos-26 + target: aarch64-apple-darwin + build-tool: cargo + - os: windows-latest + target: x86_64-pc-windows-msvc + build-tool: cargo + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.90 + targets: ${{ matrix.target }} + + - name: Upload release archive + uses: taiki-e/upload-rust-binary-action@v1 + with: + bin: wisp + package: wisp + manifest-path: crates/wisp-bin/Cargo.toml + target: ${{ matrix.target }} + archive: wisp-$target + build-tool: ${{ matrix.build-tool }} + include: README.md LICENSE + checksum: sha256 + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 02a2535..6152955 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -Wisp is a Rust 2024 tmux navigation workspace built around one canonical session model in `wisp-core`, thin integration adapters (`wisp-tmux`, `wisp-zoxide`, `wisp-preview`), and multiple projections/renderers (`wisp-ui`, `wisp-status`, `wisp-bin`). Keep logic in the lowest pure crate that can own it. +Wisp is a Rust 2024 tmux navigation workspace built around one canonical session model in `wisp-core`, thin integration adapters (`wisp-tmux`, `wisp-zoxide`, `wisp-preview`), and multiple projections/renderers (`wisp-ui`, `wisp-status`, `wisp`). Keep logic in the lowest pure crate that can own it. ## Testing expectations @@ -9,7 +9,7 @@ Wisp is a Rust 2024 tmux navigation workspace built around one canonical session - `cargo clippy --workspace --all-targets --all-features -- -D warnings` - `cargo test --workspace --all-targets` - If you touch tmux integration, run the real isolated-socket tests in `crates/wisp-tmux/tests/integration.rs`. -- If you touch CLI behavior, run `cargo test -p wisp-bin --test smoke`. +- If you touch CLI behavior, run `cargo test -p wisp --test smoke`. - If you touch hot-path projections or status formatting, make sure the benches still compile. ## Performance expectations diff --git a/Cargo.lock b/Cargo.lock index 7120e13..85230cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1174,30 +1174,30 @@ dependencies = [ ] [[package]] -name = "wisp-app" +name = "wisp" version = "0.1.0" dependencies = [ + "argh", + "crossterm", + "ratatui", + "wisp-app", "wisp-config", "wisp-core", + "wisp-fuzzy", + "wisp-preview", + "wisp-status", "wisp-tmux", + "wisp-ui", "wisp-zoxide", ] [[package]] -name = "wisp-bin" +name = "wisp-app" version = "0.1.0" dependencies = [ - "argh", - "crossterm", - "ratatui", - "wisp-app", "wisp-config", "wisp-core", - "wisp-fuzzy", - "wisp-preview", - "wisp-status", "wisp-tmux", - "wisp-ui", "wisp-zoxide", ] diff --git a/Cargo.toml b/Cargo.toml index 78e7f0c..3aad8f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ version = "0.1.0" edition = "2024" rust-version = "1.90" license = "MIT" +repository = "https://github.com/Pajn/wisp" publish = false [workspace.lints.rust] @@ -38,3 +39,12 @@ serde = { version = "1", features = ["derive"] } serde_ignored = "0.1" thiserror = "2" toml = "0.8" +wisp-app = { version = "0.1.0", path = "crates/wisp-app" } +wisp-config = { version = "0.1.0", path = "crates/wisp-config" } +wisp-core = { version = "0.1.0", path = "crates/wisp-core" } +wisp-fuzzy = { version = "0.1.0", path = "crates/wisp-fuzzy" } +wisp-preview = { version = "0.1.0", path = "crates/wisp-preview" } +wisp-status = { version = "0.1.0", path = "crates/wisp-status" } +wisp-tmux = { version = "0.1.0", path = "crates/wisp-tmux" } +wisp-ui = { version = "0.1.0", path = "crates/wisp-ui" } +wisp-zoxide = { version = "0.1.0", path = "crates/wisp-zoxide" } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b243c95 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Emma + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4bd81aa..8deb116 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Wisp is a native Rust tmux navigation tool inspired by `tmux-sessionx`. It share - `wisp-ui`: shared ratatui renderers and key translation - `wisp-status`: status-line formatting and dedup logic - `wisp-app`: app-facing state assembly helpers -- `wisp-bin`: CLI entrypoint and runtime wiring +- `wisp`: CLI entrypoint and runtime wiring ## Quick start @@ -39,12 +39,24 @@ Install the CLI: cargo install --git https://github.com/Pajn/wisp.git --bin wisp ``` +Install the latest tagged binary with `cargo-binstall`: + +```bash +cargo binstall --git https://github.com/Pajn/wisp.git wisp +``` + If you want a specific revision while the project is evolving, pin a branch, tag, or commit: ```bash cargo install --git https://github.com/Pajn/wisp.git --bin wisp --branch main ``` +For a specific tagged release with `cargo-binstall`, pin the tag: + +```bash +cargo binstall --git https://github.com/Pajn/wisp.git wisp --tag v0.1.0 +``` + Common commands after install: ```bash @@ -111,7 +123,7 @@ Config file discovery: cargo fmt --check cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test --workspace --all-targets -cargo test -p wisp-bin --test smoke +cargo test -p wisp --test smoke cargo bench -p wisp-core --bench projections --no-run cargo bench -p wisp-status --bench formatting --no-run ``` diff --git a/crates/wisp-app/Cargo.toml b/crates/wisp-app/Cargo.toml index b60326b..4597aa4 100644 --- a/crates/wisp-app/Cargo.toml +++ b/crates/wisp-app/Cargo.toml @@ -1,13 +1,17 @@ [package] name = "wisp-app" -version = "0.1.0" -edition = "2024" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "application state assembly for Wisp" [dependencies] -wisp-config = { path = "../wisp-config" } -wisp-core = { path = "../wisp-core" } -wisp-tmux = { path = "../wisp-tmux" } -wisp-zoxide = { path = "../wisp-zoxide" } +wisp-config.workspace = true +wisp-core.workspace = true +wisp-tmux.workspace = true +wisp-zoxide.workspace = true [lints] workspace = true diff --git a/crates/wisp-bin/Cargo.toml b/crates/wisp-bin/Cargo.toml index 87639e9..7e84e87 100644 --- a/crates/wisp-bin/Cargo.toml +++ b/crates/wisp-bin/Cargo.toml @@ -1,25 +1,38 @@ [package] -name = "wisp-bin" -version = "0.1.0" -edition = "2024" +name = "wisp" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "tmux-aware workspace navigator and status UI" [[bin]] name = "wisp" path = "src/main.rs" +[package.metadata.binstall] +pkg-url = "{ repo }/releases/download/v{ version }/{ bin }-{ target }.{ archive-format }" +pkg-fmt = "tar.gz" +bin-dir = "{ bin }{ binary-ext }" + +[package.metadata.binstall.overrides.x86_64-pc-windows-msvc] +pkg-fmt = "zip" +bin-dir = "{ bin }.exe" + [dependencies] argh = "0.1" crossterm.workspace = true ratatui.workspace = true -wisp-app = { path = "../wisp-app" } -wisp-config = { path = "../wisp-config" } -wisp-core = { path = "../wisp-core" } -wisp-fuzzy = { path = "../wisp-fuzzy" } -wisp-preview = { path = "../wisp-preview" } -wisp-status = { path = "../wisp-status" } -wisp-tmux = { path = "../wisp-tmux" } -wisp-ui = { path = "../wisp-ui" } -wisp-zoxide = { path = "../wisp-zoxide" } +wisp-app.workspace = true +wisp-config.workspace = true +wisp-core.workspace = true +wisp-fuzzy.workspace = true +wisp-preview.workspace = true +wisp-status.workspace = true +wisp-tmux.workspace = true +wisp-ui.workspace = true +wisp-zoxide.workspace = true [lints] workspace = true diff --git a/crates/wisp-config/Cargo.toml b/crates/wisp-config/Cargo.toml index 32f2db2..c9b35e9 100644 --- a/crates/wisp-config/Cargo.toml +++ b/crates/wisp-config/Cargo.toml @@ -1,7 +1,11 @@ [package] name = "wisp-config" -version = "0.1.0" -edition = "2024" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "configuration loading and validation for Wisp" [dependencies] serde.workspace = true diff --git a/crates/wisp-core/Cargo.toml b/crates/wisp-core/Cargo.toml index 7f471e6..eee5eae 100644 --- a/crates/wisp-core/Cargo.toml +++ b/crates/wisp-core/Cargo.toml @@ -1,7 +1,11 @@ [package] name = "wisp-core" -version = "0.1.0" -edition = "2024" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "core domain model and projections for Wisp" [dependencies] diff --git a/crates/wisp-fuzzy/Cargo.toml b/crates/wisp-fuzzy/Cargo.toml index 8b9d48c..b28a578 100644 --- a/crates/wisp-fuzzy/Cargo.toml +++ b/crates/wisp-fuzzy/Cargo.toml @@ -1,7 +1,11 @@ [package] name = "wisp-fuzzy" -version = "0.1.0" -edition = "2024" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "stable fuzzy matching abstraction for Wisp" [dependencies] diff --git a/crates/wisp-preview/Cargo.toml b/crates/wisp-preview/Cargo.toml index 45cd5e8..aa41581 100644 --- a/crates/wisp-preview/Cargo.toml +++ b/crates/wisp-preview/Cargo.toml @@ -1,12 +1,16 @@ [package] name = "wisp-preview" -version = "0.1.0" -edition = "2024" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "preview generation and caching for Wisp" [dependencies] thiserror.workspace = true -wisp-core = { path = "../wisp-core" } -wisp-tmux = { path = "../wisp-tmux" } +wisp-core.workspace = true +wisp-tmux.workspace = true [lints] workspace = true diff --git a/crates/wisp-status/Cargo.toml b/crates/wisp-status/Cargo.toml index f69b7a1..dae9943 100644 --- a/crates/wisp-status/Cargo.toml +++ b/crates/wisp-status/Cargo.toml @@ -4,10 +4,11 @@ version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true -publish.workspace = true +repository.workspace = true +description = "tmux status-line formatting for Wisp" [dependencies] -wisp-core = { path = "../wisp-core" } +wisp-core.workspace = true [lints] workspace = true diff --git a/crates/wisp-tmux/Cargo.toml b/crates/wisp-tmux/Cargo.toml index 108c165..3522cde 100644 --- a/crates/wisp-tmux/Cargo.toml +++ b/crates/wisp-tmux/Cargo.toml @@ -1,7 +1,11 @@ [package] name = "wisp-tmux" -version = "0.1.0" -edition = "2024" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "tmux backend integration for Wisp" [dependencies] thiserror.workspace = true diff --git a/crates/wisp-ui/Cargo.toml b/crates/wisp-ui/Cargo.toml index 202da8c..a57e19c 100644 --- a/crates/wisp-ui/Cargo.toml +++ b/crates/wisp-ui/Cargo.toml @@ -1,13 +1,17 @@ [package] name = "wisp-ui" -version = "0.1.0" -edition = "2024" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "shared terminal UI renderers for Wisp" [dependencies] crossterm.workspace = true ratatui.workspace = true -wisp-core = { path = "../wisp-core" } -wisp-fuzzy = { path = "../wisp-fuzzy" } +wisp-core.workspace = true +wisp-fuzzy.workspace = true [lints] workspace = true diff --git a/crates/wisp-zoxide/Cargo.toml b/crates/wisp-zoxide/Cargo.toml index 1f6a4b2..1a06f16 100644 --- a/crates/wisp-zoxide/Cargo.toml +++ b/crates/wisp-zoxide/Cargo.toml @@ -1,7 +1,11 @@ [package] name = "wisp-zoxide" -version = "0.1.0" -edition = "2024" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "zoxide integration for Wisp" [dependencies] thiserror.workspace = true diff --git a/docs/architecture.md b/docs/architecture.md index 41a440e..8bff5d6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,13 +43,13 @@ These crates consume projections rather than owning session state directly. ### 4. Runtime wiring -`wisp-app` assembles candidate sources into domain state, while the `wisp-bin` crate powers the installed `wisp` CLI that loads config, creates adapters, runs TUI surfaces, and exposes top-level commands like `doctor`, `popup`, `sidebar-pane`, and `status-line`. +`wisp-app` assembles candidate sources into domain state, while the `wisp` crate powers the installed CLI that loads config, creates adapters, runs TUI surfaces, and exposes top-level commands like `doctor`, `popup`, `sidebar-pane`, and `status-line`. ## Data flow Typical flow: -1. `wisp-bin` loads config and asks `wisp-tmux` for a snapshot. +1. `wisp` loads config and asks `wisp-tmux` for a snapshot. 2. `wisp-zoxide` contributes directory data. 3. `wisp-app` builds domain state. 4. `wisp-core` derives session lists, candidates, previews, and status projections.