clync 0.4.1

Encrypted sync for Claude Code across machines
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use std::io;
use std::path::PathBuf;

use crate::cmd::checkout::{CloneAction, UnmappedProject};

struct TuiItem {
    remote_url: String,
    normalized_remote: String,
    session_count: usize,
    checked: bool,
    clone_path: String,
}

struct TuiState {
    items: Vec<TuiItem>,
    cursor: usize,
    list_state: ListState,
    editing: Option<usize>,
    edit_buffer: String,
    edit_cursor: usize,
}

impl TuiState {
    fn from_projects(projects: &[UnmappedProject]) -> Self {
        let items: Vec<TuiItem> = projects
            .iter()
            .map(|p| TuiItem {
                remote_url: p.remote_url.clone(),
                normalized_remote: p.normalized_remote.clone(),
                session_count: p.session_count,
                checked: false,
                clone_path: p.suggested_clone_path.to_string_lossy().to_string(),
            })
            .collect();
        let mut list_state = ListState::default();
        if !items.is_empty() {
            list_state.select(Some(0));
        }
        Self {
            items,
            cursor: 0,
            list_state,
            editing: None,
            edit_buffer: String::new(),
            edit_cursor: 0,
        }
    }

    fn toggle_current(&mut self) {
        if let Some(item) = self.items.get_mut(self.cursor) {
            item.checked = !item.checked;
        }
    }

    fn start_edit(&mut self) {
        if let Some(item) = self.items.get(self.cursor) {
            self.edit_buffer = item.clone_path.clone();
            self.edit_cursor = self.edit_buffer.len();
            self.editing = Some(self.cursor);
        }
    }

    fn finish_edit(&mut self) {
        if let Some(idx) = self.editing {
            if let Some(item) = self.items.get_mut(idx) {
                item.clone_path = self.edit_buffer.clone();
            }
            self.editing = None;
        }
    }

    fn cancel_edit(&mut self) {
        self.editing = None;
    }

    fn selected_actions(&self) -> Vec<CloneAction> {
        self.items
            .iter()
            .filter(|i| i.checked)
            .map(|i| CloneAction {
                remote_url: i.remote_url.clone(),
                clone_path: PathBuf::from(&i.clone_path),
            })
            .collect()
    }
}

fn prev_char_boundary(s: &str, from: usize) -> usize {
    let mut pos = from.saturating_sub(1);
    while pos > 0 && !s.is_char_boundary(pos) {
        pos -= 1;
    }
    pos
}

fn next_char_boundary(s: &str, from: usize) -> usize {
    let mut pos = from + 1;
    while pos < s.len() && !s.is_char_boundary(pos) {
        pos += 1;
    }
    pos
}

pub fn run_tui(projects: &[UnmappedProject]) -> Result<Vec<CloneAction>> {
    enable_raw_mode()?;
    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_tui_inner(projects)));
    let _ = disable_raw_mode();
    let _ = Terminal::new(CrosstermBackend::new(io::stdout())).and_then(|mut t| t.clear());
    match result {
        Ok(r) => r,
        Err(payload) => std::panic::resume_unwind(payload),
    }
}

fn run_tui_inner(projects: &[UnmappedProject]) -> Result<Vec<CloneAction>> {
    let stdout = io::stdout();
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;
    terminal.clear()?;
    let mut state = TuiState::from_projects(projects);
    run_loop(&mut terminal, &mut state)
}

