Skip to main content

git_worktree_manager/tui/
config_editor.rs

1//! `gw config edit` — interactive TUI editor.
2//!
3//! Lets the user browse every known [`ConfigKey`] alongside the values stored
4//! in both scopes (global and repo) and edit either one in place. Saving
5//! writes the modified entries back to disk in pretty JSON.
6//!
7//! Why a TUI and not a "$EDITOR-opens-the-json-file" flow:
8//!
9//! - We want users to see _both_ scopes at once so they can spot overrides.
10//!   A raw-JSON editor only shows one file.
11//! - We control the value parser per key, so booleans / numbers / arg arrays
12//!   get validated on save instead of producing a JSON file the runtime
13//!   silently rejects on next load.
14//!
15//! Layout (alternate screen, ratatui Crossterm backend):
16//!
17//!   ┌─ gw config edit ─────────────────────────────────────────┐
18//!   │ Scope: ●global  ○repo (.cwconfig.json)         [Tab] swap│
19//!   ├──────────────────────────────────────────────────────────┤
20//!   │   ai-tool.command       claude                           │
21//!   │ ▶ launch.method         tmux                             │
22//!   │   …                                                      │
23//!   ├──────────────────────────────────────────────────────────┤
24//!   │ ↑↓ navigate  Enter edit  Tab swap scope  s save  q quit  │
25//!   └──────────────────────────────────────────────────────────┘
26
27use std::io::{stdout, IsTerminal};
28use std::path::{Path, PathBuf};
29
30use ratatui::backend::CrosstermBackend;
31use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
32use ratatui::crossterm::execute;
33use ratatui::crossterm::terminal::{
34    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
35};
36use ratatui::layout::{Constraint, Direction, Layout, Rect};
37use ratatui::style::{Modifier, Style};
38use ratatui::text::{Line, Span};
39use ratatui::widgets::{Block, Borders, Clear, Paragraph};
40use ratatui::Terminal;
41use serde_json::Value;
42
43use crate::error::{CwError, Result};
44use crate::git;
45use crate::operations::config_ops::{
46    global_path, lookup_value, parse_value_for, read_json_or_empty, set_value, unset_value,
47    write_json, ConfigKey, Scope,
48};
49
50/// Entry point — call from the CLI dispatcher.
51pub fn run() -> Result<()> {
52    if !stdout().is_terminal() {
53        return Err(CwError::Config(
54            "gw config edit needs a TTY (run it in an interactive terminal)".to_string(),
55        ));
56    }
57    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
58    let global = global_path();
59    let repo = git::get_repo_root(Some(&cwd))
60        .ok()
61        .map(|r| r.join(".cwconfig.json"));
62
63    let app_state = AppState::load(&global, repo.as_deref())?;
64    let outcome = run_loop(app_state)?;
65    if let Some(saved) = outcome {
66        for (label, count) in saved.summary() {
67            if count > 0 {
68                println!(" {}: {} key(s) updated", label, count);
69            }
70        }
71    }
72    Ok(())
73}
74
75// ---------------------------------------------------------------------------
76// Application state
77// ---------------------------------------------------------------------------
78
79#[derive(Debug)]
80struct AppState {
81    /// Working copy of the global file (edits accumulate here until save).
82    global_value: Value,
83    /// Working copy of the repo file (None when not in a repo).
84    repo_value: Option<Value>,
85
86    global_path: PathBuf,
87    repo_path: Option<PathBuf>,
88
89    cursor: usize,
90    active: Scope,
91    dirty_global: bool,
92    dirty_repo: bool,
93    status: String,
94    /// Modal input prompt when editing a value. `None` when navigating.
95    editing: Option<EditState>,
96    quit: bool,
97}
98
99#[derive(Debug)]
100struct EditState {
101    key: ConfigKey,
102    scope: Scope,
103    buffer: String,
104    error: Option<String>,
105}
106
107impl AppState {
108    fn load(global: &Path, repo: Option<&Path>) -> Result<Self> {
109        let global_value = read_json_or_empty(global)?;
110        let repo_value = match repo {
111            Some(p) => Some(read_json_or_empty(p)?),
112            None => None,
113        };
114        // Always start in global scope; users hit Tab to switch when a
115        // repo is loaded (and we no-op the toggle when it isn't).
116        Ok(Self {
117            global_value,
118            repo_value,
119            global_path: global.to_path_buf(),
120            repo_path: repo.map(Path::to_path_buf),
121            cursor: 0,
122            active: Scope::Global,
123            dirty_global: false,
124            dirty_repo: false,
125            status: String::from("Tab: swap scope · Enter: edit · s: save · q: quit"),
126            editing: None,
127            quit: false,
128        })
129    }
130
131    fn keys() -> &'static [ConfigKey] {
132        ConfigKey::ALL
133    }
134
135    fn active_value(&self) -> Option<&Value> {
136        match self.active {
137            Scope::Global => Some(&self.global_value),
138            Scope::Repo => self.repo_value.as_ref(),
139        }
140    }
141
142    fn current_key(&self) -> ConfigKey {
143        Self::keys()[self.cursor]
144    }
145
146    fn move_cursor(&mut self, delta: i32) {
147        let len = Self::keys().len() as i32;
148        if len == 0 {
149            return;
150        }
151        let next = (self.cursor as i32 + delta).rem_euclid(len);
152        self.cursor = next as usize;
153    }
154
155    fn toggle_scope(&mut self) {
156        if self.repo_value.is_none() {
157            self.status = "Not inside a git repo — repo scope unavailable".to_string();
158            return;
159        }
160        self.active = self.active.other();
161    }
162
163    /// Start editing the value under cursor in the active scope.
164    fn begin_edit(&mut self) {
165        if matches!(self.active, Scope::Repo) && self.repo_value.is_none() {
166            self.status = "Not inside a git repo — cannot edit repo scope".to_string();
167            return;
168        }
169        let key = self.current_key();
170        let buffer = match self.active {
171            Scope::Global => lookup_value(&self.global_value, key)
172                .map(value_to_input_string)
173                .unwrap_or_default(),
174            Scope::Repo => self
175                .repo_value
176                .as_ref()
177                .and_then(|v| lookup_value(v, key))
178                .map(value_to_input_string)
179                .unwrap_or_default(),
180        };
181        self.editing = Some(EditState {
182            key,
183            scope: self.active,
184            buffer,
185            error: None,
186        });
187    }
188
189    /// Apply the value currently in the edit buffer to the working tree.
190    fn commit_edit(&mut self) {
191        // Take editing out so we can borrow self mutably for the apply step
192        // without holding a borrow on `editing` at the same time.
193        let edit = match self.editing.take() {
194            Some(e) => e,
195            None => return,
196        };
197        let trimmed = edit.buffer.trim();
198        let result = if trimmed.is_empty() {
199            // Empty input = unset this key in the active scope.
200            self.apply_unset(edit.key, edit.scope);
201            Ok(())
202        } else {
203            parse_value_for(edit.key, &edit.buffer)
204                .and_then(|v| self.apply_set(edit.key, edit.scope, v))
205        };
206        match result {
207            Ok(()) => {
208                self.status = format!(
209                    "Updated {} in [{}] (unsaved)",
210                    edit.key.name(),
211                    edit.scope.label()
212                );
213            }
214            Err(e) => {
215                // Put the edit back so the user can fix it.
216                self.editing = Some(EditState {
217                    error: Some(format!("{e}")),
218                    ..edit
219                });
220            }
221        }
222    }
223
224    fn cancel_edit(&mut self) {
225        self.editing = None;
226    }
227
228    fn apply_set(&mut self, key: ConfigKey, scope: Scope, value: Value) -> Result<()> {
229        match scope {
230            Scope::Global => {
231                set_value(&mut self.global_value, key, value)?;
232                self.dirty_global = true;
233            }
234            Scope::Repo => {
235                let v = self.repo_value.as_mut().ok_or_else(|| {
236                    CwError::Config("repo scope unavailable (not in a git repo)".to_string())
237                })?;
238                set_value(v, key, value)?;
239                self.dirty_repo = true;
240            }
241        }
242        Ok(())
243    }
244
245    /// Remove `key` from the given scope. Only flips the dirty flag when the
246    /// key was actually present — avoids no-op writes when the user clears an
247    /// already-unset field.
248    fn apply_unset(&mut self, key: ConfigKey, scope: Scope) {
249        match scope {
250            Scope::Global => {
251                if lookup_value(&self.global_value, key).is_some() {
252                    unset_value(&mut self.global_value, key);
253                    self.dirty_global = true;
254                }
255            }
256            Scope::Repo => {
257                if let Some(v) = self.repo_value.as_mut() {
258                    if lookup_value(v, key).is_some() {
259                        unset_value(v, key);
260                        self.dirty_repo = true;
261                    }
262                }
263            }
264        }
265    }
266
267    fn save(&mut self) -> Result<SaveSummary> {
268        let mut summary = SaveSummary::default();
269        if self.dirty_global {
270            write_json(&self.global_path, &self.global_value)?;
271            summary.global = count_set_keys(&self.global_value);
272            self.dirty_global = false;
273        }
274        if self.dirty_repo {
275            if let (Some(p), Some(v)) = (self.repo_path.as_ref(), self.repo_value.as_ref()) {
276                write_json(p, v)?;
277                summary.repo = count_set_keys(v);
278                self.dirty_repo = false;
279            }
280        }
281        self.status = "Saved.".to_string();
282        Ok(summary)
283    }
284}
285
286/// Count keys that are currently stored in `root` (across all known
287/// [`ConfigKey`]s). Used by `save`'s summary line.
288fn count_set_keys(root: &Value) -> usize {
289    ConfigKey::ALL
290        .iter()
291        .filter(|k| lookup_value(root, **k).is_some())
292        .count()
293}
294
295/// Format a JSON value back into something the user can re-edit. Strings come
296/// out unquoted; arrays render as space-joined tokens when every element is a
297/// plain string (the common case for `ai-tool.args`).
298fn value_to_input_string(v: &Value) -> String {
299    match v {
300        Value::Null => String::new(),
301        Value::String(s) => s.clone(),
302        Value::Bool(b) => b.to_string(),
303        Value::Number(n) => n.to_string(),
304        Value::Array(a) => {
305            if a.iter().all(|e| e.is_string()) {
306                a.iter()
307                    .map(|e| e.as_str().unwrap_or("").to_string())
308                    .collect::<Vec<_>>()
309                    .join(" ")
310            } else {
311                v.to_string()
312            }
313        }
314        Value::Object(_) => v.to_string(),
315    }
316}
317
318// ---------------------------------------------------------------------------
319// Save summary returned to the dispatcher
320// ---------------------------------------------------------------------------
321
322#[derive(Debug, Default)]
323pub struct SaveSummary {
324    pub global: usize,
325    pub repo: usize,
326}
327
328impl SaveSummary {
329    fn summary(&self) -> [(&'static str, usize); 2] {
330        [("global", self.global), ("repo", self.repo)]
331    }
332}
333
334// ---------------------------------------------------------------------------
335// Event loop
336// ---------------------------------------------------------------------------
337
338fn run_loop(mut state: AppState) -> Result<Option<SaveSummary>> {
339    enable_raw_mode().map_err(io_err)?;
340    let mut out = stdout();
341    if let Err(e) = execute!(out, EnterAlternateScreen) {
342        // Construction failed before we marked active — make sure raw mode
343        // is released so the user's shell isn't left wedged.
344        let _ = disable_raw_mode();
345        return Err(io_err(e));
346    }
347    crate::tui::mark_ratatui_active();
348
349    let backend = CrosstermBackend::new(out);
350    let mut terminal = match Terminal::new(backend) {
351        Ok(t) => t,
352        Err(e) => {
353            // Active flag is set but no terminal exists — undo both screen
354            // changes by hand. Matches the `display.rs` error arm.
355            let _ = disable_raw_mode();
356            let _ = execute!(stdout(), LeaveAlternateScreen);
357            crate::tui::mark_ratatui_inactive();
358            return Err(io_err(e));
359        }
360    };
361
362    let result = (|| -> Result<Option<SaveSummary>> {
363        let mut last_save: Option<SaveSummary> = None;
364        loop {
365            terminal.draw(|f| render(f, &state)).map_err(io_err)?;
366            if state.quit {
367                break;
368            }
369            let evt = event::read().map_err(io_err)?;
370            if let Event::Key(k) = evt {
371                if let Some(s) = handle_key(&mut state, k)? {
372                    last_save = Some(s);
373                }
374            }
375        }
376        Ok(last_save)
377    })();
378
379    // Always restore terminal state, even on error.
380    let _ = disable_raw_mode();
381    let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
382    drop(terminal);
383    crate::tui::mark_ratatui_inactive();
384
385    result
386}
387
388fn io_err(e: std::io::Error) -> CwError {
389    CwError::Other(format!("terminal io: {}", e))
390}
391
392fn handle_key(state: &mut AppState, key: KeyEvent) -> Result<Option<SaveSummary>> {
393    if state.editing.is_some() {
394        handle_edit_key(state, key);
395        return Ok(None);
396    }
397    // Ctrl-C always quits without saving, matching every other gw TUI.
398    if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
399        state.quit = true;
400        return Ok(None);
401    }
402    match key.code {
403        KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
404        KeyCode::Up | KeyCode::Char('k') => state.move_cursor(-1),
405        KeyCode::Down | KeyCode::Char('j') => state.move_cursor(1),
406        KeyCode::Tab => state.toggle_scope(),
407        KeyCode::Enter => state.begin_edit(),
408        KeyCode::Char('s') => {
409            let s = state.save()?;
410            return Ok(Some(s));
411        }
412        _ => {}
413    }
414    Ok(None)
415}
416
417fn handle_edit_key(state: &mut AppState, key: KeyEvent) {
418    match key.code {
419        KeyCode::Esc => state.cancel_edit(),
420        KeyCode::Enter => state.commit_edit(),
421        KeyCode::Backspace => {
422            if let Some(e) = state.editing.as_mut() {
423                e.buffer.pop();
424                e.error = None;
425            }
426        }
427        KeyCode::Char(c) => {
428            // Ctrl-C inside the edit prompt cancels the edit; matches dialoguer
429            // and "any reasonable text input" expectations.
430            if key.modifiers.contains(KeyModifiers::CONTROL) && (c == 'c' || c == 'C') {
431                state.cancel_edit();
432                return;
433            }
434            if let Some(e) = state.editing.as_mut() {
435                e.buffer.push(c);
436                e.error = None;
437            }
438        }
439        _ => {}
440    }
441}
442
443// ---------------------------------------------------------------------------
444// Rendering
445// ---------------------------------------------------------------------------
446
447fn render(f: &mut ratatui::Frame, state: &AppState) {
448    let chunks = Layout::default()
449        .direction(Direction::Vertical)
450        .constraints([
451            Constraint::Length(3),
452            Constraint::Min(5),
453            Constraint::Length(2),
454        ])
455        .split(f.area());
456
457    render_header(f, chunks[0], state);
458    render_body(f, chunks[1], state);
459    render_footer(f, chunks[2], state);
460
461    if let Some(edit) = &state.editing {
462        render_edit_modal(f, edit);
463    }
464}
465
466fn render_header(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
467    let dim_when_unavailable = state.repo_value.is_none();
468    let active_global = matches!(state.active, Scope::Global);
469    let active_repo = matches!(state.active, Scope::Repo);
470
471    let global_label = if active_global {
472        Span::styled("● global", Style::new().add_modifier(Modifier::BOLD))
473    } else {
474        Span::raw("○ global")
475    };
476    let repo_label = if dim_when_unavailable {
477        Span::styled("○ repo (n/a)", Style::new().add_modifier(Modifier::DIM))
478    } else if active_repo {
479        Span::styled("● repo", Style::new().add_modifier(Modifier::BOLD))
480    } else {
481        Span::raw("○ repo")
482    };
483
484    let line = Line::from(vec![
485        Span::raw("Scope: "),
486        global_label,
487        Span::raw("   "),
488        repo_label,
489        Span::raw("    "),
490        Span::styled(
491            current_path_hint(state),
492            Style::new().add_modifier(Modifier::DIM),
493        ),
494    ]);
495    let block = Block::default()
496        .borders(Borders::ALL)
497        .title(" gw config edit ");
498    let para = Paragraph::new(line).block(block);
499    f.render_widget(para, area);
500}
501
502fn current_path_hint(state: &AppState) -> String {
503    let p = match state.active {
504        Scope::Global => Some(state.global_path.as_path()),
505        Scope::Repo => state.repo_path.as_deref(),
506    };
507    p.map(|p| p.display().to_string()).unwrap_or_default()
508}
509
510fn render_body(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
511    let keys = AppState::keys();
512    let key_col = keys.iter().map(|k| k.name().len()).max().unwrap_or(0);
513
514    let mut lines: Vec<Line> = Vec::with_capacity(keys.len());
515    for (i, key) in keys.iter().enumerate() {
516        let v = match state.active_value() {
517            Some(root) => lookup_value(root, *key),
518            None => None,
519        };
520        let value_str = match v {
521            Some(v) => value_to_input_string(v),
522            None => "(unset)".to_string(),
523        };
524        let key_text = format!("{key:<key_col$}", key = key.name());
525        let marker = if i == state.cursor { "▶ " } else { "  " };
526        let style = if i == state.cursor {
527            Style::new().add_modifier(Modifier::REVERSED)
528        } else {
529            Style::new()
530        };
531        lines.push(Line::from(vec![
532            Span::raw(marker),
533            Span::styled(format!("{key_text}  {value_str}"), style),
534        ]));
535    }
536
537    let title = format!(" {} ", state.active.label());
538    let block = Block::default().borders(Borders::ALL).title(title);
539    f.render_widget(Paragraph::new(lines).block(block), area);
540}
541
542fn render_footer(f: &mut ratatui::Frame, area: Rect, state: &AppState) {
543    let dirty = state.dirty_global || state.dirty_repo;
544    let mut spans = vec![Span::raw(state.status.as_str())];
545    if dirty {
546        spans.push(Span::raw("   "));
547        spans.push(Span::styled(
548            "[unsaved]",
549            Style::new().add_modifier(Modifier::BOLD),
550        ));
551    }
552    let line = Line::from(spans);
553    f.render_widget(Paragraph::new(line), area);
554}
555
556fn render_edit_modal(f: &mut ratatui::Frame, edit: &EditState) {
557    let area = centered_rect(60, 7, f.area());
558    f.render_widget(Clear, area);
559
560    let title = format!(" edit {} [{}] ", edit.key.name(), edit.scope.label());
561    let mut lines = vec![
562        Line::from(Span::raw(format!("{}_", edit.buffer))),
563        Line::from(Span::styled(
564            "Enter: commit · Esc: cancel · empty = unset",
565            Style::new().add_modifier(Modifier::DIM),
566        )),
567    ];
568    if let Some(err) = &edit.error {
569        lines.push(Line::from(Span::styled(
570            err.clone(),
571            Style::new().add_modifier(Modifier::BOLD),
572        )));
573    }
574    let block = Block::default().borders(Borders::ALL).title(title);
575    f.render_widget(Paragraph::new(lines).block(block), area);
576}
577
578fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
579    let w = width.min(area.width.saturating_sub(2));
580    let h = height.min(area.height.saturating_sub(2));
581    let x = area.x + area.width.saturating_sub(w) / 2;
582    let y = area.y + area.height.saturating_sub(h) / 2;
583    Rect {
584        x,
585        y,
586        width: w,
587        height: h,
588    }
589}
590
591// ---------------------------------------------------------------------------
592// Tests
593// ---------------------------------------------------------------------------
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598    use serde_json::json;
599    use tempfile::TempDir;
600
601    fn fresh_state() -> AppState {
602        // Synthetic state: empty global, no repo. We bypass `load` since the
603        // tests want to drive the state machine directly.
604        AppState {
605            global_value: Value::Object(serde_json::Map::new()),
606            repo_value: None,
607            global_path: PathBuf::from("/tmp/none-global.json"),
608            repo_path: None,
609            cursor: 0,
610            active: Scope::Global,
611            dirty_global: false,
612            dirty_repo: false,
613            status: String::new(),
614            editing: None,
615            quit: false,
616        }
617    }
618
619    #[test]
620    fn move_cursor_wraps_in_both_directions() {
621        let mut s = fresh_state();
622        s.move_cursor(-1);
623        assert_eq!(s.cursor, ConfigKey::ALL.len() - 1);
624        s.move_cursor(1);
625        assert_eq!(s.cursor, 0);
626    }
627
628    #[test]
629    fn toggle_scope_blocked_outside_repo() {
630        let mut s = fresh_state();
631        s.toggle_scope();
632        assert_eq!(s.active, Scope::Global);
633        let lc = s.status.to_lowercase();
634        assert!(
635            lc.contains("not inside") && lc.contains("repo"),
636            "unexpected status: {:?}",
637            s.status
638        );
639    }
640
641    #[test]
642    fn begin_edit_seeds_buffer_with_current_value() {
643        let mut s = fresh_state();
644        s.global_value = json!({"ai_tool": {"command": "codex"}});
645        // cursor=0 → ai-tool.command
646        s.begin_edit();
647        let edit = s.editing.as_ref().unwrap();
648        assert_eq!(edit.buffer, "codex");
649        assert_eq!(edit.scope, Scope::Global);
650    }
651
652    #[test]
653    fn commit_edit_updates_value_and_marks_dirty() {
654        let mut s = fresh_state();
655        s.begin_edit();
656        s.editing.as_mut().unwrap().buffer = "codex".to_string();
657        s.commit_edit();
658        assert!(s.dirty_global);
659        assert_eq!(
660            lookup_value(&s.global_value, ConfigKey::AiToolCommand).unwrap(),
661            &Value::String("codex".into())
662        );
663        assert!(s.editing.is_none());
664    }
665
666    #[test]
667    fn commit_edit_with_empty_buffer_unsets() {
668        let mut s = fresh_state();
669        s.global_value = json!({"ai_tool": {"command": "codex"}});
670        s.begin_edit();
671        s.editing.as_mut().unwrap().buffer = "  ".to_string();
672        s.commit_edit();
673        assert!(s.dirty_global);
674        assert!(lookup_value(&s.global_value, ConfigKey::AiToolCommand).is_none());
675    }
676
677    #[test]
678    fn commit_edit_invalid_value_keeps_editing_open() {
679        let mut s = fresh_state();
680        // cursor=2 → ai-tool.guard (boolean)
681        s.cursor = 2;
682        s.begin_edit();
683        s.editing.as_mut().unwrap().buffer = "definitely-not-bool".to_string();
684        s.commit_edit();
685        assert!(s.editing.is_some(), "should remain in edit mode on error");
686        assert!(s.editing.as_ref().unwrap().error.is_some());
687        assert!(!s.dirty_global);
688    }
689
690    #[test]
691    fn save_persists_global_changes() {
692        let tmp = TempDir::new().unwrap();
693        let p = tmp.path().join("config.json");
694        let mut s = fresh_state();
695        s.global_path = p.clone();
696        s.global_value = json!({"ai_tool": {"command": "codex"}});
697        s.dirty_global = true;
698
699        let summary = s.save().unwrap();
700        assert_eq!(summary.global, 1);
701        assert!(!s.dirty_global);
702
703        let read = std::fs::read_to_string(&p).unwrap();
704        assert!(read.contains("\"command\""));
705        assert!(read.contains("\"codex\""));
706    }
707
708    #[test]
709    fn save_skips_repo_when_no_repo_loaded() {
710        let tmp = TempDir::new().unwrap();
711        let p = tmp.path().join("config.json");
712        let mut s = fresh_state();
713        s.global_path = p;
714        s.dirty_repo = true;
715
716        // No panic, no write attempted; save still succeeds.
717        let summary = s.save().unwrap();
718        assert_eq!(summary.repo, 0);
719    }
720}