返回 CodeWhale
worktree_manager.rs
根目录 / crates / tui / src / tui / worktree_manager.rs
1 //! Native worktree manager UI (list / create / switch / compare).
2 //!
3 //! Data lives in [`super::git_status`]; this module is pure presentation +
4 //! key handling. Never blocks the render path on git subprocesses — refresh
5 //! is scheduled via `git_status::refresh_if_stale` / `force_refresh` from
6 //! background-friendly call sites.
7
8 use std::path::{Path, PathBuf};
9
10 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
11 use ratatui::{
12 buffer::Buffer,
13 layout::Rect,
14 style::{Modifier, Style},
15 text::{Line, Span},
16 widgets::{Block, Borders, Clear, Paragraph, Widget},
17 };
18 use unicode_width::UnicodeWidthStr;
19
20 use crate::palette;
21 use crate::tui::git_status::{self, GitStatusSnapshot, WorktreeEntry};
22 use crate::tui::menu_style;
23 use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
24
25 /// Modes inside the worktree manager.
26 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
27 enum Mode {
28 List,
29 Create,
30 Compare,
31 }
32
33 /// Native worktree manager modal.
34 pub struct WorktreeManagerView {
35 workspace: PathBuf,
36 selected: usize,
37 mode: Mode,
38 create_buffer: String,
39 create_as_new_branch: bool,
40 compare_against: Option<PathBuf>,
41 status: Option<String>,
42 last_snapshot: GitStatusSnapshot,
43 }
44
45 impl WorktreeManagerView {
46 #[must_use]
47 pub fn new(workspace: impl Into<PathBuf>) -> Self {
48 let workspace = workspace.into();
49 git_status::refresh_if_stale(&workspace);
50 let last_snapshot = git_status::cached_status();
51 Self {
52 workspace,
53 selected: 0,
54 mode: Mode::List,
55 create_buffer: String::new(),
56 create_as_new_branch: true,
57 compare_against: None,
58 status: None,
59 last_snapshot,
60 }
61 }
62
63 fn refresh(&mut self) {
64 git_status::force_refresh(&self.workspace);
65 self.last_snapshot = git_status::cached_status();
66 let n = self.last_snapshot.worktrees.len().max(1);
67 if self.selected >= n {
68 self.selected = n.saturating_sub(1);
69 }
70 }
71
72 fn entries(&self) -> &[WorktreeEntry] {
73 &self.last_snapshot.worktrees
74 }
75
76 fn selected_entry(&self) -> Option<&WorktreeEntry> {
77 self.entries().get(self.selected)
78 }
79
80 fn is_current(workspace: &Path, entry: &WorktreeEntry) -> bool {
81 same_path(workspace, &entry.path)
82 }
83 }
84
85 fn same_path(a: &Path, b: &Path) -> bool {
86 let a = a.canonicalize().unwrap_or_else(|_| a.to_path_buf());
87 let b = b.canonicalize().unwrap_or_else(|_| b.to_path_buf());
88 a == b
89 }
90
91 impl ModalView for WorktreeManagerView {
92 fn kind(&self) -> ModalKind {
93 ModalKind::WorktreeManager
94 }
95
96 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
97 self
98 }
99
100 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
101 match self.mode {
102 Mode::List => self.handle_list_key(key),
103 Mode::Create => self.handle_create_key(key),
104 Mode::Compare => self.handle_compare_key(key),
105 }
106 }
107
108 fn render(&self, area: Rect, buf: &mut Buffer) {
109 let popup = centered(area, 72, 18.min(area.height.saturating_sub(2)));
110 Clear.render(popup, buf);
111 Block::default()
112 .borders(Borders::ALL)
113 .title(" Worktrees ")
114 .border_style(Style::default().fg(palette::BORDER_COLOR))
115 .style(Style::default().bg(palette::SURFACE_ELEVATED))
116 .render(popup, buf);
117
118 let inner = Rect {
119 x: popup.x.saturating_add(1),
120 y: popup.y.saturating_add(1),
121 width: popup.width.saturating_sub(2),
122 height: popup.height.saturating_sub(2),
123 };
124 if inner.width == 0 || inner.height == 0 {
125 return;
126 }
127
128 match self.mode {
129 Mode::List => self.render_list(inner, buf),
130 Mode::Create => self.render_create(inner, buf),
131 Mode::Compare => self.render_compare(inner, buf),
132 }
133 }
134 }
135
136 impl WorktreeManagerView {
137 fn handle_list_key(&mut self, key: KeyEvent) -> ViewAction {
138 match key.code {
139 KeyCode::Esc => ViewAction::Close,
140 KeyCode::Up | KeyCode::Char('k') => {
141 self.selected = self.selected.saturating_sub(1);
142 ViewAction::None
143 }
144 KeyCode::Down | KeyCode::Char('j') => {
145 let max = self.entries().len().saturating_sub(1);
146 self.selected = (self.selected + 1).min(max);
147 ViewAction::None
148 }
149 KeyCode::Char('r')
150 if key.modifiers.contains(KeyModifiers::CONTROL)
151 || key.modifiers == KeyModifiers::NONE =>
152 {
153 self.refresh();
154 self.status = Some("Refreshed worktree list".into());
155 ViewAction::None
156 }
157 KeyCode::Char('n') => {
158 self.mode = Mode::Create;
159 self.create_buffer.clear();
160 ViewAction::None
161 }
162 KeyCode::Char('d') => {
163 if let Some(path) = self.selected_entry().map(|e| e.path.clone()) {
164 let name = path
165 .file_name()
166 .and_then(|s| s.to_str())
167 .unwrap_or("worktree")
168 .to_string();
169 self.compare_against = Some(path);
170 self.mode = Mode::Compare;
171 self.status = Some(format!("Diff against {name}"));
172 }
173 ViewAction::None
174 }
175 KeyCode::Enter => {
176 if let Some(entry) = self.selected_entry() {
177 let path = entry.path.display().to_string();
178 if Self::is_current(&self.workspace, entry) {
179 self.status = Some("Already in this worktree".into());
180 ViewAction::None
181 } else {
182 ViewAction::Emit(ViewEvent::StatusMessage {
183 message: format!(
184 "Switch: open a new session in {path} (cwd switch is session-scoped)"
185 ),
186 })
187 }
188 } else {
189 ViewAction::None
190 }
191 }
192 _ => ViewAction::None,
193 }
194 }
195
196 fn handle_create_key(&mut self, key: KeyEvent) -> ViewAction {
197 match key.code {
198 KeyCode::Esc => {
199 self.mode = Mode::List;
200 ViewAction::None
201 }
202 KeyCode::Backspace => {
203 self.create_buffer.pop();
204 ViewAction::None
205 }
206 KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
207 self.create_as_new_branch = !self.create_as_new_branch;
208 ViewAction::None
209 }
210 KeyCode::Enter => {
211 let name = self.create_buffer.trim();
212 if name.is_empty() {
213 self.status = Some("Enter a branch / path name".into());
214 return ViewAction::None;
215 }
216 let path = self
217 .workspace
218 .join(".cw-worktrees")
219 .join(name.replace('/', "-"));
220 let result = git_status::create_worktree(
221 self.last_snapshot
222 .root
223 .as_deref()
224 .unwrap_or(&self.workspace),
225 &path,
226 name,
227 self.create_as_new_branch,
228 );
229 match result {
230 Ok(()) => {
231 self.refresh();
232 self.mode = Mode::List;
233 self.status = Some(format!("Created worktree at {}", path.display()));
234 }
235 Err(err) => {
236 self.status = Some(format!("Create failed: {err}"));
237 }
238 }
239 ViewAction::None
240 }
241 KeyCode::Char(c)
242 if !key.modifiers.contains(KeyModifiers::CONTROL)
243 && !key.modifiers.contains(KeyModifiers::ALT) =>
244 {
245 self.create_buffer.push(c);
246 ViewAction::None
247 }
248 _ => ViewAction::None,
249 }
250 }
251
252 fn handle_compare_key(&mut self, key: KeyEvent) -> ViewAction {
253 match key.code {
254 KeyCode::Esc => {
255 self.mode = Mode::List;
256 self.compare_against = None;
257 ViewAction::None
258 }
259 KeyCode::Enter => {
260 let Some(path) = self.compare_against.clone() else {
261 return ViewAction::None;
262 };
263 // Emit a status that dogfood can follow; full diff_render
264 // integration uses existing /diff tooling against the path.
265 ViewAction::Emit(ViewEvent::StatusMessage {
266 message: format!(
267 "Diff against {}: use /diff or context-menu Diff on a file",
268 path.display()
269 ),
270 })
271 }
272 _ => ViewAction::None,
273 }
274 }
275
276 fn render_list(&self, area: Rect, buf: &mut Buffer) {
277 let mut lines: Vec<Line<'static>> = Vec::new();
278 let branch = self.last_snapshot.branch.as_deref().unwrap_or("detached");
279 lines.push(Line::from(vec![
280 Span::styled("repo ", Style::default().fg(palette::TEXT_MUTED)),
281 Span::styled(
282 branch.to_string(),
283 Style::default()
284 .fg(palette::WHALE_ACTION)
285 .add_modifier(Modifier::BOLD),
286 ),
287 if self.last_snapshot.dirty {
288 Span::styled(" *", Style::default().fg(palette::STATUS_WARNING))
289 } else {
290 Span::raw("")
291 },
292 ]));
293 lines.push(Line::from(Span::styled(
294 "n new · d diff against · Enter switch · r refresh · Esc close",
295 Style::default().fg(palette::TEXT_HINT),
296 )));
297 lines.push(Line::from(""));
298
299 if self.entries().is_empty() {
300 lines.push(Line::from(Span::styled(
301 "No worktrees listed (not a git repo?)",
302 Style::default().fg(palette::TEXT_MUTED),
303 )));
304 } else {
305 for (i, entry) in self.entries().iter().enumerate() {
306 let selected = i == self.selected;
307 let current = Self::is_current(&self.workspace, entry);
308 let marker = if selected { "▸ " } else { " " };
309 let cur = if current { " · current" } else { "" };
310 let locked = if entry.locked { " 🔒" } else { "" };
311 let branch = entry.branch.as_deref().unwrap_or("detached");
312 let path = entry
313 .path
314 .file_name()
315 .and_then(|s| s.to_str())
316 .unwrap_or_else(|| entry.path.to_str().unwrap_or("?"));
317 let text = format!("{marker}{path} · {branch}{cur}{locked}");
318 let style = if selected {
319 menu_style::selected_row_style_with_fg(palette::WHALE_ACTION)
320 } else if current {
321 Style::default().fg(palette::WHALE_LIVE)
322 } else {
323 Style::default().fg(palette::TEXT_PRIMARY)
324 };
325 // One-cell accent rail on selected row.
326 let rail = if selected { "▌" } else { " " };
327 lines.push(Line::from(vec![
328 Span::styled(rail, Style::default().fg(palette::WHALE_ACTION)),
329 Span::styled(
330 truncate(&text, usize::from(area.width.saturating_sub(2))),
331 style,
332 ),
333 ]));
334 }
335 }
336
337 if let Some(status) = &self.status {
338 lines.push(Line::from(""));
339 lines.push(Line::from(Span::styled(
340 status.clone(),
341 Style::default().fg(palette::TEXT_MUTED),
342 )));
343 }
344
345 Paragraph::new(lines).render(area, buf);
346 }
347
348 fn render_create(&self, area: Rect, buf: &mut Buffer) {
349 let kind = if self.create_as_new_branch {
350 "new branch"
351 } else {
352 "existing branch"
353 };
354 let lines = vec![
355 Line::from(Span::styled(
356 "Create worktree",
357 Style::default()
358 .fg(palette::TEXT_PRIMARY)
359 .add_modifier(Modifier::BOLD),
360 )),
361 Line::from(Span::styled(
362 format!("Ctrl+B toggle · currently: {kind}"),
363 Style::default().fg(palette::TEXT_HINT),
364 )),
365 Line::from(""),
366 Line::from(vec![
367 Span::styled("name › ", Style::default().fg(palette::TEXT_MUTED)),
368 Span::styled(
369 self.create_buffer.clone(),
370 Style::default().fg(palette::WHALE_ACTION),
371 ),
372 Span::styled("█", Style::default().fg(palette::TEXT_HINT)),
373 ]),
374 Line::from(""),
375 Line::from(Span::styled(
376 "Enter create · Esc back",
377 Style::default().fg(palette::TEXT_HINT),
378 )),
379 if let Some(status) = &self.status {
380 Line::from(Span::styled(
381 status.clone(),
382 Style::default().fg(palette::STATUS_WARNING),
383 ))
384 } else {
385 Line::from("")
386 },
387 ];
388 Paragraph::new(lines).render(area, buf);
389 }
390
391 fn render_compare(&self, area: Rect, buf: &mut Buffer) {
392 let path = self
393 .compare_against
394 .as_ref()
395 .map(|p| p.display().to_string())
396 .unwrap_or_else(|| "(none)".into());
397 let lines = vec![
398 Line::from(Span::styled(
399 "Diff against worktree",
400 Style::default()
401 .fg(palette::TEXT_PRIMARY)
402 .add_modifier(Modifier::BOLD),
403 )),
404 Line::from(""),
405 Line::from(Span::styled(
406 truncate(&path, usize::from(area.width)),
407 Style::default().fg(palette::WHALE_LIVE),
408 )),
409 Line::from(""),
410 Line::from(Span::styled(
411 "Enter: show diff workflow · Esc back",
412 Style::default().fg(palette::TEXT_HINT),
413 )),
414 Line::from(Span::styled(
415 "Uses existing diff_render surfaces — never blocks UI on git.",
416 Style::default().fg(palette::TEXT_MUTED),
417 )),
418 ];
419 Paragraph::new(lines).render(area, buf);
420 }
421 }
422
423 fn centered(area: Rect, width: u16, height: u16) -> Rect {
424 let width = width.min(area.width);
425 let height = height.min(area.height);
426 Rect {
427 x: area.x.saturating_add(area.width.saturating_sub(width) / 2),
428 y: area
429 .y
430 .saturating_add(area.height.saturating_sub(height) / 2),
431 width,
432 height,
433 }
434 }
435
436 fn truncate(text: &str, max: usize) -> String {
437 if UnicodeWidthStr::width(text) <= max {
438 return text.to_string();
439 }
440 if max <= 1 {
441 return "…".to_string();
442 }
443 let mut out = String::new();
444 let mut w = 0usize;
445 let limit = max.saturating_sub(1);
446 for ch in text.chars() {
447 let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
448 if w + cw > limit {
449 break;
450 }
451 out.push(ch);
452 w += cw;
453 }
454 out.push('…');
455 out
456 }
457
458 /// Context-menu labels for git actions on a path.
459 #[must_use]
460 pub fn context_menu_git_actions(path: &str, branch: Option<&str>) -> Vec<(String, String)> {
461 let mut actions = vec![
462 ("Open path".into(), format!("open:{path}")),
463 ("Diff file".into(), format!("diff:{path}")),
464 ];
465 if let Some(branch) = branch {
466 actions.push(("Branch here".into(), format!("branch:{branch}")));
467 }
468 actions.push(("Worktrees…".into(), "worktrees".into()));
469 actions
470 }
471
472 #[cfg(test)]
473 mod tests {
474 use super::*;
475
476 #[test]
477 fn context_menu_includes_worktrees() {
478 let actions = context_menu_git_actions("src/main.rs", Some("main"));
479 assert!(actions.iter().any(|(_, id)| id == "worktrees"));
480 assert!(actions.iter().any(|(label, _)| label.contains("Diff")));
481 }
482
483 #[test]
484 fn manager_constructs_from_workspace() {
485 let view = WorktreeManagerView::new(std::env::temp_dir());
486 assert_eq!(view.mode, Mode::List);
487 }
488 }
489
489 lines RUST