diff --git a/rust/host/src/kwt.rs b/rust/host/src/kwt.rs index 40687a31..c78b5ed4 100644 --- a/rust/host/src/kwt.rs +++ b/rust/host/src/kwt.rs @@ -3,6 +3,13 @@ use std::sync::Arc; use serde::{Deserialize, Deserializer, de}; +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum KwtTmuxAttachMode { + Direct, + Protected, +} + #[derive(Clone, Eq, PartialEq)] pub struct KwtBundle { revision: String, @@ -206,6 +213,7 @@ pub struct KwtWorktree { repository: String, session_name: String, tmux_socket_name: Option, + tmux_attach_mode: KwtTmuxAttachMode, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] @@ -278,6 +286,7 @@ pub struct KwtImportedWorkspace { session_name: String, #[serde(default)] tmux_socket_name: Option, + tmux_attach_mode: KwtTmuxAttachMode, } #[derive(Debug, Deserialize)] @@ -363,6 +372,7 @@ pub struct KwtWorktreeOpen { registration_fingerprint: String, generation: String, session_name: String, + tmux_socket_name: Option, } impl KwtWorktreeOpen { @@ -373,6 +383,7 @@ impl KwtWorktreeOpen { registration_fingerprint: impl Into, generation: impl Into, session_name: impl Into, + tmux_socket_name: Option, ) -> Self { Self { path: path.into(), @@ -380,6 +391,7 @@ impl KwtWorktreeOpen { registration_fingerprint: registration_fingerprint.into(), generation: generation.into(), session_name: session_name.into(), + tmux_socket_name, } } @@ -403,6 +415,11 @@ impl KwtWorktreeOpen { pub fn session_name(&self) -> &str { &self.session_name } + + #[must_use] + pub fn tmux_socket_name(&self) -> Option<&str> { + self.tmux_socket_name.as_deref() + } } impl KwtWorktreeCreate { @@ -556,6 +573,9 @@ pub(crate) fn parse_pull_request_import(output: &[u8]) -> Result Option<&str> { self.tmux_socket_name.as_deref() } + #[must_use] + pub const fn tmux_attach_mode(&self) -> KwtTmuxAttachMode { + self.tmux_attach_mode + } } impl KwtPullRequestImport { @@ -673,6 +697,47 @@ impl KwtWorktree { pub fn tmux_socket_name(&self) -> Option<&str> { self.tmux_socket_name.as_deref() } + #[must_use] + pub const fn tmux_attach_mode(&self) -> KwtTmuxAttachMode { + self.tmux_attach_mode + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct KwtDirectoryWorkspaceOpen { + path: String, + session_name: String, + tmux_socket_name: Option, +} + +impl KwtDirectoryWorkspaceOpen { + #[must_use] + pub fn new( + path: impl Into, + session_name: impl Into, + tmux_socket_name: Option, + ) -> Self { + Self { + path: path.into(), + session_name: session_name.into(), + tmux_socket_name, + } + } + + #[must_use] + pub fn path(&self) -> &str { + &self.path + } + + #[must_use] + pub fn session_name(&self) -> &str { + &self.session_name + } + + #[must_use] + pub fn tmux_socket_name(&self) -> Option<&str> { + self.tmux_socket_name.as_deref() + } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] @@ -682,6 +747,8 @@ pub struct KwtDirectoryWorkspace { path: String, session_name: String, session_live: bool, + tmux_socket_name: Option, + tmux_attach_mode: KwtTmuxAttachMode, } impl KwtDirectoryWorkspace { @@ -701,6 +768,14 @@ impl KwtDirectoryWorkspace { pub const fn session_live(&self) -> bool { self.session_live } + #[must_use] + pub fn tmux_socket_name(&self) -> Option<&str> { + self.tmux_socket_name.as_deref() + } + #[must_use] + pub const fn tmux_attach_mode(&self) -> KwtTmuxAttachMode { + self.tmux_attach_mode + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -793,8 +868,8 @@ mod tests { fn inventory_joins_global_worktrees_without_reordering_projects() { let inventory = KwtInventory::parse( br#"[{"repository":"two","name":"Second","path":"/r/two","last_touched":null,"registration_fingerprint":"two-fingerprint"},{"repository":"one","name":"First","path":"/r/one","last_touched":"now","registration_fingerprint":"one-fingerprint"}]"#, - br#"[{"path":"/w/one","branch":"main","commit_hash":"abc","is_main":true,"created_at":null,"generation":"g1","repository":"one","session_name":"one-main","tmux_socket_name":null},{"path":"/w/two","branch":"topic","commit_hash":"def","is_main":false,"created_at":"then","generation":null,"repository":"two","session_name":"two-topic","tmux_socket_name":"alt"}]"#, - br#"[{"name":"scratch","path":"/w/scratch","session_name":"scratch","session_live":false}]"#, + br#"[{"path":"/w/one","branch":"main","commit_hash":"abc","is_main":true,"created_at":null,"generation":"g1","repository":"one","session_name":"one-main","tmux_socket_name":null,"tmux_attach_mode":"direct"},{"path":"/w/two","branch":"topic","commit_hash":"def","is_main":false,"created_at":"then","generation":null,"repository":"two","session_name":"two-topic","tmux_socket_name":"alt","tmux_attach_mode":"direct"}]"#, + br#"[{"name":"scratch","path":"/w/scratch","session_name":"scratch","session_live":false,"tmux_socket_name":"kwt","tmux_attach_mode":"direct"}]"#, ).expect("valid inventory"); assert_eq!(inventory.projects()[0].project().repository(), "two"); @@ -860,7 +935,7 @@ mod tests { assert_eq!(pull_requests[0].number(), 17); assert_eq!(pull_requests[0].source_branch(), "feature/rendering"); - let imported = parse_pull_request_import(br#"{"status":"created","pull_request":{"id":"github:github.com/acme/widget#17","provider":"github","repository":{"provider":"github","identity":"github.com/acme/widget","host":"github.com","owner":"acme","name":"widget"},"number":17,"url":"https://github.com/acme/widget/pull/17","title":"Improve rendering","author":"octocat","source":{"branch":"feature/rendering","repository":{"provider":"github","identity":"github.com/octocat/widget","host":"github.com","owner":"octocat","name":"widget"},"is_fork":true},"target":{"branch":"main","repository":{"provider":"github","identity":"github.com/acme/widget","host":"github.com","owner":"acme","name":"widget"},"is_fork":false},"draft":false,"state":"open","head_sha":"0123456789abcdef0123456789abcdef01234567","imported":true,"workspace":{"id":"workspace","repository":"github.com/acme/widget","branch":"pr-17-feature-rendering","path":"/worktrees/pr-17","generation":"11111111111111111111111111111111","state":"ready","session_name":"widget-pr-17","tmux_socket_name":"kwt-pr-a1b2"}},"project":{"identity":"github.com/acme/widget","name":"widget","path":"/code/widget"},"workspace":{"id":"workspace","repository":"github.com/acme/widget","branch":"pr-17-feature-rendering","path":"/worktrees/pr-17","generation":"11111111111111111111111111111111","state":"ready","session_name":"widget-pr-17","tmux_socket_name":"kwt-pr-a1b2"}}"#).expect("valid import"); + let imported = parse_pull_request_import(br#"{"status":"created","pull_request":{"id":"github:github.com/acme/widget#17","provider":"github","repository":{"provider":"github","identity":"github.com/acme/widget","host":"github.com","owner":"acme","name":"widget"},"number":17,"url":"https://github.com/acme/widget/pull/17","title":"Improve rendering","author":"octocat","source":{"branch":"feature/rendering","repository":{"provider":"github","identity":"github.com/octocat/widget","host":"github.com","owner":"octocat","name":"widget"},"is_fork":true},"target":{"branch":"main","repository":{"provider":"github","identity":"github.com/acme/widget","host":"github.com","owner":"acme","name":"widget"},"is_fork":false},"draft":false,"state":"open","head_sha":"0123456789abcdef0123456789abcdef01234567","imported":true,"workspace":{"id":"workspace","repository":"github.com/acme/widget","branch":"pr-17-feature-rendering","path":"/worktrees/pr-17","generation":"11111111111111111111111111111111","state":"ready","session_name":"widget-pr-17","tmux_socket_name":"kwt-pr-a1b2","tmux_attach_mode":"protected"}},"project":{"identity":"github.com/acme/widget","name":"widget","path":"/code/widget"},"workspace":{"id":"workspace","repository":"github.com/acme/widget","branch":"pr-17-feature-rendering","path":"/worktrees/pr-17","generation":"11111111111111111111111111111111","state":"ready","session_name":"widget-pr-17","tmux_socket_name":"kwt-pr-a1b2","tmux_attach_mode":"protected"}}"#).expect("valid import"); assert_eq!(imported.workspace().tmux_socket_name(), Some("kwt-pr-a1b2")); } diff --git a/rust/host/src/lib.rs b/rust/host/src/lib.rs index 1025ac36..59ca8af5 100644 --- a/rust/host/src/lib.rs +++ b/rust/host/src/lib.rs @@ -22,10 +22,10 @@ mod wsl; mod zellij; pub use kwt::{ - KwtBranchCandidate, KwtBundle, KwtDirectoryWorkspace, KwtInventory, KwtProject, - KwtProjectInventory, KwtProtectedWorktreeOpen, KwtPullRequest, KwtPullRequestImport, - KwtPullRequestImportRequest, KwtWorktree, KwtWorktreeCreate, KwtWorktreeOpen, - kwt_command_failure_message, + KwtBranchCandidate, KwtBundle, KwtDirectoryWorkspace, KwtDirectoryWorkspaceOpen, KwtInventory, + KwtProject, KwtProjectInventory, KwtProtectedWorktreeOpen, KwtPullRequest, + KwtPullRequestImport, KwtPullRequestImportRequest, KwtTmuxAttachMode, KwtWorktree, + KwtWorktreeCreate, KwtWorktreeOpen, kwt_command_failure_message, }; pub use remote::{ RemoteSessionInventory, RemoteTmuxConfig, RemoteTmuxError, RemoteTmuxHost, RemoteTmuxSnapshot, diff --git a/rust/host/src/wsl.rs b/rust/host/src/wsl.rs index 139ecdf0..1c9e7578 100644 --- a/rust/host/src/wsl.rs +++ b/rust/host/src/wsl.rs @@ -23,9 +23,9 @@ use crate::kwt::{ use crate::zellij; use crate::{ CancellationToken, CommandOutput, CommandPrefix, CommandRunner, KwtBranchCandidate, KwtBundle, - KwtInventory, KwtProject, KwtProtectedWorktreeOpen, KwtPullRequest, KwtPullRequestImport, - KwtPullRequestImportRequest, KwtWorktreeCreate, KwtWorktreeOpen, RemoteTmuxConfig, - RemoteTmuxHost, + KwtDirectoryWorkspaceOpen, KwtInventory, KwtProject, KwtProtectedWorktreeOpen, KwtPullRequest, + KwtPullRequestImport, KwtPullRequestImportRequest, KwtWorktreeCreate, KwtWorktreeOpen, + RemoteTmuxConfig, RemoteTmuxHost, }; const DEFAULT_TMUX: &str = "/usr/bin/tmux"; @@ -1296,6 +1296,71 @@ impl WslHost { )) } + /// Resolve the revision-pinned helper and build a re-runnable ordinary + /// client for one exact registered directory workspace. + /// + /// # Errors + /// + /// Returns an error when the helper or captured WSL runtime cannot be + /// verified, or when KWT supplied an unsafe named socket. + pub fn kwt_directory_open_plan( + &self, + endpoint: &WslEndpoint, + runtime: &WslRuntimeIdentity, + request: &KwtDirectoryWorkspaceOpen, + term: AttachTerm, + cancellation: &CancellationToken, + ) -> Result { + if let Some(socket_name) = request.tmux_socket_name() { + require_kwt_socket_name(socket_name)?; + } + self.require_runtime(endpoint, runtime, cancellation)?; + let bundle = self.config.kwt_bundle().ok_or_else(|| { + HostError::new( + DiagnosticKind::ExecutableNotFound, + "the revision-pinned KWT helper is not bundled", + ) + })?; + let helper = self.ensure_kwt_helper(endpoint, runtime, bundle, cancellation)?; + self.require_runtime(endpoint, runtime, cancellation)?; + let readiness_path = kwt_client_readiness_path()?; + let readiness_staging_path = format!("{readiness_path}.tmp"); + let mut args = pinned_prefix(endpoint); + let kwt_home = self + .config + .kwt_home + .as_deref() + .map(|path| format!("KWT_HOME={path}")); + let extra_environment = kwt_home.as_deref().into_iter().collect::>(); + append_tmux_environment( + &mut args, + Some(term.environment()), + self.config.tmux_tmpdir.as_deref(), + &extra_environment, + ); + args.extend( + [ + "/bin/sh", + "-c", + "umask 077; /usr/bin/printf '%s\\n' \"$$\" > \"$1\" && /usr/bin/mv -T -- \"$1\" \"$2\" && shift 2 && exec \"$@\"", + "ghosthub-directory-workspace-client", + readiness_staging_path.as_str(), + readiness_path.as_str(), + helper.as_str(), + "open", + request.path(), + ] + .into_iter() + .map(OsString::from), + ); + Ok(RepairOrOpenPlan::worktree( + self.wsl_executable.as_os_str(), + args, + request.session_name(), + &readiness_path, + )) + } + /// Build a re-runnable protected attach for one imported PR workspace. /// /// # Errors @@ -1391,8 +1456,12 @@ impl WslHost { endpoint: &WslEndpoint, runtime: &WslRuntimeIdentity, readiness_path: &str, + tmux_socket_name: Option<&str>, cancellation: &CancellationToken, ) -> Result, HostError> { + if let Some(socket_name) = tmux_socket_name { + require_kwt_socket_name(socket_name)?; + } require_kwt_client_readiness_path(readiness_path)?; self.require_runtime(endpoint, runtime, cancellation)?; let receipt = self.run_scrubbed( @@ -1417,17 +1486,18 @@ impl WslHost { return Ok(None); } let client_pid = parse_kwt_client_pid(&receipt.stdout)?; - let output = self.run_tmux_command( - endpoint, - cancellation, - &[ - "-f", - "/dev/null", - "list-clients", - "-F", - CLIENT_READINESS_FORMAT, - ], - )?; + let mut command = Vec::new(); + if let Some(socket_name) = tmux_socket_name { + command.extend(["-L", socket_name]); + } + command.extend([ + "-f", + "/dev/null", + "list-clients", + "-F", + CLIENT_READINESS_FORMAT, + ]); + let output = self.run_tmux_command(endpoint, cancellation, &command)?; if output.status != 0 { let stderr = String::from_utf8_lossy(&output.stderr); if is_no_server(&stderr) { @@ -5249,7 +5319,7 @@ mod tests { } else if args.windows(2).any(|pair| pair == ["pr", "list"]) { br#"{"pull_requests":[{"id":"github:github.com/acme/widget#17","provider":"github","repository":{"provider":"github","identity":"github.com/acme/widget","host":"github.com","owner":"acme","name":"widget"},"number":17,"url":"https://github.com/acme/widget/pull/17","title":"Improve rendering","author":"octocat","source":{"branch":"feature/rendering","repository":{"provider":"github","identity":"github.com/octocat/widget","host":"github.com","owner":"octocat","name":"widget"},"is_fork":true},"target":{"branch":"main","repository":{"provider":"github","identity":"github.com/acme/widget","host":"github.com","owner":"acme","name":"widget"},"is_fork":false},"draft":false,"state":"open","head_sha":"0123456789abcdef0123456789abcdef01234567","imported":false}]}"#.to_vec() } else if args.windows(2).any(|pair| pair == ["pr", "import"]) { - br#"{"status":"created","pull_request":{"id":"github:github.com/acme/widget#17","provider":"github","repository":{"provider":"github","identity":"github.com/acme/widget","host":"github.com","owner":"acme","name":"widget"},"number":17,"url":"https://github.com/acme/widget/pull/17","title":"Improve rendering","author":"octocat","source":{"branch":"feature/rendering","repository":{"provider":"github","identity":"github.com/octocat/widget","host":"github.com","owner":"octocat","name":"widget"},"is_fork":true},"target":{"branch":"main","repository":{"provider":"github","identity":"github.com/acme/widget","host":"github.com","owner":"acme","name":"widget"},"is_fork":false},"draft":false,"state":"open","head_sha":"0123456789abcdef0123456789abcdef01234567","imported":true},"project":{"identity":"github.com/acme/widget","name":"widget","path":"/code/widget"},"workspace":{"id":"workspace","repository":"github.com/acme/widget","branch":"pr-17-feature-rendering","path":"/worktrees/pr-17","generation":"11111111111111111111111111111111","state":"ready","session_name":"widget-pr-17","tmux_socket_name":"kwt-pr-a1b2"}}"#.to_vec() + br#"{"status":"created","pull_request":{"id":"github:github.com/acme/widget#17","provider":"github","repository":{"provider":"github","identity":"github.com/acme/widget","host":"github.com","owner":"acme","name":"widget"},"number":17,"url":"https://github.com/acme/widget/pull/17","title":"Improve rendering","author":"octocat","source":{"branch":"feature/rendering","repository":{"provider":"github","identity":"github.com/octocat/widget","host":"github.com","owner":"octocat","name":"widget"},"is_fork":true},"target":{"branch":"main","repository":{"provider":"github","identity":"github.com/acme/widget","host":"github.com","owner":"acme","name":"widget"},"is_fork":false},"draft":false,"state":"open","head_sha":"0123456789abcdef0123456789abcdef01234567","imported":true},"project":{"identity":"github.com/acme/widget","name":"widget","path":"/code/widget"},"workspace":{"id":"workspace","repository":"github.com/acme/widget","branch":"pr-17-feature-rendering","path":"/worktrees/pr-17","generation":"11111111111111111111111111111111","state":"ready","session_name":"widget-pr-17","tmux_socket_name":"kwt-pr-a1b2","tmux_attach_mode":"protected"}}"#.to_vec() } else if (args.iter().any(|argument| argument == "add") && args.iter().any(|argument| argument == "--no-launch")) || (args.iter().any(|argument| argument == "remove") @@ -5754,6 +5824,7 @@ mod tests { "registration-fingerprint", "0123456789abcdef0123456789abcdef", "widget-topic", + None, ), AttachTerm::Xterm256Color, &CancellationToken::new(), @@ -5801,6 +5872,36 @@ mod tests { assert_eq!(plan.clone(), plan); } + #[test] + fn kwt_directory_open_plan_uses_the_exact_registered_path() { + let (host, _runner, endpoint, runtime) = kwt_mutation_host(); + let plan = host + .kwt_directory_open_plan( + &endpoint, + &runtime, + &KwtDirectoryWorkspaceOpen::new( + "/work/scratch", + "kwt-workspace-dir-scratch-abc", + Some("kwt".to_owned()), + ), + AttachTerm::Xterm256Color, + &CancellationToken::new(), + ) + .expect("build directory workspace open plan"); + let args = plan + .args() + .iter() + .map(|argument| argument.to_string_lossy()) + .collect::>(); + + assert!( + args.windows(3) + .any(|args| { args == [&test_kwt_helper_path(), "open", "/work/scratch",] }) + ); + assert!(!args.iter().any(|argument| argument == "--expected-session")); + assert_eq!(plan.target_name(), "kwt-workspace-dir-scratch-abc"); + } + #[test] fn protected_kwt_attach_plan_uses_pr_attach_and_exact_socket_authority() { let (host, _runner, endpoint, runtime) = kwt_mutation_host(); diff --git a/rust/host/tests/kwt_wsl_live.rs b/rust/host/tests/kwt_wsl_live.rs index 569c8dac..df862bd6 100644 --- a/rust/host/tests/kwt_wsl_live.rs +++ b/rust/host/tests/kwt_wsl_live.rs @@ -200,6 +200,7 @@ fn pinned_helper_honors_the_worktree_lifecycle_contract() { project.project().registration_fingerprint(), generation, worktree.session_name(), + worktree.tmux_socket_name().map(str::to_owned), ); assert_guarded_open_rejected( @@ -212,6 +213,7 @@ fn pinned_helper_honors_the_worktree_lifecycle_contract() { "stale-registration", generation, worktree.session_name(), + worktree.tmux_socket_name().map(str::to_owned), ), &cancellation, ); @@ -225,6 +227,7 @@ fn pinned_helper_honors_the_worktree_lifecycle_contract() { project.project().registration_fingerprint(), "00000000000000000000000000000000", worktree.session_name(), + worktree.tmux_socket_name().map(str::to_owned), ), &cancellation, ); @@ -635,7 +638,7 @@ fn wait_for_exact_client( let deadline = Instant::now() + READY_TIMEOUT; loop { if let Some(identity) = host - .kwt_client_session_identity(endpoint, runtime, readiness_path, cancellation) + .kwt_client_session_identity(endpoint, runtime, readiness_path, None, cancellation) .expect("query exact KWT client readiness") { return identity; diff --git a/rust/ui/src/lib.rs b/rust/ui/src/lib.rs index 3bc6951e..e8e0ca92 100644 --- a/rust/ui/src/lib.rs +++ b/rust/ui/src/lib.rs @@ -20,9 +20,9 @@ use surface::{CellStyle, CursorShape, Damage, GridSize, Rgb, SurfaceFrame, Surfa use workspace::{ AppearanceSettingsDraft, ConfiguredSshHost, CursorStyle, HerdrSessionState, HostConnectionState, HostItem, KeyEvent as InputKeyEvent, KeyInput, KwtProjectAction, - Modifiers as InputModifiers, MouseAction, MouseButton, MouseInput, NamedKey, SessionName, - SessionSelection, SshHostDraft, SshPromptRequest, TerminalSettingsDraft, TerminalTheme, - Workspace, WorkspaceContent, WorkspaceEvent, + KwtTmuxAttachMode, Modifiers as InputModifiers, MouseAction, MouseButton, MouseInput, NamedKey, + SessionName, SessionSelection, SshHostDraft, SshPromptRequest, TerminalSettingsDraft, + TerminalTheme, Workspace, WorkspaceContent, WorkspaceEvent, }; pub const WINDOW_TITLE: &str = "Ghosthub"; @@ -996,6 +996,7 @@ struct WorktreeOpenTarget { generation: Option, session_name: String, tmux_socket_name: Option, + tmux_attach_mode: KwtTmuxAttachMode, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -1789,14 +1790,28 @@ impl RootView { return; } - let switching = matches!( - snapshot.content(), - WorkspaceContent::Attaching { .. } | WorkspaceContent::Terminal { .. } - ); - let result = if switching { - self.workspace.switch_session(selection) + let result = if let (Some(attach_mode), Some(path)) = + (selection.tmux_attach_mode(), selection.worktree_path()) + && selection.worktree_generation().is_none() + { + self.workspace.open_kwt_directory_workspace( + selection.host_id(), + selection.endpoint(), + path, + selection.session(), + selection.tmux_socket_name(), + attach_mode, + ) } else { - self.workspace.attach(selection) + let switching = matches!( + snapshot.content(), + WorkspaceContent::Attaching { .. } | WorkspaceContent::Terminal { .. } + ); + if switching { + self.workspace.switch_session(selection) + } else { + self.workspace.attach(selection) + } }; if let Err(error) = result { self.diagnostic = Some(error.to_string()); @@ -1827,6 +1842,7 @@ impl RootView { target.generation.as_deref(), &target.session_name, target.tmux_socket_name.as_deref(), + target.tmux_attach_mode, ); if let Err(error) = result { self.diagnostic = Some(error.to_string()); @@ -2578,6 +2594,7 @@ impl RootView { generation, &target.open.session_name, target.open.tmux_socket_name.as_deref(), + target.open.tmux_attach_mode, ) } else { target.authority = None; @@ -2759,6 +2776,7 @@ impl RootView { generation, &open.session_name, open.tmux_socket_name.as_deref(), + open.tmux_attach_mode, ) { Ok(operation_id) => { if let Some(ProjectDialog::RemoveWorktree { target, error, .. }) = @@ -2924,6 +2942,7 @@ impl RootView { &target.open.worktree_path, generation, &target.open.session_name, + target.open.tmux_attach_mode, authority, ) .map(|()| None) @@ -3435,6 +3454,7 @@ impl RootView { generation: target.generation().map(str::to_owned), session_name: target.session_name().to_owned(), tmux_socket_name: target.tmux_socket_name().map(str::to_owned), + tmux_attach_mode: target.tmux_attach_mode(), }; let dialog_project_path = self.project_dialog.as_ref().map(|dialog| { if let ProjectDialog::NewWorktree { project_path, .. } = dialog { @@ -3471,6 +3491,7 @@ impl RootView { open.generation.as_deref(), &open.session_name, open.tmux_socket_name.as_deref(), + open.tmux_attach_mode, ) { self.diagnostic = Some(error.to_string()); } else { @@ -7431,23 +7452,15 @@ impl RootView { } tree = tree.child(row); for (worktree_index, worktree) in project.worktrees().iter().enumerate() { - let selection = worktree - .tmux_socket_name() - .and_then(|socket| { - worktree.generation().map(|generation| { - SessionSelection::protected_worktree( - host.id(), - host.endpoint(), - worktree.session_name(), - socket, - worktree.path(), - generation, - ) - }) - }) - .unwrap_or_else(|| { - SessionSelection::new(host.id(), host.endpoint(), worktree.session_name()) - }); + let selection = SessionSelection::worktree( + host.id(), + host.endpoint(), + worktree.session_name(), + worktree.tmux_socket_name().map(str::to_owned), + worktree.tmux_attach_mode(), + worktree.path(), + worktree.generation().map(str::to_owned), + ); let is_active = active == Some(&selection); let is_retained = retained.contains(&selection); let has_generation = worktree.generation().is_some(); @@ -7496,6 +7509,7 @@ impl RootView { generation: worktree.generation().map(str::to_owned), session_name: worktree.session_name().to_owned(), tmux_socket_name: worktree.tmux_socket_name().map(str::to_owned), + tmux_attach_mode: worktree.tmux_attach_mode(), }; let repair_open_target = (open_mode == WorktreeOpenMode::RepairOrOpen).then(|| open_target.clone()); @@ -7562,8 +7576,14 @@ impl RootView { ); } for (index, workspace) in host.directory_workspaces().iter().enumerate() { - let selection = - SessionSelection::new(host.id(), host.endpoint(), workspace.session_name()); + let selection = SessionSelection::directory_workspace( + host.id(), + host.endpoint(), + workspace.session_name(), + workspace.tmux_socket_name().map(str::to_owned), + workspace.tmux_attach_mode(), + workspace.path(), + ); let is_active = active == Some(&selection); let is_retained = retained.contains(&selection); let can_open = is_active @@ -8447,8 +8467,8 @@ fn active_session_selection(content: &WorkspaceContent) -> Option bool { - if selection.tmux_socket_name().is_some() { - host.kwt_owns_protected_presentation(selection) + if selection.tmux_attach_mode().is_some() { + host.kwt_owns_worktree_presentation(selection) } else { selection.host_id() == host.id() && selection.endpoint() == host.endpoint() @@ -9507,9 +9527,9 @@ mod tests { use workspace::{ AppearanceSettingsDraft, CursorStyle, HerdrSessionItem, HerdrSessionState, HostConnectionState, HostDiagnostic, HostItem, KeyEvent, KeyInput, KwtBranchItem, - KwtPullRequestItem, Modifiers, MouseAction, MouseButton, MouseInput, NamedKey, ProjectItem, - SessionItem, SessionSelection, SshHostDraft, TerminalSettingsDraft, TerminalTheme, - WorkspaceContent, WorkspaceSnapshot, WorktreeItem, + KwtPullRequestItem, KwtTmuxAttachMode, Modifiers, MouseAction, MouseButton, MouseInput, + NamedKey, ProjectItem, SessionItem, SessionSelection, SshHostDraft, TerminalSettingsDraft, + TerminalTheme, WorkspaceContent, WorkspaceSnapshot, WorktreeItem, }; #[test] @@ -10023,6 +10043,7 @@ mod tests { generation: Some("11111111111111111111111111111111".to_owned()), session_name: "widget-topic".to_owned(), tmux_socket_name: None, + tmux_attach_mode: KwtTmuxAttachMode::Direct, }, project_name: "widget".to_owned(), branch: "topic".to_owned(), @@ -10496,7 +10517,7 @@ mod tests { true, None, "project-main", - None, + workspace::KwtTmuxEndpoint::new(None, KwtTmuxAttachMode::Direct), true, ), WorktreeItem::new( @@ -10505,7 +10526,10 @@ mod tests { false, None, "custom-socket", - Some("project-socket".to_owned()), + workspace::KwtTmuxEndpoint::new( + Some("project-socket".to_owned()), + KwtTmuxAttachMode::Direct, + ), false, ), ], @@ -10548,19 +10572,23 @@ mod tests { false, Some("0123456789abcdef0123456789abcdef".to_owned()), "project-pr-17", - Some("kwt-pr-0123456789abcdef".to_owned()), + workspace::KwtTmuxEndpoint::new( + Some("kwt-pr-0123456789abcdef".to_owned()), + KwtTmuxAttachMode::Protected, + ), false, )], )], Vec::new(), ); - let active = SessionSelection::protected_worktree( + let active = SessionSelection::worktree( "wsl", "Ubuntu", "project-pr-17", - "kwt-pr-0123456789abcdef", + Some("kwt-pr-0123456789abcdef".to_owned()), + KwtTmuxAttachMode::Protected, "/repos/project-pr", - "0123456789abcdef0123456789abcdef", + Some("0123456789abcdef0123456789abcdef".to_owned()), ); let rows = tree_sessions(&host, Some(&active), &[]); @@ -10586,7 +10614,10 @@ mod tests { false, Some("0123456789abcdef0123456789abcdef".to_owned()), "project-pr-17", - Some("kwt-pr-replacement".to_owned()), + workspace::KwtTmuxEndpoint::new( + Some("kwt-pr-replacement".to_owned()), + KwtTmuxAttachMode::Protected, + ), false, )], )], diff --git a/rust/workspace/src/lib.rs b/rust/workspace/src/lib.rs index 2bae2905..76684923 100644 --- a/rust/workspace/src/lib.rs +++ b/rust/workspace/src/lib.rs @@ -10,6 +10,7 @@ use std::time::{Duration, Instant}; use config::{ApplicationConfig, Roots, SshHostSettings, TerminalAppearance}; pub use config::{CursorStyle, TerminalTheme}; +pub use host::KwtTmuxAttachMode; use host::{ AdmissionAttacher, AttachTerm, CancellationToken, CommandRunner, HerdrInventory, HostError, HostSnapshot, KwtInventory, KwtPullRequestImportRequest, KwtSshExecutable, LiveSessionTarget, @@ -66,8 +67,9 @@ use scene::{ begin_scene_navigation, bump_scene_revision, cancel_owned_kwt_listing, cancel_pending_paste, capture_attach_request, capture_create_request, capture_herdr_create_request, capture_herdr_lifecycle, capture_herdr_restart_request, capture_kill_request, - capture_kwt_removal_authority, capture_kwt_worktree_request, capture_zellij_create_request, - clear_pending_paste, clear_terminal_notice, detach_scene_locked, drop_matching_confirmations, + capture_kwt_directory_workspace_request, capture_kwt_removal_authority, + capture_kwt_worktree_request, capture_zellij_create_request, clear_pending_paste, + clear_terminal_notice, detach_scene_locked, drop_matching_confirmations, drop_matching_kill_confirmations, expire_refresh, fail_refresh_start, fail_retained_retry, failed_attachment_context, fallback_owns_request, finish_kwt_project_mutation, finish_kwt_worktree_operation, invalidate_pending_herdr_lifecycle, invalidate_pending_kill, @@ -293,6 +295,7 @@ pub struct SessionSelection { session: String, kind: SessionKind, tmux_socket_name: Option, + tmux_attach_mode: Option, worktree_path: Option, worktree_generation: Option, } @@ -330,31 +333,54 @@ impl SessionSelection { session: session.into(), kind: SessionKind::Tmux, tmux_socket_name: None, + tmux_attach_mode: None, worktree_path: None, worktree_generation: None, } } #[must_use] - pub fn protected_worktree( + pub fn worktree( host_id: impl Into, endpoint: impl Into, session: impl Into, - socket_name: impl Into, + socket_name: Option, + attach_mode: KwtTmuxAttachMode, path: impl Into, - generation: impl Into, + generation: Option, ) -> Self { Self { host_id: host_id.into(), endpoint: endpoint.into(), session: session.into(), kind: SessionKind::Tmux, - tmux_socket_name: Some(socket_name.into()), + tmux_socket_name: socket_name, + tmux_attach_mode: Some(attach_mode), worktree_path: Some(path.into()), - worktree_generation: Some(generation.into()), + worktree_generation: generation, } } + #[must_use] + pub fn directory_workspace( + host_id: impl Into, + endpoint: impl Into, + session: impl Into, + socket_name: Option, + attach_mode: KwtTmuxAttachMode, + path: impl Into, + ) -> Self { + Self::worktree( + host_id, + endpoint, + session, + socket_name, + attach_mode, + path, + None, + ) + } + #[must_use] pub fn herdr( host_id: impl Into, @@ -367,6 +393,7 @@ impl SessionSelection { session: session.into(), kind: SessionKind::Herdr, tmux_socket_name: None, + tmux_attach_mode: None, worktree_path: None, worktree_generation: None, } @@ -384,6 +411,7 @@ impl SessionSelection { session: session.into(), kind: SessionKind::Zellij, tmux_socket_name: None, + tmux_attach_mode: None, worktree_path: None, worktree_generation: None, } @@ -414,6 +442,11 @@ impl SessionSelection { self.tmux_socket_name.as_deref() } + #[must_use] + pub const fn tmux_attach_mode(&self) -> Option { + self.tmux_attach_mode + } + #[must_use] pub fn worktree_path(&self) -> Option<&str> { self.worktree_path.as_deref() @@ -550,6 +583,22 @@ impl ProjectItem { } } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct KwtTmuxEndpoint { + socket_name: Option, + attach_mode: KwtTmuxAttachMode, +} + +impl KwtTmuxEndpoint { + #[must_use] + pub const fn new(socket_name: Option, attach_mode: KwtTmuxAttachMode) -> Self { + Self { + socket_name, + attach_mode, + } + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct WorktreeItem { path: String, @@ -558,6 +607,7 @@ pub struct WorktreeItem { generation: Option, session_name: String, tmux_socket_name: Option, + tmux_attach_mode: KwtTmuxAttachMode, session_available: bool, } @@ -569,7 +619,7 @@ impl WorktreeItem { is_main: bool, generation: Option, session_name: impl Into, - tmux_socket_name: Option, + tmux_endpoint: KwtTmuxEndpoint, session_available: bool, ) -> Self { Self { @@ -578,7 +628,8 @@ impl WorktreeItem { is_main, generation, session_name: session_name.into(), - tmux_socket_name, + tmux_socket_name: tmux_endpoint.socket_name, + tmux_attach_mode: tmux_endpoint.attach_mode, session_available, } } @@ -613,6 +664,11 @@ impl WorktreeItem { self.tmux_socket_name.as_deref() } + #[must_use] + pub const fn tmux_attach_mode(&self) -> KwtTmuxAttachMode { + self.tmux_attach_mode + } + #[must_use] pub const fn session_available(&self) -> bool { self.session_available @@ -624,6 +680,8 @@ pub struct DirectoryWorkspaceItem { name: String, path: String, session_name: String, + tmux_socket_name: Option, + tmux_attach_mode: KwtTmuxAttachMode, session_available: bool, } @@ -633,12 +691,16 @@ impl DirectoryWorkspaceItem { name: impl Into, path: impl Into, session_name: impl Into, + tmux_socket_name: Option, + tmux_attach_mode: KwtTmuxAttachMode, session_available: bool, ) -> Self { Self { name: name.into(), path: path.into(), session_name: session_name.into(), + tmux_socket_name, + tmux_attach_mode, session_available, } } @@ -658,6 +720,16 @@ impl DirectoryWorkspaceItem { &self.session_name } + #[must_use] + pub fn tmux_socket_name(&self) -> Option<&str> { + self.tmux_socket_name.as_deref() + } + + #[must_use] + pub const fn tmux_attach_mode(&self) -> KwtTmuxAttachMode { + self.tmux_attach_mode + } + #[must_use] pub const fn session_available(&self) -> bool { self.session_available @@ -974,21 +1046,22 @@ impl HostItem { pub fn kwt_owns_default_tmux_session(&self, name: &str) -> bool { self.projects.iter().any(|project| { project.worktrees.iter().any(|worktree| { - worktree.tmux_socket_name.is_none() && worktree.session_name == name + worktree.tmux_attach_mode == KwtTmuxAttachMode::Direct + && worktree.tmux_socket_name.is_none() + && worktree.session_name == name }) - }) || self - .directory_workspaces - .iter() - .any(|workspace| workspace.session_name == name) + }) || self.directory_workspaces.iter().any(|workspace| { + workspace.tmux_attach_mode == KwtTmuxAttachMode::Direct + && workspace.tmux_socket_name.is_none() + && workspace.session_name == name + }) } #[must_use] - pub fn kwt_owns_protected_presentation(&self, selection: &SessionSelection) -> bool { - let (Some(socket_name), Some(worktree_path), Some(generation)) = ( - selection.tmux_socket_name(), - selection.worktree_path(), - selection.worktree_generation(), - ) else { + pub fn kwt_owns_worktree_presentation(&self, selection: &SessionSelection) -> bool { + let (Some(attach_mode), Some(worktree_path)) = + (selection.tmux_attach_mode(), selection.worktree_path()) + else { return false; }; selection.kind() == SessionKind::Tmux @@ -997,9 +1070,10 @@ impl HostItem { && self.projects.iter().any(|project| { project.worktrees.iter().any(|worktree| { worktree.session_name == selection.session() - && worktree.tmux_socket_name.as_deref() == Some(socket_name) + && worktree.tmux_attach_mode == attach_mode + && worktree.tmux_socket_name.as_deref() == selection.tmux_socket_name() && worktree.path == worktree_path - && worktree.generation.as_deref() == Some(generation) + && worktree.generation.as_deref() == selection.worktree_generation() }) }) } @@ -1444,6 +1518,7 @@ pub struct KwtWorktreeTarget { generation: Option, session_name: String, tmux_socket_name: Option, + tmux_attach_mode: KwtTmuxAttachMode, } impl KwtWorktreeTarget { @@ -1483,6 +1558,11 @@ impl KwtWorktreeTarget { pub fn tmux_socket_name(&self) -> Option<&str> { self.tmux_socket_name.as_deref() } + + #[must_use] + pub const fn tmux_attach_mode(&self) -> KwtTmuxAttachMode { + self.tmux_attach_mode + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -2605,7 +2685,13 @@ fn normalize_attached_worktree_target( snapshot: &HostSnapshot, attached_name: &str, ) -> bool { - if !matches!(active.request.target, AttachTarget::Worktree { .. }) { + let AttachTarget::Worktree { + tmux_socket_name, .. + } = &active.request.target + else { + return false; + }; + if tmux_socket_name.is_some() { return false; } let Some(identity) = snapshot @@ -2630,7 +2716,12 @@ fn worktree_tmux_presentation_key( request: &AttachRequest, snapshot: &HostSnapshot, ) -> Option { - let AttachTarget::Worktree { session_name, .. } = &request.target else { + let AttachTarget::Worktree { + session_name, + tmux_socket_name: None, + .. + } = &request.target + else { return None; }; let identity = snapshot @@ -2656,8 +2747,13 @@ fn attach_target_matches_killed_tmux( ) -> bool { match target { AttachTarget::Tmux(target_identity) => target_identity == identity, - AttachTarget::Worktree { session_name, .. } => { - socket_name.is_none() && name.is_some_and(|name| session_name == name) + AttachTarget::Worktree { + session_name, + tmux_socket_name, + .. + } => { + tmux_socket_name.as_deref() == socket_name + && name.is_some_and(|name| session_name == name) } AttachTarget::ProtectedWorktree { session_name, @@ -2667,6 +2763,14 @@ fn attach_target_matches_killed_tmux( name.is_some_and(|name| session_name == name) && socket_name.is_some_and(|socket| tmux_socket_name == socket) } + AttachTarget::DirectoryWorkspace { + session_name, + tmux_socket_name, + .. + } => { + tmux_socket_name.as_deref() == socket_name + && name.is_some_and(|name| session_name == name) + } AttachTarget::Herdr { .. } | AttachTarget::Zellij { .. } => false, } } @@ -2706,6 +2810,7 @@ enum AttachTarget { path: String, generation: Option, session_name: String, + tmux_socket_name: Option, }, ProtectedWorktree { repository: String, @@ -2716,6 +2821,11 @@ enum AttachTarget { session_name: String, tmux_socket_name: String, }, + DirectoryWorkspace { + path: String, + session_name: String, + tmux_socket_name: Option, + }, Herdr { executable: String, is_default: bool, @@ -2745,9 +2855,10 @@ impl AttachTarget { const fn kind(&self) -> SessionKind { match self { - Self::Tmux(_) | Self::Worktree { .. } | Self::ProtectedWorktree { .. } => { - SessionKind::Tmux - } + Self::Tmux(_) + | Self::Worktree { .. } + | Self::ProtectedWorktree { .. } + | Self::DirectoryWorkspace { .. } => SessionKind::Tmux, Self::Herdr { .. } => SessionKind::Herdr, Self::Zellij { .. } => SessionKind::Zellij, } @@ -2962,13 +3073,46 @@ impl AttachRequest { .. } = &self.target { - return SessionSelection::protected_worktree( + return SessionSelection::worktree( &self.host_id, self.endpoint.distro(), &self.name, - tmux_socket_name, + Some(tmux_socket_name.clone()), + KwtTmuxAttachMode::Protected, + path, + Some(generation.clone()), + ); + } + if let AttachTarget::Worktree { + path, + generation, + tmux_socket_name, + .. + } = &self.target + { + return SessionSelection::worktree( + &self.host_id, + self.endpoint.distro(), + &self.name, + tmux_socket_name.clone(), + KwtTmuxAttachMode::Direct, + path, + generation.clone(), + ); + } + if let AttachTarget::DirectoryWorkspace { + path, + tmux_socket_name, + .. + } = &self.target + { + return SessionSelection::directory_workspace( + &self.host_id, + self.endpoint.distro(), + &self.name, + tmux_socket_name.clone(), + KwtTmuxAttachMode::Direct, path, - generation, ); } match self.target.kind() { @@ -3423,7 +3567,8 @@ fn refreshed_session_name( .find(|session| session.identity() == identity) .map(|session| session.name().to_owned()), AttachTarget::Worktree { session_name, .. } - | AttachTarget::ProtectedWorktree { session_name, .. } => Some(session_name.clone()), + | AttachTarget::ProtectedWorktree { session_name, .. } + | AttachTarget::DirectoryWorkspace { session_name, .. } => Some(session_name.clone()), AttachTarget::Herdr { executable, is_default, @@ -5084,6 +5229,7 @@ impl Workspace { generation: &str, session_name: &str, tmux_socket_name: Option<&str>, + tmux_attach_mode: KwtTmuxAttachMode, ) -> Result { // Same closed-scene fence as the other destructive confirmations, // held from authority creation through the identity-query @@ -5105,6 +5251,7 @@ impl Workspace { generation, session_name, tmux_socket_name, + tmux_attach_mode, )?; let mut pending = self .scene @@ -5148,6 +5295,7 @@ impl Workspace { generation: generation.to_owned(), session_name: session_name.to_owned(), socket_name, + attach_mode: tmux_attach_mode, }; let scene = Arc::clone(&self.scene); self.scene @@ -5234,6 +5382,7 @@ impl Workspace { worktree_path: &str, generation: &str, session_name: &str, + tmux_attach_mode: KwtTmuxAttachMode, authority: u64, ) -> Result<(), WorkspaceError> { if !is_canonical_kwt_generation(generation) { @@ -5251,6 +5400,7 @@ impl Workspace { worktree_path, generation, session_name, + tmux_attach_mode, )?; let result = self.start_kwt_worktree_operation( host_id, @@ -5263,6 +5413,7 @@ impl Workspace { generation: generation.to_owned(), session_name: session_name.to_owned(), socket_name: pending.socket_name.clone(), + attach_mode: pending.attach_mode, live_target: pending.live_target.clone(), operation_id: authority, }, @@ -6048,6 +6199,7 @@ impl Workspace { generation: Option<&str>, session_name: &str, tmux_socket_name: Option<&str>, + tmux_attach_mode: KwtTmuxAttachMode, ) -> Result<(), WorkspaceError> { let _snapshot_write = begin_snapshot_write(&self.scene.runtime); let _navigation = lock_live_navigation(&self.scene)?; @@ -6062,6 +6214,7 @@ impl Workspace { generation, session_name, tmux_socket_name, + tmux_attach_mode, )?; let worktree_key = request.presentation_key(); let equivalent_tmux_key = equivalent_tmux_presentation_key(&self.scene.runtime, &request); @@ -6114,6 +6267,80 @@ impl Workspace { self.start_attachment(request, fallback, navigation_generation) } + /// Open one exact registered KWT directory workspace through `kwt open`. + /// + /// # Errors + /// + /// Returns an error when the host or directory endpoint no longer matches + /// current authoritative KWT inventory. + #[allow(clippy::too_many_arguments)] + pub fn open_kwt_directory_workspace( + &self, + host_id: &str, + endpoint: &str, + path: &str, + session_name: &str, + tmux_socket_name: Option<&str>, + tmux_attach_mode: KwtTmuxAttachMode, + ) -> Result<(), WorkspaceError> { + let _snapshot_write = begin_snapshot_write(&self.scene.runtime); + let _navigation = lock_live_navigation(&self.scene)?; + let request = capture_kwt_directory_workspace_request( + &self.scene, + host_id, + endpoint, + path, + session_name, + tmux_socket_name, + tmux_attach_mode, + )?; + let key = request.presentation_key(); + if self + .scene + .attachment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .active() + .is_some_and(|active| active.request.presentation_key() == key) + { + return Ok(()); + } + let navigation_generation = self.begin_navigation(); + let already_open = self + .scene + .retained_presentations + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&key); + let in_flight_fallback = self.supersede_inflight_attachment()?; + let visible_previous = self.retain_active_presentation()?; + let previous = in_flight_fallback.or(visible_previous); + let fallback = previous.clone().map(|presentation| FallbackAuthority { + presentation, + target: key.clone(), + navigation_generation, + }); + match self.activate_retained_presentation(&key, fallback.clone()) { + Ok(true) => return Ok(()), + Ok(false) => {} + Err(error) => { + if let Some(previous) = previous { + let _restored = self.activate_retained_presentation(&previous, None); + } + return Err(error); + } + } + if already_open { + if let Some(previous) = previous { + let _restored = self.activate_retained_presentation(&previous, None); + } + return Err(WorkspaceError::new( + "the retained directory workspace presentation is no longer available", + )); + } + self.start_attachment(request, fallback, navigation_generation) + } + fn switch_session_locked(&self, selection: &SessionSelection) -> Result<(), WorkspaceError> { let same_visible_selection = matches!( self.scene @@ -8403,6 +8630,7 @@ enum KwtWorktreeOperation { generation: String, session_name: String, socket_name: Option, + attach_mode: KwtTmuxAttachMode, live_target: Option>, operation_id: u64, }, @@ -8450,6 +8678,7 @@ struct PendingKwtRemoval { generation: String, session_name: String, socket_name: Option, + attach_mode: KwtTmuxAttachMode, live_target: Option>, } @@ -8465,6 +8694,7 @@ struct KwtRemovalCapture { generation: String, session_name: String, socket_name: Option, + attach_mode: KwtTmuxAttachMode, } #[derive(Clone)] @@ -8504,6 +8734,7 @@ fn validate_kwt_worktree_operation( generation, session_name, socket_name, + attach_mode, .. } = operation else { @@ -8517,6 +8748,7 @@ fn validate_kwt_worktree_operation( && worktree.generation.as_deref() == Some(generation) && worktree.session_name == *session_name && worktree.tmux_socket_name.as_ref() == socket_name.as_ref() + && worktree.tmux_attach_mode == *attach_mode }) .ok_or_else(|| { WorkspaceError::new("the selected worktree changed; refresh and choose it again") @@ -8574,6 +8806,7 @@ fn preflight_kwt_worktree_remove( generation: &str, session_name: &str, socket_name: Option<&str>, + attach_mode: KwtTmuxAttachMode, ) -> Result<(), String> { let inventory = task .host @@ -8590,6 +8823,7 @@ fn preflight_kwt_worktree_remove( && worktree.generation() == Some(generation) && worktree.session_name() == session_name && worktree.tmux_socket_name() == socket_name + && worktree.tmux_attach_mode() == attach_mode && !worktree.is_main() }) })? @@ -8694,6 +8928,7 @@ fn pending_kwt_creation_target( generation: worktree.generation().map(str::to_owned), session_name: worktree.session_name().to_owned(), tmux_socket_name: worktree.tmux_socket_name().map(str::to_owned), + tmux_attach_mode: worktree.tmux_attach_mode(), }) } @@ -9525,12 +9760,17 @@ fn reconcile_kwt_session_availability(host: &mut HostItem) { .collect::>(); for project in &mut host.projects { for worktree in &mut project.worktrees { - worktree.session_available = worktree.tmux_socket_name.is_none() + worktree.session_available = worktree.tmux_attach_mode == KwtTmuxAttachMode::Direct + && worktree.tmux_socket_name.is_none() && session_names.contains(worktree.session_name.as_str()); } } for workspace in &mut host.directory_workspaces { - workspace.session_available = session_names.contains(workspace.session_name.as_str()); + if workspace.tmux_attach_mode == KwtTmuxAttachMode::Direct + && workspace.tmux_socket_name.is_none() + { + workspace.session_available = session_names.contains(workspace.session_name.as_str()); + } } } diff --git a/rust/workspace/src/runtime.rs b/rust/workspace/src/runtime.rs index d3a034e1..10175db7 100644 --- a/rust/workspace/src/runtime.rs +++ b/rust/workspace/src/runtime.rs @@ -535,8 +535,13 @@ pub(crate) fn require_current_protected_selection( runtime: &Runtime, selection: &SessionSelection, ) -> Result<(), WorkspaceError> { - let Some(socket_name) = selection.tmux_socket_name() else { + if selection.tmux_attach_mode() != Some(crate::KwtTmuxAttachMode::Protected) { return Ok(()); + } + let Some(socket_name) = selection.tmux_socket_name() else { + return Err(WorkspaceError::new( + "protected worktree endpoint is unresolved", + )); }; let exact_worktree = runtime .hosts @@ -552,6 +557,7 @@ pub(crate) fn require_current_protected_selection( .flat_map(ProjectItem::worktrees) .any(|worktree| { worktree.session_name() == selection.session() + && worktree.tmux_attach_mode() == crate::KwtTmuxAttachMode::Protected && worktree.tmux_socket_name() == Some(socket_name) && worktree.path() == selection.worktree_path().unwrap_or_default() && worktree.generation() == selection.worktree_generation() @@ -1037,6 +1043,7 @@ pub(crate) fn capture_kwt_worktree_removal_context( generation: &str, session_name: &str, tmux_socket_name: Option<&str>, + tmux_attach_mode: crate::KwtTmuxAttachMode, ) -> Result< ( RuntimeHost, @@ -1095,6 +1102,7 @@ pub(crate) fn capture_kwt_worktree_removal_context( && worktree.generation.as_deref() == Some(generation) && worktree.session_name == session_name && worktree.tmux_socket_name.as_deref() == tmux_socket_name + && worktree.tmux_attach_mode() == tmux_attach_mode }) }) .ok_or_else(|| { diff --git a/rust/workspace/src/scene.rs b/rust/workspace/src/scene.rs index 725d7cf1..b1c9fb5a 100644 --- a/rust/workspace/src/scene.rs +++ b/rust/workspace/src/scene.rs @@ -11,10 +11,10 @@ use crate::{ KWT_REFRESH_INTERVAL, KillCaptureIntent, KillCaptureRequest, KillTarget, KwtBranchItem, KwtInventory, KwtProjectAction, KwtProjectMutationRequest, KwtProjectMutationTask, KwtPullRequestImportRequest, KwtPullRequestItem, KwtRefresh, KwtRemovalCapture, - KwtRemovalCaptureIntent, KwtState, KwtWorktreeOperation, KwtWorktreeOutcome, KwtWorktreeTarget, - KwtWorktreeTask, Mutex, Ordering, PendingCreation, PendingHerdrLifecycle, PendingKill, - PendingKwtCreation, PendingKwtRemoval, PendingPaste, PresentationKey, ProjectItem, Published, - REDUCED_COLOR_NOTICE, RecvTimeoutError, RefreshPresentation, RemoteActive, + KwtRemovalCaptureIntent, KwtState, KwtTmuxAttachMode, KwtWorktreeOperation, KwtWorktreeOutcome, + KwtWorktreeTarget, KwtWorktreeTask, Mutex, Ordering, PendingCreation, PendingHerdrLifecycle, + PendingKill, PendingKwtCreation, PendingKwtRemoval, PendingPaste, PresentationKey, ProjectItem, + Published, REDUCED_COLOR_NOTICE, RecvTimeoutError, RefreshPresentation, RemoteActive, RemoteAttachmentReset, RemoteConstructiveReset, RemoteConstructiveState, RemoteConstructiveTarget, RemoteHerdrAttachRequest, RemoteHerdrCreateRequest, RemoteHostContext, RemoteInventory, RemotePresentationKey, RemotePublicationFence, @@ -1996,6 +1996,7 @@ pub(crate) fn publish_captured_kwt_removal( generation: capture.generation, session_name: capture.session_name, socket_name: capture.socket_name, + attach_mode: capture.attach_mode, live_target, }); // The capture this intent tracked has published; nothing is in flight. @@ -2051,6 +2052,7 @@ pub(crate) fn take_pending_kwt_removal( worktree_path: &str, generation: &str, session_name: &str, + tmux_attach_mode: KwtTmuxAttachMode, ) -> Result { let pending = scene .pending_kwt_removal @@ -2065,7 +2067,8 @@ pub(crate) fn take_pending_kwt_removal( && pending.registration_fingerprint == registration_fingerprint && pending.worktree_path == worktree_path && pending.generation == generation - && pending.session_name == session_name; + && pending.session_name == session_name + && pending.attach_mode == tmux_attach_mode; if !matches || scene.kwt_removal_generation.load(Ordering::Acquire) != authority { return Err(WorkspaceError::new( "the worktree changed after confirmation; review the removal again", @@ -2327,22 +2330,9 @@ pub(crate) fn run_kwt_worktree_operation(scene: &Arc, task: &KwtWorktreeT ), refresh_tmux: false, }, - KwtWorktreeOperation::Remove { - worktree_path, - generation, - session_name, - socket_name, - live_target, - .. - } => run_kwt_worktree_remove( - scene, - task, - worktree_path, - generation, - session_name, - socket_name.as_deref(), - live_target.as_deref(), - ), + operation @ KwtWorktreeOperation::Remove { .. } => { + run_kwt_worktree_remove(scene, task, operation) + } }; finish_kwt_worktree_operation(scene, task); if outcome.refresh_tmux { @@ -2404,7 +2394,8 @@ pub(crate) fn run_kwt_pull_request_import( && imported.project_path() == task.project_path && workspace.repository() == task.repository && workspace.generation().is_some() - && workspace.tmux_socket_name().is_some(); + && workspace.tmux_socket_name().is_some() + && workspace.tmux_attach_mode() == KwtTmuxAttachMode::Protected; if !exact_response { publish_kwt_mutation_failure(scene, task.generation, &task.endpoint, &task.runtime); push_lossless_event( @@ -2452,6 +2443,7 @@ pub(crate) fn run_kwt_pull_request_import( && worktree.generation() == workspace.generation() && worktree.session_name() == workspace.session_name() && worktree.tmux_socket_name() == workspace.tmux_socket_name() + && worktree.tmux_attach_mode() == workspace.tmux_attach_mode() }) }) .flatten() @@ -2486,6 +2478,7 @@ pub(crate) fn run_kwt_pull_request_import( generation: exact.generation().map(str::to_owned), session_name: exact.session_name().to_owned(), tmux_socket_name: exact.tmux_socket_name().map(str::to_owned), + tmux_attach_mode: exact.tmux_attach_mode(), }; publish_kwt_inventory( scene, @@ -2506,20 +2499,35 @@ pub(crate) fn run_kwt_pull_request_import( pub(crate) fn run_kwt_worktree_remove( scene: &Arc, task: &KwtWorktreeTask, - worktree_path: &str, - generation: &str, - session_name: &str, - socket_name: Option<&str>, - live_target: Option<&host::LiveSessionTarget>, + operation: &KwtWorktreeOperation, ) -> KwtWorktreeOutcome { + let KwtWorktreeOperation::Remove { + worktree_path, + generation, + session_name, + socket_name, + attach_mode, + live_target, + .. + } = operation + else { + unreachable!("worktree removal requires a remove operation"); + }; + let socket_name = socket_name.as_deref(); + let live_target = live_target.as_deref(); let _session_operation = scene .runtime .session_operations .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Err(error) = - preflight_kwt_worktree_remove(task, worktree_path, generation, session_name, socket_name) - { + if let Err(error) = preflight_kwt_worktree_remove( + task, + worktree_path, + generation, + session_name, + socket_name, + *attach_mode, + ) { fail_kwt_worktree_remove(scene, task, error); return KwtWorktreeOutcome::default(); } @@ -3930,7 +3938,9 @@ pub(crate) fn publish_kwt_inventory( .worktrees() .iter() .map(|worktree| { - let available = worktree.tmux_socket_name().is_none() + let available = worktree.tmux_attach_mode() + == KwtTmuxAttachMode::Direct + && worktree.tmux_socket_name().is_none() && session_names.contains(worktree.session_name()); WorktreeItem::new( worktree.path(), @@ -3938,7 +3948,10 @@ pub(crate) fn publish_kwt_inventory( worktree.is_main(), worktree.generation().map(str::to_owned), worktree.session_name(), - worktree.tmux_socket_name().map(str::to_owned), + crate::KwtTmuxEndpoint::new( + worktree.tmux_socket_name().map(str::to_owned), + worktree.tmux_attach_mode(), + ), available, ) }) @@ -3954,7 +3967,9 @@ pub(crate) fn publish_kwt_inventory( workspace.name(), workspace.path(), workspace.session_name(), - workspace.session_live() && session_names.contains(workspace.session_name()), + workspace.tmux_socket_name().map(str::to_owned), + workspace.tmux_attach_mode(), + workspace.session_live(), ) }) .collect(); @@ -6979,6 +6994,7 @@ pub(crate) fn attach_fresh<'scene>( path, generation, session_name, + tmux_socket_name, } => { let cancellation = CancellationToken::new(); let open = host::KwtWorktreeOpen::new( @@ -6992,6 +7008,7 @@ pub(crate) fn attach_fresh<'scene>( ) })?, session_name, + tmux_socket_name.clone(), ); launch_fresh_worktree(scene, request, term, &fresh, &open, &cancellation)? } @@ -7016,6 +7033,16 @@ pub(crate) fn attach_fresh<'scene>( ); launch_fresh_protected_worktree(scene, request, term, &fresh, &open, &cancellation)? } + AttachTarget::DirectoryWorkspace { + path, + session_name, + tmux_socket_name, + } => { + let cancellation = CancellationToken::new(); + let open = + host::KwtDirectoryWorkspaceOpen::new(path, session_name, tmux_socket_name.clone()); + launch_fresh_directory_workspace(scene, request, term, &fresh, &open, &cancellation)? + } AttachTarget::Herdr { .. } => { let Some(session) = fresh_herdr_session(&fresh, &request.target) else { return Err(AttachFreshError::SessionChanged { @@ -7115,6 +7142,7 @@ pub(crate) fn attach_fresh_retained<'scene>( path, generation, session_name, + tmux_socket_name, } => { let cancellation = CancellationToken::new(); let open = host::KwtWorktreeOpen::new( @@ -7128,6 +7156,7 @@ pub(crate) fn attach_fresh_retained<'scene>( ) })?, session_name, + tmux_socket_name.clone(), ); let (worker, snapshot, name, geometry, _actual_term) = launch_fresh_worktree( scene, @@ -7168,6 +7197,25 @@ pub(crate) fn attach_fresh_retained<'scene>( )?; (worker, snapshot, name, geometry) } + AttachTarget::DirectoryWorkspace { + path, + session_name, + tmux_socket_name, + } => { + let cancellation = CancellationToken::new(); + let open = + host::KwtDirectoryWorkspaceOpen::new(path, session_name, tmux_socket_name.clone()); + let (worker, snapshot, name, geometry, _actual_term) = + launch_fresh_directory_workspace( + scene, + &resolved_request, + AttachTerm::Xterm, + &fresh, + &open, + &cancellation, + )?; + (worker, snapshot, name, geometry) + } AttachTarget::Herdr { .. } => { let session = fresh_herdr_session(&fresh, &retry.key.target) .expect("resolved retained request has a matching Herdr session"); @@ -7241,6 +7289,7 @@ pub(crate) fn capture_kwt_worktree_request( generation: Option<&str>, session_name: &str, tmux_socket_name: Option<&str>, + tmux_attach_mode: KwtTmuxAttachMode, ) -> Result { if host_id != "wsl" || scene @@ -7294,6 +7343,7 @@ pub(crate) fn capture_kwt_worktree_request( && worktree.generation.as_deref() == generation && worktree.session_name == session_name && worktree.tmux_socket_name.as_deref() == tmux_socket_name + && worktree.tmux_attach_mode == tmux_attach_mode }) }) .ok_or_else(|| { @@ -7306,7 +7356,12 @@ pub(crate) fn capture_kwt_worktree_request( host: context.host.clone(), endpoint: context.snapshot.endpoint().clone(), runtime: context.snapshot.runtime().clone(), - target: if let Some(tmux_socket_name) = &worktree.tmux_socket_name { + target: if worktree.tmux_attach_mode == KwtTmuxAttachMode::Protected { + let tmux_socket_name = worktree.tmux_socket_name.clone().ok_or_else(|| { + WorkspaceError::new( + "protected worktree endpoint is unresolved; refresh KWT inventory", + ) + })?; AttachTarget::ProtectedWorktree { repository: repository.to_owned(), project_path: project_path.to_owned(), @@ -7318,7 +7373,7 @@ pub(crate) fn capture_kwt_worktree_request( ) })?, session_name: worktree.session_name.clone(), - tmux_socket_name: tmux_socket_name.clone(), + tmux_socket_name, } } else { AttachTarget::Worktree { @@ -7327,6 +7382,7 @@ pub(crate) fn capture_kwt_worktree_request( path: worktree.path.clone(), generation: worktree.generation.clone(), session_name: worktree.session_name.clone(), + tmux_socket_name: worktree.tmux_socket_name.clone(), } }, name: worktree.session_name.clone(), @@ -7335,6 +7391,88 @@ pub(crate) fn capture_kwt_worktree_request( }) } +pub(crate) fn capture_kwt_directory_workspace_request( + scene: &Scene, + host_id: &str, + endpoint: &str, + path: &str, + session_name: &str, + tmux_socket_name: Option<&str>, + tmux_attach_mode: KwtTmuxAttachMode, +) -> Result { + if host_id != "wsl" + || scene + .selected_host + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_deref() + != Some(host_id) + { + return Err(WorkspaceError::new("the WSL host is not selected")); + } + if tmux_attach_mode != KwtTmuxAttachMode::Direct { + return Err(WorkspaceError::new( + "protected directory workspaces are not attachable through KWT open", + )); + } + let host = scene + .runtime + .host + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let context = host + .as_ref() + .ok_or_else(|| WorkspaceError::new("WSL inventory is not ready"))?; + context.map(|context, inventory_generation| { + if context.snapshot.endpoint().distro() != endpoint { + return Err(WorkspaceError::new( + "host endpoint changed; refresh the directory workspace selection", + )); + } + let hosts = scene + .runtime + .hosts + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let host_item = hosts + .iter() + .find(|host| host.id == host_id && host.endpoint == endpoint) + .ok_or_else(|| WorkspaceError::new("the selected WSL host is unavailable"))?; + if host_item.connection != HostConnectionState::Ready || !host_item.kwt_available() { + return Err(WorkspaceError::new( + "refresh KWT inventory before opening this directory workspace", + )); + } + let workspace = host_item + .directory_workspaces + .iter() + .find(|workspace| { + workspace.path == path + && workspace.session_name == session_name + && workspace.tmux_socket_name.as_deref() == tmux_socket_name + && workspace.tmux_attach_mode == tmux_attach_mode + }) + .ok_or_else(|| { + WorkspaceError::new( + "the selected directory workspace is no longer in authoritative KWT inventory", + ) + })?; + Ok(AttachRequest { + host_id: host_id.to_owned(), + host: context.host.clone(), + endpoint: context.snapshot.endpoint().clone(), + runtime: context.snapshot.runtime().clone(), + target: AttachTarget::DirectoryWorkspace { + path: workspace.path.clone(), + session_name: workspace.session_name.clone(), + tmux_socket_name: workspace.tmux_socket_name.clone(), + }, + name: workspace.session_name.clone(), + inventory_generation, + }) + }) +} + pub(crate) fn launch_fresh_worktree( scene: &Scene, request: &AttachRequest, @@ -7370,6 +7508,202 @@ pub(crate) fn launch_fresh_worktree( } } +pub(crate) fn launch_fresh_directory_workspace( + scene: &Scene, + request: &AttachRequest, + term: AttachTerm, + fresh: &HostSnapshot, + open: &host::KwtDirectoryWorkspaceOpen, + cancellation: &CancellationToken, +) -> Result< + ( + TerminalWorker, + HostSnapshot, + String, + TerminalGeometry, + AttachTerm, + ), + AttachFreshError, +> { + match launch_fresh_directory_workspace_once(scene, request, term, fresh, open, cancellation) { + Ok((worker, snapshot, name, geometry)) => Ok((worker, snapshot, name, geometry, term)), + Err(WorktreeLaunchError::RetryWithXterm) if term == AttachTerm::Xterm256Color => { + let (worker, snapshot, name, geometry) = launch_fresh_directory_workspace_once( + scene, + request, + AttachTerm::Xterm, + fresh, + open, + cancellation, + ) + .map_err(WorktreeLaunchError::into_attach_error)?; + Ok((worker, snapshot, name, geometry, AttachTerm::Xterm)) + } + Err(error) => Err(error.into_attach_error()), + } +} + +fn validate_opened_kwt_client( + request: &AttachRequest, + fresh: &HostSnapshot, + socket_name: Option<&str>, + session_name: &str, + client_identity: &session::SessionIdentity, + cancellation: &CancellationToken, + workspace_kind: &str, +) -> Result { + if let Some(socket_name) = socket_name { + let live = request + .host + .capture_live_session_on_socket( + fresh.endpoint(), + fresh.runtime(), + socket_name, + session_name, + cancellation, + ) + .map_err(|error| WorktreeLaunchError::Attach(kwt_attachment_failure(fresh, error)))?; + if live.identity() != client_identity { + return Err(WorktreeLaunchError::Attach(kwt_attachment_failure( + fresh, + format!( + "KWT attached its client to a session that did not match the {workspace_kind} endpoint" + ), + ))); + } + return Ok(fresh.clone()); + } + let discovered = request + .host + .discover_with_cancel(&ConptyAdmissionAttacher::new(), cancellation) + .map_err(|error| WorktreeLaunchError::Attach(kwt_attachment_failure(fresh, error)))?; + if discovered.endpoint() != &request.endpoint || discovered.runtime() != &request.runtime { + return Err(WorktreeLaunchError::Attach(kwt_attachment_failure( + fresh, + format!("WSL changed while opening the {workspace_kind} session"), + ))); + } + let identity_matches = discovered + .sessions() + .iter() + .any(|session| session.name() == session_name && session.identity() == client_identity); + if !identity_matches { + return Err(WorktreeLaunchError::Attach(kwt_attachment_failure( + fresh, + format!( + "KWT attached its client to a session that did not match the {workspace_kind} inventory" + ), + ))); + } + Ok(discovered) +} + +fn launch_fresh_directory_workspace_once( + scene: &Scene, + request: &AttachRequest, + term: AttachTerm, + fresh: &HostSnapshot, + open: &host::KwtDirectoryWorkspaceOpen, + cancellation: &CancellationToken, +) -> Result<(TerminalWorker, HostSnapshot, String, TerminalGeometry), WorktreeLaunchError> { + let exact = request + .host + .discover_kwt(fresh.endpoint(), fresh.runtime(), cancellation) + .map_err(|error| WorktreeLaunchError::Attach(kwt_attachment_failure(fresh, error)))? + .is_some_and(|inventory| { + inventory.directory_workspaces().iter().any(|workspace| { + workspace.path() == open.path() + && workspace.session_name() == open.session_name() + && workspace.tmux_socket_name() == open.tmux_socket_name() + && workspace.tmux_attach_mode() == KwtTmuxAttachMode::Direct + }) + }); + if !exact { + return Err(WorktreeLaunchError::Attach(kwt_attachment_failure( + fresh, + "directory workspace endpoint changed; refresh and choose it again", + ))); + } + let plan = request + .host + .kwt_directory_open_plan(fresh.endpoint(), fresh.runtime(), open, term, cancellation) + .map_err(|error| WorktreeLaunchError::Attach(kwt_attachment_failure(fresh, error)))?; + let geometry = *scene + .terminal_geometry + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let worker = TerminalWorker::repair_or_open_with_metadata( + &plan, + geometry.grid, + geometry.sequence, + geometry.pixels, + ClipboardPolicy::remote(scene.runtime.allow_remote_clipboard_write), + current_default_colors(&scene.runtime), + current_default_cursor_shape(&scene.runtime), + ) + .map_err(|error| { + WorktreeLaunchError::Attach(AttachFreshError::Host(WorkspaceError::new( + error.to_string(), + ))) + })?; + let readiness_path = plan.readiness_path().to_owned(); + let client_identity = wait_for_worktree_client_startup( + term, + cancellation, + &WORKTREE_CLIENT_STARTUP_BACKOFF, + || { + worker + .startup_status() + .map_err(|error| WorkspaceError::new(error.to_string())) + }, + || { + request + .host + .kwt_client_session_identity( + fresh.endpoint(), + fresh.runtime(), + &readiness_path, + open.tmux_socket_name(), + cancellation, + ) + .map_err(|error| WorkspaceError::new(error.to_string())) + }, + ); + request.host.remove_kwt_client_readiness( + fresh.endpoint(), + &readiness_path, + &CancellationToken::new(), + ); + let client_identity = match client_identity { + Ok(identity) => identity, + Err(error) => { + drop(worker); + return Err(match error { + WorktreeClientStartupError::RetryWithXterm => WorktreeLaunchError::RetryWithXterm, + WorktreeClientStartupError::Failed(error) => { + WorktreeLaunchError::Attach(kwt_attachment_failure(fresh, error)) + } + }); + } + }; + let opened = validate_opened_kwt_client( + request, + fresh, + open.tmux_socket_name(), + open.session_name(), + &client_identity, + cancellation, + "directory workspace", + ); + match opened { + Ok(snapshot) => Ok((worker, snapshot, plan.target_name().to_owned(), geometry)), + Err(error) => { + drop(worker); + Err(error) + } + } +} + pub(crate) fn launch_fresh_protected_worktree( scene: &Scene, request: &AttachRequest, @@ -7536,6 +7870,7 @@ pub(crate) fn launch_fresh_worktree_once( fresh.endpoint(), fresh.runtime(), &readiness_path, + open.tmux_socket_name(), cancellation, ) .map_err(|error| WorkspaceError::new(error.to_string())) @@ -7558,28 +7893,22 @@ pub(crate) fn launch_fresh_worktree_once( }); } }; - let discovered = request - .host - .discover_with_cancel(&ConptyAdmissionAttacher::new(), cancellation) - .map_err(|error| WorktreeLaunchError::Attach(kwt_attachment_failure(fresh, error)))?; - if discovered.endpoint() != &request.endpoint || discovered.runtime() != &request.runtime { - drop(worker); - return Err(WorktreeLaunchError::Attach(kwt_attachment_failure( - fresh, - "WSL changed while opening the worktree session", - ))); - } - let identity_matches = discovered.sessions().iter().any(|session| { - session.name() == open.session_name() && session.identity() == &client_identity - }); - if !identity_matches { - drop(worker); - return Err(WorktreeLaunchError::Attach(kwt_attachment_failure( - fresh, - "KWT attached its client to a session that did not match the worktree inventory", - ))); + let opened = validate_opened_kwt_client( + request, + fresh, + open.tmux_socket_name(), + open.session_name(), + &client_identity, + cancellation, + "worktree", + ); + match opened { + Ok(snapshot) => Ok((worker, snapshot, plan.target_name().to_owned(), geometry)), + Err(error) => { + drop(worker); + Err(error) + } } - Ok((worker, discovered, plan.target_name().to_owned(), geometry)) } pub(crate) fn launch_fresh_herdr( diff --git a/rust/workspace/src/tests.rs b/rust/workspace/src/tests.rs index 981a0137..504579aa 100644 --- a/rust/workspace/src/tests.rs +++ b/rust/workspace/src/tests.rs @@ -442,7 +442,10 @@ fn worktree_removal_capture_requires_the_reviewed_tmux_socket() { false, Some(generation.to_owned()), "project-protected", - Some("kwt-pr-reviewed".to_owned()), + KwtTmuxEndpoint::new( + Some("kwt-pr-reviewed".to_owned()), + KwtTmuxAttachMode::Protected, + ), false, )); @@ -457,6 +460,7 @@ fn worktree_removal_capture_requires_the_reviewed_tmux_socket() { generation, "project-protected", Some("kwt-pr-reviewed"), + KwtTmuxAttachMode::Protected, ) .expect("the reviewed protected socket grants removal capture"); assert_eq!(captured.3.as_deref(), Some("kwt-pr-reviewed")); @@ -475,6 +479,7 @@ fn worktree_removal_capture_requires_the_reviewed_tmux_socket() { generation, "project-protected", Some("kwt-pr-reviewed"), + KwtTmuxAttachMode::Protected, ) .is_err(), "a changed protected socket requires a fresh removal confirmation" @@ -505,7 +510,7 @@ fn later_kwt_inventory_resolves_a_pending_created_worktree_once() { }); let inventory = KwtInventory::parse( br#"[{"repository":"github.com/acme/widget","name":"widget","path":"/code/widget","last_touched":null,"registration_fingerprint":"registration"}]"#, - br#"[{"path":"/work/widget/new","branch":"feature/new","commit_hash":"abc","is_main":false,"created_at":null,"generation":"0123456789abcdef0123456789abcdef","repository":"github.com/acme/widget","session_name":"widget-new","tmux_socket_name":null}]"#, + br#"[{"path":"/work/widget/new","branch":"feature/new","commit_hash":"abc","is_main":false,"created_at":null,"generation":"0123456789abcdef0123456789abcdef","repository":"github.com/acme/widget","session_name":"widget-new","tmux_socket_name":null,"tmux_attach_mode":"direct"}]"#, b"[]", ) .expect("valid KWT inventory"); @@ -565,7 +570,7 @@ fn pending_creation_ignores_a_preexisting_same_branch_worktree_and_expires() { }); let inventory = KwtInventory::parse( br#"[{"repository":"github.com/acme/widget","name":"widget","path":"/code/widget","last_touched":null,"registration_fingerprint":"registration"}]"#, - br#"[{"path":"/work/widget/existing","branch":"feature/new","commit_hash":"def","is_main":false,"created_at":null,"generation":"fedcba9876543210fedcba9876543210","repository":"github.com/acme/widget","session_name":"widget-existing","tmux_socket_name":null}]"#, + br#"[{"path":"/work/widget/existing","branch":"feature/new","commit_hash":"def","is_main":false,"created_at":null,"generation":"fedcba9876543210fedcba9876543210","repository":"github.com/acme/widget","session_name":"widget-existing","tmux_socket_name":null,"tmux_attach_mode":"direct"}]"#, b"[]", ) .expect("valid KWT inventory"); @@ -609,7 +614,7 @@ fn confirmed_creation_expiry_rejects_a_late_same_branch_worktree() { }); let inventory = KwtInventory::parse( br#"[{"repository":"github.com/acme/widget","name":"widget","path":"/code/widget","last_touched":null,"registration_fingerprint":"registration"}]"#, - br#"[{"path":"/work/widget/late","branch":"feature/new","commit_hash":"abc","is_main":false,"created_at":null,"generation":"0123456789abcdef0123456789abcdef","repository":"github.com/acme/widget","session_name":"widget-late","tmux_socket_name":null}]"#, + br#"[{"path":"/work/widget/late","branch":"feature/new","commit_hash":"abc","is_main":false,"created_at":null,"generation":"0123456789abcdef0123456789abcdef","repository":"github.com/acme/widget","session_name":"widget-late","tmux_socket_name":null,"tmux_attach_mode":"direct"}]"#, b"[]", ) .expect("valid KWT inventory"); @@ -666,6 +671,7 @@ fn worktree_removal_authority_can_be_restored_before_dispatch() { generation: "0123456789abcdef0123456789abcdef".to_owned(), session_name: "widget-topic".to_owned(), socket_name: None, + attach_mode: KwtTmuxAttachMode::Direct, live_target: Some(Arc::new(host::LiveSessionTarget::test_fixture( &snapshot, "widget-topic", @@ -683,6 +689,7 @@ fn worktree_removal_authority_can_be_restored_before_dispatch() { "/work/widget/topic", "0123456789abcdef0123456789abcdef", "widget-topic", + KwtTmuxAttachMode::Direct, ) .expect("exact confirmation authority"); @@ -730,7 +737,7 @@ fn worktree_removal_reservation_requires_the_exact_non_main_inventory_row() { true, Some("11111111111111111111111111111111".to_owned()), "widget-main", - None, + KwtTmuxEndpoint::new(None, KwtTmuxAttachMode::Direct), false, ), WorktreeItem::new( @@ -739,7 +746,7 @@ fn worktree_removal_reservation_requires_the_exact_non_main_inventory_row() { false, Some("22222222222222222222222222222222".to_owned()), "widget-topic", - None, + KwtTmuxEndpoint::new(None, KwtTmuxAttachMode::Direct), true, ), WorktreeItem::new( @@ -748,7 +755,10 @@ fn worktree_removal_reservation_requires_the_exact_non_main_inventory_row() { false, Some("33333333333333333333333333333333".to_owned()), "widget-protected", - Some("protected-socket".to_owned()), + KwtTmuxEndpoint::new( + Some("protected-socket".to_owned()), + KwtTmuxAttachMode::Protected, + ), false, ), ], @@ -759,60 +769,60 @@ fn worktree_removal_reservation_requires_the_exact_non_main_inventory_row() { generation: generation.to_owned(), session_name: session.to_owned(), socket_name: socket_name.map(str::to_owned), + attach_mode: if socket_name.is_some() { + KwtTmuxAttachMode::Protected + } else { + KwtTmuxAttachMode::Direct + }, live_target: None, operation_id: 1, } }; - assert!( - validate_kwt_worktree_operation( - &project, - &remove( + let cases = [ + ( + remove( "/work/widget/topic", "22222222222222222222222222222222", "widget-topic", None, ), - ) - .is_ok() - ); - assert!( - validate_kwt_worktree_operation( - &project, - &remove( + true, + ), + ( + remove( "/code/widget", "11111111111111111111111111111111", "widget-main", None, ), - ) - .is_err() - ); - assert!( - validate_kwt_worktree_operation( - &project, - &remove( + false, + ), + ( + remove( "/work/widget/topic", "22222222222222222222222222222222", "replacement", None, ), - ) - .is_err() - ); - assert!( - validate_kwt_worktree_operation( - &project, - &remove( + false, + ), + ( + remove( "/work/widget/protected", "33333333333333333333333333333333", "widget-protected", Some("protected-socket"), ), - ) - .is_ok(), - "custom-socket worktrees are removable only through their exact protected socket" - ); + true, + ), + ]; + for (operation, valid) in cases { + assert_eq!( + validate_kwt_worktree_operation(&project, &operation).is_ok(), + valid + ); + } } #[test] @@ -824,6 +834,7 @@ fn killed_tmux_cleanup_matches_worktree_presentations_by_authoritative_name() { path: "/work/project/topic".to_owned(), generation: Some("generation".to_owned()), session_name: "project-topic".to_owned(), + tmux_socket_name: None, }; assert!(attach_target_matches_killed_tmux( @@ -865,6 +876,7 @@ fn worktree_navigation_reuses_the_equivalent_discovered_tmux_identity() { path: "/work/project/topic".to_owned(), generation: Some("generation".to_owned()), session_name: "project-topic".to_owned(), + tmux_socket_name: None, }; let key = worktree_tmux_presentation_key(&worktree, &snapshot) @@ -894,6 +906,7 @@ fn launched_worktree_identity_remains_reusable_after_it_becomes_unbound() { path: "/work/project/topic".to_owned(), generation: Some("generation".to_owned()), session_name: "project-topic".to_owned(), + tmux_socket_name: None, }; let worktree_key = worktree.presentation_key(); @@ -959,7 +972,7 @@ fn successful_worktree_removal_tombstones_only_the_exact_cached_generation() { false, Some("old-generation".to_owned()), "project-topic", - None, + KwtTmuxEndpoint::new(None, KwtTmuxAttachMode::Direct), false, ), WorktreeItem::new( @@ -968,7 +981,7 @@ fn successful_worktree_removal_tombstones_only_the_exact_cached_generation() { false, Some("replacement-generation".to_owned()), "project-topic", - None, + KwtTmuxEndpoint::new(None, KwtTmuxAttachMode::Direct), false, ), ], @@ -1575,7 +1588,7 @@ fn kwt_worktree_workspace_fixture() -> (Workspace, Arc) { .store(7, Ordering::Release); let inventory = KwtInventory::parse( br#"[{"repository":"project-id","name":"project","path":"/repos/project","last_touched":null,"registration_fingerprint":"project-fingerprint"}]"#, - br#"[{"path":"/repos/project","branch":"main","commit_hash":"abc","is_main":true,"created_at":null,"generation":"0123456789abcdef0123456789abcdef","repository":"project-id","session_name":"project-main","tmux_socket_name":null}]"#, + br#"[{"path":"/repos/project","branch":"main","commit_hash":"abc","is_main":true,"created_at":null,"generation":"0123456789abcdef0123456789abcdef","repository":"project-id","session_name":"project-main","tmux_socket_name":null,"tmux_attach_mode":"direct"}]"#, b"[]", ) .expect("valid KWT inventory"); @@ -5527,6 +5540,16 @@ fn host_refresh_keeps_cached_multiplexer_rows_visible() { assert_eq!(host.zellij_sessions()[0].name(), "zellij-work"); } +fn publish_kwt_fixture(workspace: &Workspace, snapshot: &HostSnapshot, inventory: &KwtInventory) { + publish_kwt_inventory( + &workspace.scene, + 7, + snapshot.endpoint(), + snapshot.runtime(), + inventory, + ); +} + #[test] fn kwt_inventory_projects_worktrees_without_replacing_session_state() { let bundle = @@ -5570,18 +5593,12 @@ fn kwt_inventory_projects_worktrees_without_replacing_session_state() { .store(7, Ordering::Release); let inventory = KwtInventory::parse( br#"[{"repository":"project-id","name":"project","path":"/repos/project","last_touched":null,"registration_fingerprint":"project-fingerprint"}]"#, - br#"[{"path":"/repos/project","branch":"main","commit_hash":"abc","is_main":true,"created_at":null,"generation":"g1","repository":"project-id","session_name":"project-main","tmux_socket_name":null}]"#, - br#"[{"name":"scratch","path":"/work/scratch","session_name":"scratch","session_live":false}]"#, + br#"[{"path":"/repos/project","branch":"main","commit_hash":"abc","is_main":true,"created_at":null,"generation":"g1","repository":"project-id","session_name":"project-main","tmux_socket_name":null,"tmux_attach_mode":"direct"}]"#, + br#"[{"name":"scratch","path":"/work/scratch","session_name":"scratch","session_live":false,"tmux_socket_name":"kwt","tmux_attach_mode":"direct"}]"#, ) .expect("valid KWT inventory"); - publish_kwt_inventory( - &workspace.scene, - 7, - snapshot.endpoint(), - snapshot.runtime(), - &inventory, - ); + publish_kwt_fixture(&workspace, &snapshot, &inventory); let projected = workspace.snapshot(); assert!(matches!(projected.content(), WorkspaceContent::Shell)); @@ -5590,8 +5607,15 @@ fn kwt_inventory_projects_worktrees_without_replacing_session_state() { assert_eq!(host.projects()[0].name(), "project"); assert_eq!(host.projects()[0].worktrees()[0].branch(), "main"); assert!(host.projects()[0].worktrees()[0].session_available()); - assert_eq!(host.directory_workspaces()[0].name(), "scratch"); - assert!(!host.directory_workspaces()[0].session_available()); + let directory = &host.directory_workspaces()[0]; + assert_eq!( + (directory.name(), directory.session_available()), + ("scratch", false) + ); + assert_eq!( + (directory.tmux_socket_name(), directory.tmux_attach_mode()), + (Some("kwt"), KwtTmuxAttachMode::Direct) + ); set_inventory_state( &workspace.scene.runtime, @@ -5673,8 +5697,8 @@ fn worktree_open_uses_durable_kwt_identity_even_without_a_live_tmux_session() { .store(7, Ordering::Release); let inventory = KwtInventory::parse( br#"[{"repository":"project-id","name":"project","path":"/repos/project","last_touched":null,"registration_fingerprint":"project-fingerprint"}]"#, - br#"[{"path":"/work/project/topic","branch":"topic","commit_hash":"abc","is_main":false,"created_at":null,"generation":"g7","repository":"project-id","session_name":"project-topic","tmux_socket_name":null},{"path":"/work/project/pr-17","branch":"pr-17","commit_hash":"def","is_main":false,"created_at":null,"generation":"g8","repository":"project-id","session_name":"project-pr-17","tmux_socket_name":"kwt-pr-a1b2"}]"#, - b"[]", + br#"[{"path":"/work/project/topic","branch":"topic","commit_hash":"abc","is_main":false,"created_at":null,"generation":"g7","repository":"project-id","session_name":"project-topic","tmux_socket_name":"kwt","tmux_attach_mode":"direct"},{"path":"/work/project/pr-17","branch":"pr-17","commit_hash":"def","is_main":false,"created_at":null,"generation":"g8","repository":"project-id","session_name":"project-pr-17","tmux_socket_name":"kwt-pr-a1b2","tmux_attach_mode":"protected"}]"#, + br#"[{"name":"scratch","path":"/work/scratch","session_name":"kwt-workspace-dir-scratch","session_live":true,"tmux_socket_name":"kwt","tmux_attach_mode":"direct"}]"#, ) .expect("valid KWT inventory"); publish_kwt_inventory( @@ -5695,11 +5719,24 @@ fn worktree_open_uses_durable_kwt_identity_even_without_a_live_tmux_session() { "/work/project/topic", Some("g7"), "project-topic", - None, + Some("kwt"), + KwtTmuxAttachMode::Direct, ) .expect("KWT identity grants repair-or-open authority"); - assert!(matches!(request.target, AttachTarget::Worktree { .. })); + assert!(matches!( + request.target, + AttachTarget::Worktree { + ref tmux_socket_name, + .. + } if tmux_socket_name.as_deref() == Some("kwt") + )); assert_eq!(request.name, "project-topic"); + let direct_selection = request.selection(); + assert_eq!( + direct_selection.tmux_attach_mode(), + Some(KwtTmuxAttachMode::Direct) + ); + assert_eq!(direct_selection.tmux_socket_name(), Some("kwt")); let protected = capture_kwt_worktree_request( &workspace.scene, "wsl", @@ -5711,6 +5748,7 @@ fn worktree_open_uses_durable_kwt_identity_even_without_a_live_tmux_session() { Some("g8"), "project-pr-17", Some("kwt-pr-a1b2"), + KwtTmuxAttachMode::Protected, ) .expect("KWT identity grants protected attach authority"); assert!(matches!( @@ -5719,12 +5757,39 @@ fn worktree_open_uses_durable_kwt_identity_even_without_a_live_tmux_session() { if tmux_socket_name == "kwt-pr-a1b2" )); let protected_selection = protected.selection(); + assert_eq!( + protected_selection.tmux_attach_mode(), + Some(KwtTmuxAttachMode::Protected) + ); assert_eq!(protected_selection.tmux_socket_name(), Some("kwt-pr-a1b2")); assert_ne!( protected_selection, SessionSelection::new("wsl", "Ubuntu", "project-pr-17"), "a same-named default-socket session is a different presentation" ); + let directory = capture_kwt_directory_workspace_request( + &workspace.scene, + "wsl", + "Ubuntu", + "/work/scratch", + "kwt-workspace-dir-scratch", + Some("kwt"), + KwtTmuxAttachMode::Direct, + ) + .expect("KWT directory identity grants open authority"); + assert!(matches!( + directory.target, + AttachTarget::DirectoryWorkspace { + ref tmux_socket_name, + .. + } if tmux_socket_name.as_deref() == Some("kwt") + )); + let directory_selection = directory.selection(); + assert_eq!(directory_selection.tmux_socket_name(), Some("kwt")); + assert_eq!( + directory_selection.tmux_attach_mode(), + Some(KwtTmuxAttachMode::Direct) + ); assert!( capture_kwt_worktree_request( &workspace.scene, @@ -5737,6 +5802,7 @@ fn worktree_open_uses_durable_kwt_identity_even_without_a_live_tmux_session() { Some("g8"), "project-pr-17", Some("kwt-pr-replaced"), + KwtTmuxAttachMode::Protected, ) .is_err(), "a stale protected-socket action cannot open the replacement server" @@ -5758,7 +5824,8 @@ fn worktree_open_uses_durable_kwt_identity_even_without_a_live_tmux_session() { "/work/project/topic", Some("stale"), "project-topic", - None, + Some("kwt"), + KwtTmuxAttachMode::Direct, ) .is_err() ); @@ -8281,6 +8348,7 @@ fn removed_kwt_worktree_drops_matching_confirmations_in_every_scene() { generation: generation.to_owned(), session_name: "widget-topic".to_owned(), socket_name: None, + attach_mode: KwtTmuxAttachMode::Direct, live_target: None, }; *b.scene @@ -8336,7 +8404,7 @@ fn removed_kwt_worktree_drops_matching_confirmations_in_every_scene() { fn kwt_removal_reconciliation_branches_gate_confirmation_drops() { fn widget_inventory(with_topic_worktree: bool) -> KwtInventory { let worktrees: &[u8] = if with_topic_worktree { - br#"[{"path":"/work/widget/topic","branch":"topic","commit_hash":"abc","is_main":false,"created_at":null,"generation":"0123456789abcdef0123456789abcdef","repository":"github.com/acme/widget","session_name":"widget-topic","tmux_socket_name":null}]"# + br#"[{"path":"/work/widget/topic","branch":"topic","commit_hash":"abc","is_main":false,"created_at":null,"generation":"0123456789abcdef0123456789abcdef","repository":"github.com/acme/widget","session_name":"widget-topic","tmux_socket_name":null,"tmux_attach_mode":"direct"}]"# } else { b"[]" }; @@ -8367,6 +8435,7 @@ fn kwt_removal_reconciliation_branches_gate_confirmation_drops() { generation: generation.to_owned(), session_name: "widget-topic".to_owned(), socket_name: None, + attach_mode: KwtTmuxAttachMode::Direct, live_target: None, }); }; @@ -8421,6 +8490,7 @@ fn kwt_removal_reconciliation_branches_gate_confirmation_drops() { generation: generation.to_owned(), session_name: "widget-topic".to_owned(), socket_name: None, + attach_mode: KwtTmuxAttachMode::Direct, live_target: None, operation_id: 9, }, @@ -9341,6 +9411,7 @@ fn removal_completing_during_identity_capture_cannot_publish_a_stale_confirmatio generation: generation.to_owned(), session_name: "widget-topic".to_owned(), socket_name: None, + attach_mode: KwtTmuxAttachMode::Direct, }; // Both scenes' removal requests recorded their capture intents (the // request registration writes exactly this shape) and the captured @@ -10843,6 +10914,7 @@ fn created_worktree_target() -> KwtWorktreeTarget { generation: Some("0123456789abcdef0123456789abcdef".to_owned()), session_name: "widget-topic".to_owned(), tmux_socket_name: None, + tmux_attach_mode: KwtTmuxAttachMode::Direct, } } @@ -11824,6 +11896,7 @@ fn destructive_lifecycle_paths_refuse_a_closed_scene() { &"a".repeat(40), "ghosthub/web", None, + KwtTmuxAttachMode::Direct, ) .expect_err("a closed scene arms no worktree removal"); assert!(error.to_string().contains("closed"), "{error}");