Skip to main content

gitversion_rs/tui/
mod.rs

1//! Ratatui-based interactive TUI.
2//!
3//! Goes beyond a simple viewer to offer exploration of computed results and operations
4//! that affect the actual version (tag/branch creation, next-version override, cache
5//! clearing, dynamic clone, per-branch recomputation).
6
7use 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
29/// Translation keys for tab titles (resolved via `t!` at render time).
30const 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/// Actions that require text input.
39#[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    /// Currently checked-out branch (baseline).
67    base_branch: String,
68    /// Branch to recompute for (selected in the Branches tab). None means the baseline branch.
69    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    /// Signal to the event loop to leave the terminal temporarily and run side-effect hooks.
87    pending_run_hooks: bool,
88    /// Global config key currently being edited (for EditConfig input).
89    edit_config_key: Option<String>,
90    /// Global config changes made via the TUI (key=value). Written as a minimal diff when saving.
91    tui_overrides: std::collections::BTreeMap<String, String>,
92}
93
94/// Global config keys editable in the Config tab (same meaning as overrideconfig).
95/// Tuple of (config key, hint translation key); hints are resolved via `t!` at render time.
96const 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
112/// Convert a string to a YAML scalar (bool / int / string).
113fn 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
123/// Current value of a global config key as a string.
124fn 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
164/// Launch the TUI. Accepts a repository and configuration and runs interactively.
165pub 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        // Action label translation keys (resolved via `t!` at render time). Order matches run_action indices.
187        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    // Replace the panic hook temporarily so that panics do not corrupt the alternate screen;
213    // capture the message, then catch_unwind to shut down gracefully.
214    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    // Always restore the terminal regardless of what happened.
236    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            // Convert the panic to a normal error instead of a crash (terminal already restored).
254            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    /// Recompute with the configuration reflecting the current overrides.
262    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                // When a version exec hook is present, use its output to override the version and recompute (mirrors CLI).
271                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        // Refresh the commit list for the target branch.
297        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    /// Currently displayed variables with the search filter applied.
313    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                // A version-hook change affects the version output → recompute then persist.
387                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, // Event loop exits the terminal to run.
455            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    /// Save changed global config keys to GitVersion.yml as a minimal diff (existing content preserved).
477    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        // Write the changed global keys as scalars.
485        for (k, v) in &self.tui_overrides {
486            doc.insert(serde_yaml::Value::String(k.clone()), yaml_scalar(v));
487        }
488        // Write the exec hook map.
489        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    /// Edit a global config key (same logic as overrideconfig), record it, recompute, and save.
518    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        // Input modal takes priority.
557        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        // Search input mode (Variables tab).
574        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        // Side-effect hook run requested: leave the terminal temporarily to show command output directly.
626        if app.pending_run_hooks {
627            app.pending_run_hooks = false;
628            run_hooks_suspended(terminal, app)?;
629        }
630    }
631}
632
633/// Temporarily restore the terminal, run exec side-effect hooks, then re-enter TUI mode.
634fn 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    // Return to the normal screen.
643    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    // Re-enter TUI mode.
658    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                // Edit the selected global config key, pre-filling the current value.
688                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    /// Input modal prompt text (EditConfig shows the key name).
710    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    // Header.
738    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    // Tabs.
769    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    // Footer (status + help).
794    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    // Input modal.
812    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    // Top: editable global config (selection list). Bottom: effective config result.
862    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}