1use crate::config::effective::EffectiveConfiguration;
8use crate::config::{loader, GitVersionConfiguration};
9use crate::exec;
10use crate::git::{CommitInfo, GitRepo};
11use crate::output::{generator, VersionVariables};
12use crate::remote::{self, DynamicRepoOptions};
13use crate::version::calculation;
14use anyhow::Result;
15use crossterm::{
16 event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
17 execute,
18 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
19};
20use ratatui::{
21 prelude::*,
22 widgets::{Block, Borders, Cell, Clear, List, ListItem, Paragraph, Row, Table, Tabs},
23};
24use rust_i18n::t;
25use std::io;
26use std::path::PathBuf;
27use std::time::Duration;
28
29const TAB_KEYS: [&str; 5] = [
31 "tui.tab.variables",
32 "tui.tab.config",
33 "tui.tab.commits",
34 "tui.tab.branches",
35 "tui.tab.actions",
36];
37
38#[derive(Clone, Copy, PartialEq)]
40enum InputAction {
41 CreateTag,
42 CreateBranch,
43 SetNextVersion,
44 DynamicClone,
45 EditExecHook,
46 EditConfig,
47}
48
49impl InputAction {
50 fn prompt(&self) -> &'static str {
51 match self {
52 InputAction::CreateTag => "tui.prompt.create_tag",
53 InputAction::CreateBranch => "tui.prompt.create_branch",
54 InputAction::SetNextVersion => "tui.prompt.set_next_version",
55 InputAction::DynamicClone => "tui.prompt.dynamic_clone",
56 InputAction::EditExecHook => "tui.prompt.edit_exec_hook",
57 InputAction::EditConfig => "tui.prompt.edit_config",
58 }
59 }
60}
61
62struct App {
63 repo: GitRepo,
64 config: GitVersionConfiguration,
65 work_dir: PathBuf,
66 base_branch: String,
68 branch_override: Option<String>,
70 next_version_override: Option<String>,
71
72 vars: VersionVariables,
73 json: String,
74 commits: Vec<CommitInfo>,
75 branches: Vec<String>,
76
77 tab: usize,
78 selected: usize,
79 scroll: u16,
80 search: String,
81 searching: bool,
82 input: Option<InputAction>,
83 input_buf: String,
84 status: String,
85 actions: Vec<&'static str>,
86 pending_run_hooks: bool,
88 edit_config_key: Option<String>,
90 tui_overrides: std::collections::BTreeMap<String, String>,
92}
93
94const EDITABLE_CONFIG: [(&str, &str); 13] = [
97 ("increment", "tui.hint.increment"),
98 ("mode", "tui.hint.mode"),
99 ("label", "tui.hint.prerelease_label"),
100 ("tag-prefix", "tui.hint.tag_prefix"),
101 ("next-version", "tui.hint.version_example"),
102 ("semantic-version-format", "tui.hint.semver_format"),
103 ("tag-pre-release-weight", "tui.hint.integer"),
104 ("update-build-number", "tui.hint.bool"),
105 ("commit-date-format", "tui.hint.date_example"),
106 ("major-version-bump-message", "tui.hint.regex"),
107 ("minor-version-bump-message", "tui.hint.regex"),
108 ("patch-version-bump-message", "tui.hint.regex"),
109 ("no-bump-message", "tui.hint.regex"),
110];
111
112fn yaml_scalar(v: &str) -> serde_yaml::Value {
114 if let Ok(b) = v.parse::<bool>() {
115 return serde_yaml::Value::Bool(b);
116 }
117 if let Ok(i) = v.parse::<i64>() {
118 return serde_yaml::Value::Number(i.into());
119 }
120 serde_yaml::Value::String(v.to_string())
121}
122
123fn global_value(config: &GitVersionConfiguration, key: &str) -> String {
125 match key {
126 "increment" => config
127 .increment
128 .map(|v| format!("{v:?}"))
129 .unwrap_or_default(),
130 "mode" => config.mode.map(|v| format!("{v:?}")).unwrap_or_default(),
131 "label" => config.label.clone().unwrap_or_default(),
132 "tag-prefix" => config.tag_prefix.clone().unwrap_or_default(),
133 "next-version" => config.next_version.clone().unwrap_or_default(),
134 "semantic-version-format" => config
135 .semantic_version_format
136 .map(|v| format!("{v:?}"))
137 .unwrap_or_default(),
138 "tag-pre-release-weight" => config
139 .tag_pre_release_weight
140 .map(|v| v.to_string())
141 .unwrap_or_default(),
142 "update-build-number" => config
143 .update_build_number
144 .map(|v| v.to_string())
145 .unwrap_or_default(),
146 "commit-date-format" => config.commit_date_format.clone().unwrap_or_default(),
147 "major-version-bump-message" => config
148 .major_version_bump_message
149 .clone()
150 .unwrap_or_default(),
151 "minor-version-bump-message" => config
152 .minor_version_bump_message
153 .clone()
154 .unwrap_or_default(),
155 "patch-version-bump-message" => config
156 .patch_version_bump_message
157 .clone()
158 .unwrap_or_default(),
159 "no-bump-message" => config.no_bump_message.clone().unwrap_or_default(),
160 _ => String::new(),
161 }
162}
163
164pub fn run(repo: GitRepo, config: GitVersionConfiguration, work_dir: PathBuf) -> Result<()> {
166 let base_branch = repo.current_branch_name().unwrap_or_default();
167 let mut app = App {
168 repo,
169 config,
170 work_dir,
171 base_branch,
172 branch_override: None,
173 next_version_override: None,
174 vars: VersionVariables::default(),
175 json: String::new(),
176 commits: Vec::new(),
177 branches: Vec::new(),
178 tab: 0,
179 selected: 0,
180 scroll: 0,
181 search: String::new(),
182 searching: false,
183 input: None,
184 input_buf: String::new(),
185 status: t!("tui.status.ready").to_string(),
186 actions: vec![
188 "tui.action.create_tag",
189 "tui.action.create_branch",
190 "tui.action.set_next_version",
191 "tui.action.edit_exec_hook",
192 "tui.action.run_exec_hook",
193 "tui.action.save_config",
194 "tui.action.clear_cache",
195 "tui.action.dynamic_clone",
196 "tui.action.recompute",
197 "tui.action.reset_base",
198 ],
199 pending_run_hooks: false,
200 edit_config_key: None,
201 tui_overrides: std::collections::BTreeMap::new(),
202 };
203 app.recompute();
204 app.reload_lists();
205
206 enable_raw_mode()?;
207 let mut stdout = io::stdout();
208 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
209 let backend = CrosstermBackend::new(stdout);
210 let mut terminal = Terminal::new(backend)?;
211
212 let panic_msg: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
215 let captured = panic_msg.clone();
216 let original_hook = std::panic::take_hook();
217 std::panic::set_hook(Box::new(move |info| {
218 let msg = info
219 .payload()
220 .downcast_ref::<&str>()
221 .map(|s| s.to_string())
222 .or_else(|| info.payload().downcast_ref::<String>().cloned())
223 .unwrap_or_else(|| t!("tui.panic.unknown").to_string());
224 let loc = info
225 .location()
226 .map(|l| format!(" ({}:{})", l.file(), l.line()))
227 .unwrap_or_default();
228 *captured.lock().unwrap() = Some(format!("{msg}{loc}"));
229 }));
230
231 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
232 event_loop(&mut terminal, &mut app)
233 }));
234
235 let _ = disable_raw_mode();
237 let _ = execute!(
238 terminal.backend_mut(),
239 LeaveAlternateScreen,
240 DisableMouseCapture
241 );
242 let _ = terminal.show_cursor();
243 std::panic::set_hook(original_hook);
244
245 match result {
246 Ok(r) => r,
247 Err(_) => {
248 let msg = panic_msg
249 .lock()
250 .unwrap()
251 .clone()
252 .unwrap_or_else(|| t!("tui.panic.internal").to_string());
253 log::error!("{}", t!("tui.panic.defended", msg = msg));
255 Err(anyhow::anyhow!("{}", t!("tui.panic.exit", msg = msg)))
256 }
257 }
258}
259
260impl App {
261 fn recompute(&mut self) {
263 let mut cfg = self.config.clone();
264 if let Some(nv) = &self.next_version_override {
265 cfg.next_version = Some(nv.clone());
266 }
267 match calculation::calculate(&self.repo, &cfg, self.branch_override.clone()) {
268 Ok(mut v) => {
269 let mut hook_applied = false;
270 if let Some(cmd) = cfg.exec.get("version").cloned() {
272 if let Ok(Some(nv)) = exec::run_version_hook(&cmd, &v, &self.work_dir, false) {
273 cfg.next_version = Some(nv.clone());
274 if let Ok(v2) =
275 calculation::calculate(&self.repo, &cfg, self.branch_override.clone())
276 {
277 v = v2;
278 self.status =
279 t!("tui.status.version_hook_applied", nv = nv).to_string();
280 hook_applied = true;
281 }
282 }
283 }
284 self.json = generator::to_json(&v).unwrap_or_default();
285 self.vars = v;
286 if !hook_applied {
287 self.status = t!(
288 "tui.status.recompute_done",
289 branch = self.branch_override.as_deref().unwrap_or(&self.base_branch)
290 )
291 .to_string();
292 }
293 }
294 Err(e) => self.status = t!("tui.status.calc_error", error = format!("{e}")).to_string(),
295 }
296 let target = self
298 .branch_override
299 .clone()
300 .unwrap_or_else(|| self.base_branch.clone());
301 self.commits = self
302 .repo
303 .first_parent_between(None, &target)
304 .unwrap_or_default();
305 self.commits.truncate(200);
306 }
307
308 fn reload_lists(&mut self) {
309 self.branches = self.repo.local_branch_names().unwrap_or_default();
310 }
311
312 fn filtered_vars(&self) -> Vec<(String, String)> {
314 let q = self.search.to_lowercase();
315 self.vars
316 .to_map()
317 .into_iter()
318 .filter(|(k, v)| {
319 q.is_empty() || k.to_lowercase().contains(&q) || v.to_lowercase().contains(&q)
320 })
321 .collect()
322 }
323
324 fn copy(&mut self, text: &str) {
325 match arboard::Clipboard::new().and_then(|mut c| c.set_text(text.to_string())) {
326 Ok(_) => self.status = t!("tui.status.copied", text = truncate(text, 40)).to_string(),
327 Err(e) => {
328 self.status = t!("tui.status.clipboard_failed", error = format!("{e}")).to_string()
329 }
330 }
331 }
332
333 fn confirm_input(&mut self) {
334 let action = self.input.take();
335 let buf = std::mem::take(&mut self.input_buf);
336 let buf = buf.trim().to_string();
337 if buf.is_empty() {
338 self.status = t!("tui.status.input_cancelled").to_string();
339 return;
340 }
341 match action {
342 Some(InputAction::CreateTag) => match self.repo.create_tag(&buf, None) {
343 Ok(_) => {
344 self.status = t!("tui.status.tag_created", name = buf).to_string();
345 self.recompute();
346 self.reload_lists();
347 }
348 Err(e) => {
349 self.status = t!("git.tag_create_failed", name = format!("{e}")).to_string()
350 }
351 },
352 Some(InputAction::CreateBranch) => match self.repo.create_branch(&buf, None) {
353 Ok(_) => {
354 self.status = t!("tui.status.branch_created", name = buf).to_string();
355 self.reload_lists();
356 }
357 Err(e) => {
358 self.status = t!("git.branch_create_failed", name = format!("{e}")).to_string()
359 }
360 },
361 Some(InputAction::SetNextVersion) => {
362 self.next_version_override = Some(buf.clone());
363 self.status = t!("tui.status.next_version_set", version = buf).to_string();
364 self.recompute();
365 }
366 Some(InputAction::DynamicClone) => self.do_dynamic_clone(&buf),
367 Some(InputAction::EditExecHook) => {
368 let Some((name, cmd)) = buf.split_once('=') else {
369 self.status = t!("tui.status.format_name_cmd").to_string();
370 return;
371 };
372 let (name, cmd) = (name.trim().to_string(), cmd.trim().to_string());
373 const VALID: [&str; 6] =
374 ["verify", "prepare", "publish", "success", "fail", "version"];
375 if !VALID.contains(&name.as_str()) {
376 self.status = t!("tui.status.hook_unknown_name", name = name).to_string();
377 return;
378 }
379 if cmd.is_empty() {
380 self.config.exec.remove(&name);
381 self.status = t!("tui.status.hook_removed", name = name).to_string();
382 } else {
383 self.config.exec.insert(name.clone(), cmd);
384 self.status = t!("tui.status.hook_set", name = name).to_string();
385 }
386 self.recompute();
388 self.save_config();
389 }
390 Some(InputAction::EditConfig) => {
391 if let Some(key) = self.edit_config_key.take() {
392 self.apply_global_edit(&key, &buf);
393 self.status =
394 t!("tui.status.config_saved_key", key = key, value = buf).to_string();
395 }
396 }
397 None => {}
398 }
399 }
400
401 fn do_dynamic_clone(&mut self, spec: &str) {
402 let mut parts = spec.split_whitespace();
403 let (url, branch) = (parts.next(), parts.next());
404 let Some(url) = url else {
405 self.status = t!("tui.status.url_required").to_string();
406 return;
407 };
408 let opts = DynamicRepoOptions {
409 url: url.to_string(),
410 branch: branch
411 .map(|s| s.to_string())
412 .or_else(|| Some("main".into())),
413 username: None,
414 password: None,
415 commit: None,
416 location: None,
417 };
418 self.status = t!("tui.status.cloning").to_string();
419 match remote::prepare(&opts) {
420 Ok(dest) => match GitRepo::discover(&dest) {
421 Ok(repo) => {
422 let root = repo
423 .workdir()
424 .map(|p| p.to_path_buf())
425 .unwrap_or_else(|| dest.clone());
426 self.config = loader::load(None, &root, Some(&root))
427 .unwrap_or_else(|_| self.config.clone());
428 self.repo = repo;
429 self.work_dir = root;
430 self.base_branch = self.repo.current_branch_name().unwrap_or_default();
431 self.branch_override = None;
432 self.next_version_override = None;
433 self.recompute();
434 self.reload_lists();
435 self.status = t!("tui.status.clone_done", url = url).to_string();
436 }
437 Err(e) => {
438 self.status =
439 t!("tui.status.clone_open_failed", error = format!("{e}")).to_string()
440 }
441 },
442 Err(e) => {
443 self.status = t!("tui.status.clone_failed", error = format!("{e}")).to_string()
444 }
445 }
446 }
447
448 fn run_action(&mut self, idx: usize) {
449 match idx {
450 0 => self.start_input(InputAction::CreateTag),
451 1 => self.start_input(InputAction::CreateBranch),
452 2 => self.start_input(InputAction::SetNextVersion),
453 3 => self.start_input(InputAction::EditExecHook),
454 4 => self.pending_run_hooks = true, 5 => self.save_config(),
456 6 => match self.repo.clear_cache() {
457 Ok(n) => self.status = t!("tui.status.cache_cleared", count = n).to_string(),
458 Err(e) => {
459 self.status =
460 t!("tui.status.cache_clear_failed", error = format!("{e}")).to_string()
461 }
462 },
463 7 => self.start_input(InputAction::DynamicClone),
464 8 => self.recompute(),
465 9 => {
466 self.branch_override = None;
467 self.next_version_override = None;
468 self.recompute();
469 self.status =
470 t!("tui.status.reset_base", branch = self.base_branch.clone()).to_string();
471 }
472 _ => {}
473 }
474 }
475
476 fn save_config(&mut self) {
478 let path = self.work_dir.join("GitVersion.yml");
479 let mut doc: serde_yaml::Mapping = std::fs::read_to_string(&path)
480 .ok()
481 .and_then(|s| serde_yaml::from_str(&s).ok())
482 .unwrap_or_default();
483
484 for (k, v) in &self.tui_overrides {
486 doc.insert(serde_yaml::Value::String(k.clone()), yaml_scalar(v));
487 }
488 if self.config.exec.is_empty() {
490 doc.remove(serde_yaml::Value::String("exec".into()));
491 } else {
492 let mut exec_map = serde_yaml::Mapping::new();
493 for (k, v) in &self.config.exec {
494 exec_map.insert(
495 serde_yaml::Value::String(k.clone()),
496 serde_yaml::Value::String(v.clone()),
497 );
498 }
499 doc.insert(
500 serde_yaml::Value::String("exec".into()),
501 serde_yaml::Value::Mapping(exec_map),
502 );
503 }
504
505 match serde_yaml::to_string(&doc)
506 .map_err(anyhow::Error::from)
507 .and_then(|y| std::fs::write(&path, y).map_err(anyhow::Error::from))
508 {
509 Ok(_) => self.status = t!("tui.status.config_saved", path = path.display()).to_string(),
510 Err(e) => {
511 self.status =
512 t!("tui.status.config_save_failed", error = format!("{e}")).to_string()
513 }
514 }
515 }
516
517 fn apply_global_edit(&mut self, key: &str, value: &str) {
519 crate::cli::apply_overrides(&mut self.config, &[format!("{key}={value}")]);
520 self.tui_overrides
521 .insert(key.to_string(), value.to_string());
522 self.recompute();
523 self.save_config();
524 }
525
526 fn start_input(&mut self, action: InputAction) {
527 self.input = Some(action);
528 self.input_buf.clear();
529 }
530}
531
532fn truncate(s: &str, n: usize) -> String {
533 if s.chars().count() <= n {
534 s.to_string()
535 } else {
536 format!("{}…", s.chars().take(n).collect::<String>())
537 }
538}
539
540fn event_loop<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()>
541where
542 B::Error: std::error::Error + Send + Sync + 'static,
543{
544 loop {
545 terminal.draw(|f| ui(f, app))?;
546 if !event::poll(Duration::from_millis(200))? {
547 continue;
548 }
549 let Event::Key(key) = event::read()? else {
550 continue;
551 };
552 if key.kind != KeyEventKind::Press {
553 continue;
554 }
555
556 if app.input.is_some() {
558 match key.code {
559 KeyCode::Esc => {
560 app.input = None;
561 app.input_buf.clear();
562 }
563 KeyCode::Enter => app.confirm_input(),
564 KeyCode::Backspace => {
565 app.input_buf.pop();
566 }
567 KeyCode::Char(c) => app.input_buf.push(c),
568 _ => {}
569 }
570 continue;
571 }
572
573 if app.searching {
575 match key.code {
576 KeyCode::Esc => {
577 app.searching = false;
578 app.search.clear();
579 }
580 KeyCode::Enter => app.searching = false,
581 KeyCode::Backspace => {
582 app.search.pop();
583 }
584 KeyCode::Char(c) => app.search.push(c),
585 _ => {}
586 }
587 continue;
588 }
589
590 match key.code {
591 KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
592 KeyCode::Tab | KeyCode::Right => {
593 app.tab = (app.tab + 1) % TAB_KEYS.len();
594 app.selected = 0;
595 }
596 KeyCode::Left => {
597 app.tab = (app.tab + TAB_KEYS.len() - 1) % TAB_KEYS.len();
598 app.selected = 0;
599 }
600 KeyCode::Char(c @ '1'..='5') => {
601 app.tab = c as usize - '1' as usize;
602 app.selected = 0;
603 }
604 KeyCode::Down | KeyCode::Char('j') => app.move_down(),
605 KeyCode::Up | KeyCode::Char('k') => app.move_up(),
606 KeyCode::Char('/') if app.tab == 0 => {
607 app.searching = true;
608 app.search.clear();
609 }
610 KeyCode::Char('c') if app.tab == 0 => {
611 let items = app.filtered_vars();
612 if let Some((_, v)) = items.get(app.selected) {
613 let v = v.clone();
614 app.copy(&v);
615 }
616 }
617 KeyCode::Char('C') => {
618 let json = app.json.clone();
619 app.copy(&json);
620 }
621 KeyCode::Enter => app.on_enter(),
622 _ => {}
623 }
624
625 if app.pending_run_hooks {
627 app.pending_run_hooks = false;
628 run_hooks_suspended(terminal, app)?;
629 }
630 }
631}
632
633fn run_hooks_suspended<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()>
635where
636 B::Error: std::error::Error + Send + Sync + 'static,
637{
638 if app.config.exec.is_empty() {
639 app.status = t!("tui.status.no_exec_hooks").to_string();
640 return Ok(());
641 }
642 let _ = disable_raw_mode();
644 let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
645 println!("\n=== {} ===", t!("tui.exec_run_header"));
646 let result = exec::run_hooks(&app.config.exec, None, &app.vars, &app.work_dir, false);
647 app.status = match &result {
648 Ok(_) => t!("tui.status.exec_done").to_string(),
649 Err(e) => t!("tui.status.exec_failed", error = format!("{e}")).to_string(),
650 };
651 if let Err(e) = &result {
652 println!("{}", t!("error.generic", error = format!("{e}")));
653 }
654 println!("\n{}", t!("tui.press_enter_return"));
655 let mut line = String::new();
656 let _ = io::stdin().read_line(&mut line);
657 enable_raw_mode()?;
659 execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
660 terminal.clear()?;
661 Ok(())
662}
663
664impl App {
665 fn list_len(&self) -> usize {
666 match self.tab {
667 0 => self.filtered_vars().len(),
668 1 => EDITABLE_CONFIG.len(),
669 2 => self.commits.len(),
670 3 => self.branches.len(),
671 4 => self.actions.len(),
672 _ => 0,
673 }
674 }
675 fn move_down(&mut self) {
676 let len = self.list_len();
677 if len > 0 {
678 self.selected = (self.selected + 1).min(len - 1);
679 }
680 }
681 fn move_up(&mut self) {
682 self.selected = self.selected.saturating_sub(1);
683 }
684 fn on_enter(&mut self) {
685 match self.tab {
686 1 => {
687 if let Some((key, _)) = EDITABLE_CONFIG.get(self.selected) {
689 self.edit_config_key = Some((*key).to_string());
690 self.input_buf = global_value(&self.config, key);
691 self.input = Some(InputAction::EditConfig);
692 }
693 }
694 3 => {
695 if let Some(b) = self.branches.get(self.selected).cloned() {
696 self.branch_override = if b == self.base_branch {
697 None
698 } else {
699 Some(b.clone())
700 };
701 self.recompute();
702 }
703 }
704 4 => self.run_action(self.selected),
705 _ => {}
706 }
707 }
708
709 fn input_prompt(&self) -> String {
711 match (&self.input, &self.edit_config_key) {
712 (Some(InputAction::EditConfig), Some(key)) => {
713 let hint_key = EDITABLE_CONFIG
714 .iter()
715 .find(|(k, _)| k == key)
716 .map(|(_, h)| *h)
717 .unwrap_or("");
718 t!("tui.config_edit_prompt", key = key, hint = t!(hint_key)).to_string()
719 }
720 (Some(a), _) => t!(a.prompt()).to_string(),
721 _ => String::new(),
722 }
723 }
724}
725
726fn ui(f: &mut Frame, app: &App) {
727 let chunks = Layout::default()
728 .direction(Direction::Vertical)
729 .constraints([
730 Constraint::Length(3),
731 Constraint::Length(3),
732 Constraint::Min(1),
733 Constraint::Length(1),
734 ])
735 .split(f.area());
736
737 let target = app.branch_override.as_deref().unwrap_or(&app.base_branch);
739 let mut header_spans = vec![
740 Span::styled(
741 " GitVersion ",
742 Style::default()
743 .fg(Color::Black)
744 .bg(Color::Cyan)
745 .add_modifier(Modifier::BOLD),
746 ),
747 Span::raw(" "),
748 Span::styled(
749 &app.vars.full_sem_ver,
750 Style::default()
751 .fg(Color::Green)
752 .add_modifier(Modifier::BOLD),
753 ),
754 Span::raw(format!(" {}: ", t!("tui.header.branch"))),
755 Span::styled(target, Style::default().fg(Color::Yellow)),
756 ];
757 if app.next_version_override.is_some() {
758 header_spans.push(Span::styled(
759 " [next-version override]",
760 Style::default().fg(Color::Magenta),
761 ));
762 }
763 f.render_widget(
764 Paragraph::new(Line::from(header_spans)).block(Block::default().borders(Borders::ALL)),
765 chunks[0],
766 );
767
768 let tabs = Tabs::new(
770 TAB_KEYS
771 .iter()
772 .enumerate()
773 .map(|(i, k)| format!("{}:{}", i + 1, t!(*k)))
774 .collect::<Vec<_>>(),
775 )
776 .select(app.tab)
777 .block(Block::default().borders(Borders::ALL))
778 .highlight_style(
779 Style::default()
780 .fg(Color::Cyan)
781 .add_modifier(Modifier::BOLD),
782 );
783 f.render_widget(tabs, chunks[1]);
784
785 match app.tab {
786 0 => render_variables(f, app, chunks[2]),
787 1 => render_config(f, app, chunks[2]),
788 2 => render_commits(f, app, chunks[2]),
789 3 => render_branches(f, app, chunks[2]),
790 _ => render_actions(f, app, chunks[2]),
791 }
792
793 let help = match app.tab {
795 0 => t!("tui.help.variables"),
796 1 => t!("tui.help.config"),
797 3 => t!("tui.help.branches"),
798 4 => t!("tui.help.actions"),
799 _ => t!("tui.help.default"),
800 };
801 let footer = Line::from(vec![
802 Span::styled(
803 format!(" {} ", app.status),
804 Style::default().fg(Color::Black).bg(Color::Gray),
805 ),
806 Span::raw(" "),
807 Span::styled(help.to_string(), Style::default().fg(Color::DarkGray)),
808 ]);
809 f.render_widget(Paragraph::new(footer), chunks[3]);
810
811 if let Some(action) = &app.input {
813 let _ = action;
814 render_input_modal(f, &app.input_prompt(), &app.input_buf);
815 }
816}
817
818fn render_variables(f: &mut Frame, app: &App, area: Rect) {
819 let items = app.filtered_vars();
820 let rows: Vec<Row> = items
821 .iter()
822 .enumerate()
823 .map(|(i, (k, v))| {
824 let style = if i == app.selected {
825 Style::default().fg(Color::Black).bg(Color::Cyan)
826 } else {
827 Style::default().fg(Color::White)
828 };
829 Row::new(vec![
830 Cell::from(k.clone()).style(Style::default().fg(Color::Cyan)),
831 Cell::from(v.clone()),
832 ])
833 .style(style)
834 })
835 .collect();
836 let title = if app.searching || !app.search.is_empty() {
837 t!("tui.title.variables_search", query = app.search).to_string()
838 } else {
839 t!("tui.title.variables_count", count = items.len()).to_string()
840 };
841 let table = Table::new(
842 rows,
843 [Constraint::Percentage(38), Constraint::Percentage(62)],
844 )
845 .header(
846 Row::new(vec![
847 t!("tui.col.variable").to_string(),
848 t!("tui.col.value").to_string(),
849 ])
850 .style(
851 Style::default()
852 .add_modifier(Modifier::BOLD)
853 .fg(Color::Yellow),
854 ),
855 )
856 .block(Block::default().borders(Borders::ALL).title(title));
857 f.render_widget(table, area);
858}
859
860fn render_config(f: &mut Frame, app: &App, area: Rect) {
861 let halves = Layout::default()
863 .direction(Direction::Vertical)
864 .constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
865 .split(area);
866
867 let edit_items: Vec<ListItem> = EDITABLE_CONFIG
868 .iter()
869 .enumerate()
870 .map(|(i, (key, _))| {
871 let val = global_value(&app.config, key);
872 let shown = if val.is_empty() {
873 t!("tui.unset").to_string()
874 } else {
875 val
876 };
877 let style = if i == app.selected {
878 Style::default().fg(Color::Black).bg(Color::Cyan)
879 } else {
880 Style::default().fg(Color::White)
881 };
882 ListItem::new(format!("{key:<28}{shown}")).style(style)
883 })
884 .collect();
885 f.render_widget(
886 List::new(edit_items).block(
887 Block::default()
888 .borders(Borders::ALL)
889 .title(format!(" {} ", t!("tui.title.global_config"))),
890 ),
891 halves[0],
892 );
893
894 let eff = EffectiveConfiguration::resolve(
895 &app.config,
896 app.branch_override.as_deref().unwrap_or(&app.base_branch),
897 );
898 let strategies: Vec<String> = if app.config.strategies.is_empty() {
899 vec![t!("tui.default_paren").to_string()]
900 } else {
901 app.config
902 .strategies
903 .iter()
904 .map(|s| format!("{s:?}"))
905 .collect()
906 };
907 let none_paren = t!("tui.none_paren").to_string();
908 let exec_hooks: String = if app.config.exec.is_empty() {
909 none_paren.clone()
910 } else {
911 app.config
912 .exec
913 .keys()
914 .cloned()
915 .collect::<Vec<_>>()
916 .join(", ")
917 };
918 let lines = vec![
919 kv(&t!("tui.kv.matched_branch_key"), &eff.branch_key),
920 kv("increment", &format!("{:?}", eff.increment)),
921 kv("mode(deployment)", &format!("{:?}", eff.deployment_mode)),
922 kv("label", &eff.label),
923 kv("regex", eff.regex.as_deref().unwrap_or("")),
924 kv("is-release-branch", &eff.is_release_branch.to_string()),
925 kv("is-main-branch", &eff.is_main_branch.to_string()),
926 kv(
927 "tracks-release-branches",
928 &eff.tracks_release_branches.to_string(),
929 ),
930 kv("track-merge-message", &eff.track_merge_message.to_string()),
931 kv(
932 "commit-message-incrementing",
933 &format!("{:?}", eff.commit_message_incrementing),
934 ),
935 kv(
936 "prevent-increment.of-merged",
937 &eff.prevent_increment_of_merged_branch.to_string(),
938 ),
939 kv(
940 "prevent-increment.when-tagged",
941 &eff.prevent_increment_when_current_commit_tagged.to_string(),
942 ),
943 kv("pre-release-weight", &eff.pre_release_weight.to_string()),
944 kv(
945 "tag-pre-release-weight",
946 &eff.tag_pre_release_weight.to_string(),
947 ),
948 kv("tag-prefix", &eff.tag_prefix),
949 kv(
950 "semantic-version-format",
951 &format!("{:?}", eff.semantic_version_format),
952 ),
953 kv("source-branches", &eff.source_branches.join(", ")),
954 kv("strategies", &strategies.join(", ")),
955 kv(&t!("tui.kv.exec_hooks"), &exec_hooks),
956 kv(
957 "next-version",
958 app.next_version_override
959 .as_deref()
960 .or(app.config.next_version.as_deref())
961 .unwrap_or(&none_paren),
962 ),
963 ];
964 let para = Paragraph::new(lines).block(
965 Block::default()
966 .borders(Borders::ALL)
967 .title(format!(" {} ", t!("tui.title.effective"))),
968 );
969 f.render_widget(para, halves[1]);
970}
971
972fn kv(k: &str, v: &str) -> Line<'static> {
973 Line::from(vec![
974 Span::styled(format!("{k:<30}"), Style::default().fg(Color::Cyan)),
975 Span::styled(v.to_string(), Style::default().fg(Color::White)),
976 ])
977}
978
979fn render_commits(f: &mut Frame, app: &App, area: Rect) {
980 let src = app.vars.version_source_sha.clone();
981 let items: Vec<ListItem> = app
982 .commits
983 .iter()
984 .enumerate()
985 .map(|(i, c)| {
986 let is_src = !src.is_empty() && c.sha.starts_with(&src[..src.len().min(c.sha.len())])
987 || c.sha == src;
988 let marker = if is_src { "◆ " } else { " " };
989 let date = c.when.format("%Y-%m-%d").to_string();
990 let msg = c.message.lines().next().unwrap_or("");
991 let line = format!("{marker}{} {date} {}", &c.short_sha, truncate(msg, 60));
992 let mut style = Style::default().fg(if is_src { Color::Green } else { Color::White });
993 if i == app.selected {
994 style = style.bg(Color::DarkGray);
995 }
996 ListItem::new(line).style(style)
997 })
998 .collect();
999 let title = t!("tui.title.commits", count = app.commits.len()).to_string();
1000 f.render_widget(
1001 List::new(items).block(Block::default().borders(Borders::ALL).title(title)),
1002 area,
1003 );
1004}
1005
1006fn render_branches(f: &mut Frame, app: &App, area: Rect) {
1007 let current = app.branch_override.as_deref().unwrap_or(&app.base_branch);
1008 let items: Vec<ListItem> = app
1009 .branches
1010 .iter()
1011 .enumerate()
1012 .map(|(i, b)| {
1013 let mark = if b == current {
1014 "● "
1015 } else if b == &app.base_branch {
1016 "○ "
1017 } else {
1018 " "
1019 };
1020 let mut style = Style::default().fg(Color::White);
1021 if i == app.selected {
1022 style = Style::default().fg(Color::Black).bg(Color::Cyan);
1023 } else if b == current {
1024 style = Style::default().fg(Color::Green);
1025 }
1026 ListItem::new(format!("{mark}{b}")).style(style)
1027 })
1028 .collect();
1029 f.render_widget(
1030 List::new(items).block(
1031 Block::default()
1032 .borders(Borders::ALL)
1033 .title(format!(" {} ", t!("tui.title.branches"))),
1034 ),
1035 area,
1036 );
1037}
1038
1039fn render_actions(f: &mut Frame, app: &App, area: Rect) {
1040 let items: Vec<ListItem> = app
1041 .actions
1042 .iter()
1043 .enumerate()
1044 .map(|(i, a)| {
1045 let style = if i == app.selected {
1046 Style::default().fg(Color::Black).bg(Color::Cyan)
1047 } else {
1048 Style::default().fg(Color::White)
1049 };
1050 ListItem::new(format!(" {}", t!(*a))).style(style)
1051 })
1052 .collect();
1053 f.render_widget(
1054 List::new(items).block(
1055 Block::default()
1056 .borders(Borders::ALL)
1057 .title(format!(" {} ", t!("tui.title.actions"))),
1058 ),
1059 area,
1060 );
1061}
1062
1063fn render_input_modal(f: &mut Frame, prompt: &str, buf: &str) {
1064 let area = centered_rect(70, 20, f.area());
1065 f.render_widget(Clear, area);
1066 let block = Block::default()
1067 .borders(Borders::ALL)
1068 .title(format!(" {} ", t!("tui.title.input_modal")))
1069 .border_style(Style::default().fg(Color::Magenta));
1070 let text = vec![
1071 Line::from(Span::styled(prompt, Style::default().fg(Color::Yellow))),
1072 Line::from(""),
1073 Line::from(Span::styled(
1074 format!("> {buf}_"),
1075 Style::default().fg(Color::White),
1076 )),
1077 ];
1078 f.render_widget(Paragraph::new(text).block(block), area);
1079}
1080
1081fn centered_rect(px: u16, py: u16, r: Rect) -> Rect {
1082 let v = Layout::default()
1083 .direction(Direction::Vertical)
1084 .constraints([
1085 Constraint::Percentage((100 - py) / 2),
1086 Constraint::Percentage(py),
1087 Constraint::Percentage((100 - py) / 2),
1088 ])
1089 .split(r);
1090 Layout::default()
1091 .direction(Direction::Horizontal)
1092 .constraints([
1093 Constraint::Percentage((100 - px) / 2),
1094 Constraint::Percentage(px),
1095 Constraint::Percentage((100 - px) / 2),
1096 ])
1097 .split(v[1])[1]
1098}