fn run_loop(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    state: &mut TuiState,
) -> Result<Vec<CloneAction>> {
    loop {
        terminal.draw(|frame| draw(frame, &mut *state))?;

        if let Event::Key(key) = event::read()? {
            if state.editing.is_some() {
                match key.code {
                    KeyCode::Enter => state.finish_edit(),
                    KeyCode::Esc => state.cancel_edit(),
                    KeyCode::Backspace => {
                        if state.edit_cursor > 0 {
                            let prev = prev_char_boundary(&state.edit_buffer, state.edit_cursor);
                            state.edit_buffer.drain(prev..state.edit_cursor);
                            state.edit_cursor = prev;
                        }
                    }
                    KeyCode::Left => {
                        if state.edit_cursor > 0 {
                            state.edit_cursor =
                                prev_char_boundary(&state.edit_buffer, state.edit_cursor);
                        }
                    }
                    KeyCode::Right => {
                        if state.edit_cursor < state.edit_buffer.len() {
                            state.edit_cursor =
                                next_char_boundary(&state.edit_buffer, state.edit_cursor);
                        }
                    }
                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                        return Ok(Vec::new());
                    }
                    KeyCode::Char(c) => {
                        state.edit_buffer.insert(state.edit_cursor, c);
                        state.edit_cursor += c.len_utf8();
                    }
                    _ => {}
                }
                continue;
            }

            match key.code {
                KeyCode::Char('q') | KeyCode::Esc => return Ok(Vec::new()),
                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                    return Ok(Vec::new());
                }
                KeyCode::Up | KeyCode::Char('k') => {
                    if state.cursor > 0 {
                        state.cursor -= 1;
                        state.list_state.select(Some(state.cursor));
                    }
                }
                KeyCode::Down | KeyCode::Char('j') => {
                    if state.cursor + 1 < state.items.len() {
                        state.cursor += 1;
                        state.list_state.select(Some(state.cursor));
                    }
                }
                KeyCode::Char(' ') => state.toggle_current(),
                KeyCode::Char('e') => state.start_edit(),
                KeyCode::Char('a') => {
                    let all_checked = state.items.iter().all(|i| i.checked);
                    for item in &mut state.items {
                        item.checked = !all_checked;
                    }
                }
                KeyCode::Enter => {
                    return Ok(state.selected_actions());
                }
                _ => {}
            }
        }
    }
}

fn draw(frame: &mut ratatui::Frame, state: &mut TuiState) {
    let area = frame.area();

    let chunks = Layout::vertical([Constraint::Min(3), Constraint::Length(3)]).split(area);

    let items: Vec<ListItem> = state
        .items
        .iter()
        .enumerate()
        .map(|(i, item)| {
            let check = if item.checked { "[x]" } else { "[ ]" };
            let is_current = i == state.cursor;
            let is_editing = state.editing == Some(i);

            let remote_style = if is_current {
                Style::default()
                    .fg(Color::Cyan)
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default()
            };

            let sessions = format!(
                " ({} session{})",
                item.session_count,
                if item.session_count == 1 { "" } else { "s" }
            );

            let path_line = if is_editing {
                let before = &state.edit_buffer[..state.edit_cursor];
                let rest = &state.edit_buffer[state.edit_cursor..];
                let mut chars = rest.chars();
                let cursor_char = chars
                    .next()
                    .map(|c| c.to_string())
                    .unwrap_or(" ".to_string());
                let after = chars.as_str();
                Line::from(vec![
                    Span::raw("    -> "),
                    Span::raw(before.to_string()),
                    Span::styled(
                        cursor_char,
                        Style::default().bg(Color::White).fg(Color::Black),
                    ),
                    Span::raw(after.to_string()),
                ])
            } else {
                Line::from(vec![
                    Span::raw("    -> "),
                    Span::styled(
                        item.clone_path.clone(),
                        Style::default().fg(Color::DarkGray),
                    ),
                ])
            };

            let lines = vec![
                Line::from(vec![
                    Span::raw(format!(" {check} ")),
                    Span::styled(item.normalized_remote.clone(), remote_style),
                    Span::styled(sessions, Style::default().fg(Color::DarkGray)),
                ]),
                path_line,
            ];

            ListItem::new(lines)
        })
        .collect();

    let list = List::new(items)
        .block(
            Block::default()
                .title(" clync checkout ")
                .borders(Borders::ALL),
        )
        .highlight_style(Style::default());
    frame.render_stateful_widget(list, chunks[0], &mut state.list_state);

    let help = if state.editing.is_some() {
        " ENTER: save | ESC: cancel "
    } else {
        " SPACE: toggle | a: all | e: edit path | ENTER: clone | q: quit "
    };
    let help_widget = Paragraph::new(help).block(
        Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Color::DarkGray)),
    );
    frame.render_widget(help_widget, chunks[1]);
}