From 207be3c771d281baae6e5fa0fb74be9a056e97a2 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Tue, 1 Sep 2026 15:48:22 +0300 Subject: 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 --- src/client/shell/worktree_overlays.rs | 410 ++++++++++++++++++++++++++++++++++ 1 file changed, 410 insertions(+) create mode 100644 src/client/shell/worktree_overlays.rs (limited to 'src/client/shell/worktree_overlays.rs') diff --git a/src/client/shell/worktree_overlays.rs b/src/client/shell/worktree_overlays.rs new file mode 100644 index 00000000..a2b5c65f --- /dev/null +++ b/src/client/shell/worktree_overlays.rs @@ -0,0 +1,410 @@ +use super::*; + +pub(super) fn render_worktree_create_overlay( + b: &mut Buffer, + create: &ClientWorktreeCreateOverlay, + p: &Palette, +) -> Option { + let popup = popup(b.area, 68, 12)?; + let inner = panel(b, popup, p.accent, p.panel_bg)?; + put_text( + b, + inner.x, + inner.y, + inner.width, + "new worktree", + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + b, + inner.x, + inner.y + 2, + inner.width, + " branch", + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + let input = Rect::new(inner.x, inner.y + 3, inner.width, 1); + b.set_style(input, Style::default().fg(p.text).bg(p.surface0)); + put_text( + b, + input.x, + input.y, + input.width, + &format!(" {}", create.branch), + Style::default().fg(p.text).bg(p.surface0), + ); + put_text( + b, + inner.x, + inner.y + 5, + inner.width, + " checkout", + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + put_text( + b, + inner.x, + inner.y + 6, + inner.width, + &format!(" {}", create.checkout_path), + Style::default().fg(p.subtext0).bg(p.panel_bg), + ); + if create.creating { + put_text( + b, + inner.x, + inner.y + 8, + inner.width, + " creating…", + Style::default().fg(p.accent).bg(p.panel_bg), + ); + } else if let Some(error) = create.error.as_deref() { + put_text( + b, + inner.x, + inner.y + 8, + inner.width, + &format!(" {error}"), + Style::default().fg(p.red).bg(p.panel_bg), + ); + } + let buttons = row(inner, &[20, 12], 2, 9); + let [primary, cancel] = buttons.as_slice() else { + return None; + }; + button( + b, + *primary, + " ↵ create and open ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ); + button( + b, + *cancel, + " esc cancel ", + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ); + Some(OverlayRender { + primary: *primary, + clear: Rect::default(), + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: (!create.creating).then(|| crate::protocol::CursorState { + x: (input.x + 1 + display_width(&create.branch)).min(input.right() - 1), + y: input.y, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} + +pub(super) fn render_worktree_open_overlay( + b: &mut Buffer, + open: &ClientWorktreeOpenOverlay, + p: &Palette, +) -> Option { + let popup_height = (open.entries.len().saturating_mul(2) + 7).clamp(12, 26) as u16; + let popup = popup(b.area, 96, popup_height)?; + let inner = panel(b, popup, p.accent, p.panel_bg)?; + put_text( + b, + inner.x, + inner.y, + inner.width, + "open worktree", + Style::default() + .fg(p.text) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + let search = Rect::new(inner.x, inner.y + 1, inner.width, 1); + let filtered = open.filtered_indices(); + put_text( + b, + search.x, + search.y, + search.width, + &if open.search_focused || !open.query.is_empty() { + format!(" / {}", open.query) + } else { + " / filter worktrees".to_owned() + }, + Style::default() + .fg(if open.search_focused { + p.text + } else { + p.overlay0 + }) + .bg(p.panel_bg), + ); + let count = if filtered.len() == open.entries.len() { + format!("{} checkouts", open.entries.len()) + } else { + format!("{}/{} checkouts", filtered.len(), open.entries.len()) + }; + put_right_text( + b, + search, + search.y, + &count, + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + put_text( + b, + inner.x, + inner.y + 2, + inner.width, + &"─".repeat(inner.width as usize), + Style::default().fg(p.surface1).bg(p.panel_bg), + ); + let body = Rect::new( + inner.x, + inner.y + 3, + inner.width, + inner.height.saturating_sub(6), + ); + let visible_count = (body.height / 2).max(1) as usize; + let selected_position = filtered + .iter() + .position(|index| *index == open.selected) + .unwrap_or(0); + let start = selected_position + .saturating_sub(visible_count.saturating_sub(1)) + .min(filtered.len().saturating_sub(visible_count)); + let mut row_hits = Vec::new(); + for (visible, entry_index) in filtered + .iter() + .copied() + .skip(start) + .take(visible_count) + .enumerate() + { + let entry = &open.entries[entry_index]; + let rect = Rect::new(body.x, body.y + visible as u16 * 2, body.width, 2); + row_hits.push((rect, entry_index)); + let selected = entry_index == open.selected; + let style = if selected { + Style::default().fg(contrast(p)).bg(p.accent) + } else { + Style::default().fg(p.text).bg(p.panel_bg) + }; + b.set_style(rect, style); + put_text( + b, + rect.x, + rect.y, + rect.width, + &format!(" {}", entry.label), + style.add_modifier(Modifier::BOLD), + ); + let status = entry.status_label(); + if !status.is_empty() { + put_right_text(b, rect, rect.y, status, style); + } + put_text( + b, + rect.x, + rect.y + 1, + rect.width, + &format!(" {}", entry.path), + if selected { + style + } else { + Style::default().fg(p.overlay0).bg(p.panel_bg) + }, + ); + } + if filtered.is_empty() { + put_text( + b, + body.x, + body.y, + body.width, + " no matching worktrees", + Style::default().fg(p.overlay0).bg(p.panel_bg), + ); + } + if open.opening { + put_text( + b, + inner.x, + inner.bottom() - 3, + inner.width, + " opening…", + Style::default().fg(p.accent).bg(p.panel_bg), + ); + } else if let Some(error) = open.error.as_deref() { + put_text( + b, + inner.x, + inner.bottom() - 3, + inner.width, + &format!(" {error}"), + Style::default().fg(p.red).bg(p.panel_bg), + ); + } + let buttons = row(inner, &[10, 12], 2, inner.height.saturating_sub(1)); + let [primary, cancel] = buttons.as_slice() else { + return None; + }; + button( + b, + *primary, + " ↵ open ", + Style::default() + .fg(contrast(p)) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ); + button( + b, + *cancel, + " esc cancel ", + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ); + Some(OverlayRender { + primary: *primary, + clear: Rect::default(), + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: search, + worktree_rows: row_hits, + cursor: (open.search_focused && !open.opening).then(|| crate::protocol::CursorState { + x: (search.x + 3 + display_width(&open.query)).min(search.right() - 1), + y: search.y, + visible: true, + shape: 0, + }), + ..OverlayRender::default() + }) +} + +pub(super) fn render_worktree_remove_overlay( + b: &mut Buffer, + remove: &ClientWorktreeRemoveOverlay, + p: &Palette, +) -> Option { + let popup = popup(b.area, 72, 10)?; + let inner = panel(b, popup, p.red, p.panel_bg)?; + put_text( + b, + inner.x, + inner.y, + inner.width, + " delete worktree checkout?", + Style::default() + .fg(p.red) + .bg(p.panel_bg) + .add_modifier(Modifier::BOLD), + ); + put_text( + b, + inner.x, + inner.y + 1, + inner.width, + " This removes the checkout folder:", + Style::default().fg(p.text).bg(p.panel_bg), + ); + put_text( + b, + inner.x, + inner.y + 2, + inner.width, + &format!(" {}", remove.path), + Style::default().fg(p.subtext0).bg(p.panel_bg), + ); + put_text( + b, + inner.x, + inner.y + 3, + inner.width, + " The branch is not deleted. The Herdr workspace will close.", + Style::default().fg(p.text).bg(p.panel_bg), + ); + if remove.force_confirmation { + put_text( + b, + inner.x, + inner.y + 4, + inner.width, + " Dirty or untracked files will be permanently deleted.", + Style::default().fg(p.red).bg(p.panel_bg), + ); + } + if remove.removing { + put_text( + b, + inner.x, + inner.y + 5, + inner.width, + " removing…", + Style::default().fg(p.accent).bg(p.panel_bg), + ); + } else if let Some(error) = remove.error.as_deref() { + put_text( + b, + inner.x, + inner.y + 5, + inner.width, + &format!(" {error}"), + Style::default().fg(p.red).bg(p.panel_bg), + ); + } + let buttons = row(inner, &[18, 12], 2, 7); + let [primary, cancel] = buttons.as_slice() else { + return None; + }; + button( + b, + *primary, + if remove.force_confirmation { + " ↵ delete anyway " + } else { + " ↵ remove " + }, + Style::default() + .fg(contrast(p)) + .bg(p.red) + .add_modifier(Modifier::BOLD), + ); + button( + b, + *cancel, + " esc cancel ", + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ); + Some(OverlayRender { + primary: *primary, + clear: Rect::default(), + cancel: *cancel, + navigator_popup: Rect::default(), + navigator_search: Rect::default(), + navigator_rows: Vec::new(), + worktree_search: Rect::default(), + worktree_rows: Vec::new(), + cursor: None, + ..OverlayRender::default() + }) +} -- cgit v1.2.3