aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/worktrees.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/client/shell/worktrees.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/client/shell/worktrees.rs')
-rw-r--r--src/client/shell/worktrees.rs545
1 files changed, 545 insertions, 0 deletions
diff --git a/src/client/shell/worktrees.rs b/src/client/shell/worktrees.rs
new file mode 100644
index 00000000..792cd248
--- /dev/null
+++ b/src/client/shell/worktrees.rs
@@ -0,0 +1,545 @@
+use super::*;
+
+impl ClientShellState {
+ fn endpoint_worktree_directory(&self) -> Option<std::path::PathBuf> {
+ self.snapshot
+ .as_deref()
+ .map(|snapshot| std::path::PathBuf::from(&snapshot.worktree_directory))
+ }
+
+ pub(super) fn insert_worktree_overlay_text(&mut self, text: &str) -> bool {
+ match self.overlay.as_mut() {
+ Some(ClientShellOverlay::WorktreeCreate(create)) if !create.creating => {
+ if create.replace_on_type {
+ create.branch.clear();
+ create.replace_on_type = false;
+ }
+ create.branch.push_str(text);
+ self.sync_worktree_create_path();
+ true
+ }
+ Some(ClientShellOverlay::WorktreeOpen(open))
+ if open.search_focused && !open.opening =>
+ {
+ open.query.push_str(text);
+ if let Some(first) = open.filtered_indices().first().copied() {
+ open.selected = first;
+ }
+ true
+ }
+ _ => false,
+ }
+ }
+
+ pub(super) fn route_worktree_overlay_key(
+ &mut self,
+ key: &crate::input::TerminalKey,
+ outcome: &mut ClientShellInput,
+ ) -> bool {
+ let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers));
+ match self.overlay.as_ref() {
+ Some(ClientShellOverlay::WorktreeCreate(_)) => {
+ let creating = matches!(
+ self.overlay,
+ Some(ClientShellOverlay::WorktreeCreate(
+ ClientWorktreeCreateOverlay { creating: true, .. }
+ ))
+ );
+ match code {
+ KeyCode::Esc if !creating => {
+ self.overlay = None;
+ outcome.repaint = true;
+ }
+ KeyCode::Enter => self.submit_worktree_create(outcome),
+ KeyCode::Backspace if !creating => {
+ if let Some(ClientShellOverlay::WorktreeCreate(create)) =
+ self.overlay.as_mut()
+ {
+ if create.replace_on_type {
+ create.branch.clear();
+ create.replace_on_type = false;
+ } else {
+ create.branch.pop();
+ }
+ }
+ self.sync_worktree_create_path();
+ outcome.repaint = true;
+ }
+ KeyCode::Char(character)
+ if !creating
+ && modifiers
+ .difference(crossterm::event::KeyModifiers::SHIFT)
+ .is_empty() =>
+ {
+ let text = key
+ .generated_text
+ .clone()
+ .unwrap_or_else(|| character.to_string());
+ self.insert_worktree_overlay_text(&text);
+ outcome.repaint = true;
+ }
+ _ => {}
+ }
+ true
+ }
+ Some(ClientShellOverlay::WorktreeOpen(_)) => {
+ let opening = matches!(
+ self.overlay,
+ Some(ClientShellOverlay::WorktreeOpen(
+ ClientWorktreeOpenOverlay { opening: true, .. }
+ ))
+ );
+ let search_focused = matches!(
+ self.overlay,
+ Some(ClientShellOverlay::WorktreeOpen(
+ ClientWorktreeOpenOverlay {
+ search_focused: true,
+ ..
+ }
+ ))
+ );
+ match code {
+ KeyCode::Esc if !opening => {
+ self.overlay = None;
+ outcome.repaint = true;
+ }
+ KeyCode::Enter => self.submit_worktree_open(outcome),
+ KeyCode::Up if !opening => {
+ self.move_worktree_open_selection(-1);
+ outcome.repaint = true;
+ }
+ KeyCode::Down if !opening => {
+ self.move_worktree_open_selection(1);
+ outcome.repaint = true;
+ }
+ KeyCode::Char('/') if !opening && !search_focused => {
+ if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut()
+ {
+ open.search_focused = true;
+ }
+ outcome.repaint = true;
+ }
+ KeyCode::Backspace if !opening && search_focused => {
+ if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut()
+ {
+ open.query.pop();
+ if let Some(first) = open.filtered_indices().first().copied() {
+ open.selected = first;
+ }
+ }
+ outcome.repaint = true;
+ }
+ KeyCode::Char(character)
+ if !opening
+ && search_focused
+ && modifiers
+ .difference(crossterm::event::KeyModifiers::SHIFT)
+ .is_empty() =>
+ {
+ let text = key
+ .generated_text
+ .clone()
+ .unwrap_or_else(|| character.to_string());
+ self.insert_worktree_overlay_text(&text);
+ outcome.repaint = true;
+ }
+ _ => {}
+ }
+ true
+ }
+ Some(ClientShellOverlay::WorktreeRemove(_)) => {
+ let removing = matches!(
+ self.overlay,
+ Some(ClientShellOverlay::WorktreeRemove(
+ ClientWorktreeRemoveOverlay { removing: true, .. }
+ ))
+ );
+ match code {
+ KeyCode::Esc if !removing => {
+ self.overlay = None;
+ outcome.repaint = true;
+ }
+ KeyCode::Enter => self.submit_worktree_remove(outcome),
+ _ => {}
+ }
+ true
+ }
+ _ => false,
+ }
+ }
+
+ pub(super) fn begin_worktree_action(
+ &mut self,
+ action: crate::input::KeybindAction,
+ outcome: &mut ClientShellInput,
+ ) {
+ let Some(workspace_id) = self.workspace_action_id() else {
+ return;
+ };
+ self.begin_worktree_action_for(action, workspace_id, outcome);
+ }
+
+ pub(super) fn begin_worktree_action_for(
+ &mut self,
+ action: crate::input::KeybindAction,
+ workspace_id: String,
+ outcome: &mut ClientShellInput,
+ ) {
+ use crate::api::schema::{Method, WorktreeListParams};
+ use crate::input::KeybindAction;
+
+ let workspace = self.snapshot.as_deref().and_then(|snapshot| {
+ snapshot
+ .workspaces
+ .iter()
+ .find(|workspace| workspace.workspace_id == workspace_id)
+ });
+ let linked = workspace
+ .and_then(|workspace| workspace.worktree.as_ref())
+ .is_some_and(|worktree| worktree.is_linked_worktree);
+ let kind = match action {
+ KeybindAction::NewWorktree | KeybindAction::OpenWorktree if linked => {
+ self.endpoint_error = Some(
+ "New and open worktree actions start from the repo parent workspace."
+ .to_owned(),
+ );
+ outcome.repaint = true;
+ return;
+ }
+ KeybindAction::NewWorktree => PendingEndpointKind::PrepareWorktreeCreate {
+ workspace_id: workspace_id.clone(),
+ },
+ KeybindAction::OpenWorktree => PendingEndpointKind::PrepareWorktreeOpen {
+ workspace_id: workspace_id.clone(),
+ },
+ KeybindAction::RemoveWorktree if !linked => {
+ self.endpoint_error =
+ Some("This workspace is not a Herdr-managed worktree checkout.".to_owned());
+ outcome.repaint = true;
+ return;
+ }
+ KeybindAction::RemoveWorktree => PendingEndpointKind::PrepareWorktreeRemove {
+ workspace_id: workspace_id.clone(),
+ },
+ _ => return,
+ };
+ self.push_endpoint_method_with_kind(
+ Method::WorktreeList(WorktreeListParams {
+ workspace_id: Some(workspace_id),
+ cwd: None,
+ trust_repository: false,
+ }),
+ kind,
+ outcome,
+ );
+ }
+
+ pub(super) fn sync_worktree_create_path(&mut self) {
+ let Some(worktree_directory) = self.endpoint_worktree_directory() else {
+ return;
+ };
+ let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() else {
+ return;
+ };
+ create.checkout_path = crate::worktree::default_checkout_path(
+ &worktree_directory,
+ &create.repo_name,
+ &create.branch,
+ )
+ .display()
+ .to_string();
+ create.error = None;
+ }
+
+ pub(super) fn submit_worktree_create(&mut self, outcome: &mut ClientShellInput) {
+ let Some(worktree_directory) = self.endpoint_worktree_directory() else {
+ return;
+ };
+ let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() else {
+ return;
+ };
+ if create.creating {
+ return;
+ }
+ let branch = create.branch.trim().to_owned();
+ if branch.is_empty() {
+ create.error = Some("branch is required".to_owned());
+ outcome.repaint = true;
+ return;
+ }
+ create.branch = branch.clone();
+ create.replace_on_type = false;
+ create.checkout_path =
+ crate::worktree::default_checkout_path(&worktree_directory, &create.repo_name, &branch)
+ .display()
+ .to_string();
+ create.creating = true;
+ create.error = None;
+ let workspace_id = create.source_workspace_id.clone();
+ self.push_endpoint_method_with_kind(
+ crate::api::schema::Method::WorktreeCreate(crate::api::schema::WorktreeCreateParams {
+ workspace_id: Some(workspace_id),
+ cwd: None,
+ branch: Some(branch),
+ base: Some("HEAD".to_owned()),
+ path: None,
+ label: None,
+ focus: true,
+ trust_repository: false,
+ }),
+ PendingEndpointKind::WorktreeCreate,
+ outcome,
+ );
+ outcome.repaint = true;
+ }
+
+ pub(super) fn move_worktree_open_selection(&mut self, delta: isize) {
+ let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() else {
+ return;
+ };
+ let filtered = open.filtered_indices();
+ if filtered.is_empty() {
+ open.selected = 0;
+ return;
+ }
+ let current = filtered
+ .iter()
+ .position(|index| *index == open.selected)
+ .unwrap_or(0);
+ let next = (current as isize + delta).clamp(0, filtered.len() as isize - 1) as usize;
+ open.selected = filtered[next];
+ }
+
+ pub(super) fn submit_worktree_open(&mut self, outcome: &mut ClientShellInput) {
+ let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() else {
+ return;
+ };
+ if open.opening {
+ return;
+ }
+ let Some(index) = open.selected_entry_index() else {
+ return;
+ };
+ let Some(entry) = open.entries.get(index) else {
+ return;
+ };
+ let workspace_id = open.source_workspace_id.clone();
+ let path = entry.path.clone();
+ open.selected = index;
+ open.opening = true;
+ open.error = None;
+ self.push_endpoint_method_with_kind(
+ crate::api::schema::Method::WorktreeOpen(crate::api::schema::WorktreeOpenParams {
+ workspace_id: Some(workspace_id),
+ cwd: None,
+ path: Some(path),
+ branch: None,
+ label: None,
+ focus: true,
+ trust_repository: false,
+ }),
+ PendingEndpointKind::WorktreeOpen,
+ outcome,
+ );
+ outcome.repaint = true;
+ }
+
+ pub(super) fn submit_worktree_remove(&mut self, outcome: &mut ClientShellInput) {
+ let Some(ClientShellOverlay::WorktreeRemove(remove)) = self.overlay.as_mut() else {
+ return;
+ };
+ if remove.removing {
+ return;
+ }
+ let workspace_id = remove.workspace_id.clone();
+ let forced = remove.force_confirmation;
+ remove.removing = true;
+ remove.error = None;
+ self.push_endpoint_method_with_kind(
+ crate::api::schema::Method::WorktreeRemove(crate::api::schema::WorktreeRemoveParams {
+ workspace_id,
+ force: forced,
+ trust_repository: false,
+ }),
+ PendingEndpointKind::WorktreeRemove { forced },
+ outcome,
+ );
+ outcome.repaint = true;
+ }
+
+ pub(super) fn handle_worktree_endpoint_result(
+ &mut self,
+ kind: PendingEndpointKind,
+ result: Result<crate::api::schema::ResponseResult, ClientShellEndpointError>,
+ ) -> bool {
+ use crate::api::schema::ResponseResult;
+
+ match (kind, result) {
+ (
+ PendingEndpointKind::PrepareWorktreeCreate { workspace_id },
+ Ok(ResponseResult::WorktreeList { source, .. }),
+ ) => {
+ let seed = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|duration| duration.as_micros().min(u128::from(u64::MAX)) as u64)
+ .unwrap_or(0);
+ let branch = crate::worktree::generated_branch_slug(seed);
+ let Some(worktree_directory) = self.endpoint_worktree_directory() else {
+ return false;
+ };
+ let checkout_path = crate::worktree::default_checkout_path(
+ &worktree_directory,
+ &source.repo_name,
+ &branch,
+ )
+ .display()
+ .to_string();
+ self.overlay = Some(ClientShellOverlay::WorktreeCreate(
+ ClientWorktreeCreateOverlay {
+ source_workspace_id: workspace_id,
+ repo_name: source.repo_name,
+ branch,
+ checkout_path,
+ replace_on_type: true,
+ error: None,
+ creating: false,
+ },
+ ));
+ true
+ }
+ (
+ PendingEndpointKind::PrepareWorktreeOpen { workspace_id },
+ Ok(ResponseResult::WorktreeList { worktrees, .. }),
+ ) => {
+ let entries = worktrees
+ .into_iter()
+ .filter(|entry| !entry.is_bare && !entry.is_prunable)
+ .map(|entry| {
+ let label = entry.branch.clone().unwrap_or_else(|| entry.label.clone());
+ ClientWorktreeOpenEntry {
+ path: entry.path,
+ branch: entry.branch,
+ is_linked_worktree: entry.is_linked_worktree,
+ is_detached: entry.is_detached,
+ open_workspace_id: entry.open_workspace_id,
+ label,
+ }
+ })
+ .collect::<Vec<_>>();
+ if entries.is_empty() {
+ self.endpoint_error = Some("No Git worktrees found for this repo.".to_owned());
+ } else {
+ self.overlay = Some(ClientShellOverlay::WorktreeOpen(
+ ClientWorktreeOpenOverlay {
+ source_workspace_id: workspace_id,
+ entries,
+ selected: 0,
+ query: String::new(),
+ search_focused: false,
+ error: None,
+ opening: false,
+ },
+ ));
+ }
+ true
+ }
+ (
+ PendingEndpointKind::PrepareWorktreeRemove { workspace_id },
+ Ok(ResponseResult::WorktreeList { worktrees, .. }),
+ ) => {
+ let path = worktrees
+ .into_iter()
+ .find(|entry| entry.open_workspace_id.as_deref() == Some(&workspace_id))
+ .map(|entry| entry.path);
+ if let Some(path) = path {
+ self.overlay = Some(ClientShellOverlay::WorktreeRemove(
+ ClientWorktreeRemoveOverlay {
+ workspace_id,
+ path,
+ error: None,
+ removing: false,
+ force_confirmation: false,
+ },
+ ));
+ } else {
+ self.endpoint_error =
+ Some("This workspace is not a Herdr-managed worktree checkout.".to_owned());
+ }
+ true
+ }
+ (PendingEndpointKind::WorktreeCreate, Ok(ResponseResult::WorktreeCreated { .. }))
+ | (PendingEndpointKind::WorktreeOpen, Ok(ResponseResult::WorktreeOpened { .. }))
+ | (
+ PendingEndpointKind::WorktreeRemove { .. },
+ Ok(ResponseResult::WorktreeRemoved { .. }),
+ ) => {
+ self.overlay = None;
+ true
+ }
+ (PendingEndpointKind::WorktreeCreate, Err(error)) => {
+ if let Some(ClientShellOverlay::WorktreeCreate(create)) = self.overlay.as_mut() {
+ create.creating = false;
+ create.error = Some(error.message);
+ }
+ true
+ }
+ (PendingEndpointKind::WorktreeOpen, Err(error)) => {
+ if let Some(ClientShellOverlay::WorktreeOpen(open)) = self.overlay.as_mut() {
+ open.opening = false;
+ open.error = Some(error.message);
+ }
+ true
+ }
+ (PendingEndpointKind::WorktreeRemove { forced: false }, Err(error))
+ if error.code.as_deref() == Some("dirty_worktree_requires_force") =>
+ {
+ if let Some(ClientShellOverlay::WorktreeRemove(remove)) = self.overlay.as_mut() {
+ remove.removing = false;
+ remove.force_confirmation = true;
+ remove.error = None;
+ }
+ true
+ }
+ (PendingEndpointKind::WorktreeRemove { .. }, Err(error)) => {
+ if let Some(ClientShellOverlay::WorktreeRemove(remove)) = self.overlay.as_mut() {
+ remove.removing = false;
+ remove.error = Some(error.message);
+ }
+ true
+ }
+ (
+ PendingEndpointKind::PrepareWorktreeCreate { .. }
+ | PendingEndpointKind::PrepareWorktreeOpen { .. }
+ | PendingEndpointKind::PrepareWorktreeRemove { .. },
+ Err(error),
+ ) => {
+ self.endpoint_error = Some(error.message);
+ true
+ }
+ (_, Ok(_)) => {
+ self.endpoint_error =
+ Some("endpoint returned an unexpected worktree result".to_owned());
+ true
+ }
+ (
+ PendingEndpointKind::Generic
+ | PendingEndpointKind::ProductAnnouncementDismiss { .. }
+ | PendingEndpointKind::ReleaseNotesDismiss
+ | PendingEndpointKind::PopupCommand
+ | PendingEndpointKind::ReloadConfig
+ | PendingEndpointKind::IntegrationList
+ | PendingEndpointKind::IntegrationInstall
+ | PendingEndpointKind::SelectionCopy { .. }
+ | PendingEndpointKind::PaneScroll { .. }
+ | PendingEndpointKind::WordSelection { .. }
+ | PendingEndpointKind::PaneLinkActivate { .. }
+ | PendingEndpointKind::CopyMotion { .. }
+ | PendingEndpointKind::CopySearch { .. },
+ Err(error),
+ ) => {
+ self.endpoint_error = Some(error.message);
+ true
+ }
+ }
+ }
+}