use std::collections::{HashMap, HashSet};
use std::sync::mpsc::{self, Receiver};
use std::thread;
use std::time::Duration;
use anyhow::Result;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, List, ListItem, ListState, Paragraph};
use ratatui::{DefaultTerminal, Frame};
use super::{build_candidates, sync_extension, SyncCandidate, SyncItemResult, SOURCE, TARGET};
use crate::sync::editors;
use crate::theme;
use crate::vsix::install;
use crate::widgets::{
checkbox, item_desc_style, item_title_style, key_hint_line, list_cursor_style, wrapping_index,
LIST_CURSOR,
};
const POLL_INTERVAL: Duration = Duration::from_millis(120);
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const CONFIRM_PREVIEW: usize = 12;
const LOADING_HINTS: &[(&str, &str)] = &[("Esc", "返回")];
const ERROR_HINTS: &[(&str, &str)] = &[("Enter/q", "返回")];
const SELECT_HINTS: &[(&str, &str)] = &[
("↑/↓", "移动"),
("Space", "选择"),
("A", "全选"),
("Enter", "下一步"),
("Esc", "退出"),
];
const CONFIRM_HINTS: &[(&str, &str)] = &[("Enter", "确认开始"), ("Esc", "返回修改")];
const DONE_HINTS: &[(&str, &str)] = &[("Enter/q", "返回")];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Phase {
Loading,
Select,
Confirm,
Syncing,
Done,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ItemStatus {
Pending,
Downloading,
Success,
Failed,
}
enum SyncEvent {
ItemStart { index: usize, id: String },
ItemDone(SyncItemResult),
AllDone,
}
pub struct App {
phase: Phase,
error: Option<String>,
installable: Vec<SyncCandidate>,
installed: Vec<SyncCandidate>,
selected: HashSet<String>,
list_state: ListState,
spinner: usize,
quit: bool,
load_rx: Option<Receiver<std::result::Result<Vec<SyncCandidate>, String>>>,
chosen: Vec<SyncCandidate>,
item_status: HashMap<String, ItemStatus>,
results: Vec<SyncItemResult>,
progress_current: usize,
progress_total: usize,
sync_rx: Option<Receiver<SyncEvent>>,
}
impl App {
pub fn new() -> Self {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let _ = tx.send(load_candidates());
});
Self {
phase: Phase::Loading,
error: None,
installable: Vec::new(),
installed: Vec::new(),
selected: HashSet::new(),
list_state: ListState::default(),
spinner: 0,
quit: false,
load_rx: Some(rx),
chosen: Vec::new(),
item_status: HashMap::new(),
results: Vec::new(),
progress_current: 0,
progress_total: 0,
sync_rx: None,
}
}
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
while !self.quit {
self.poll_background();
terminal.draw(|frame| render(frame, self))?;
if event::poll(POLL_INTERVAL)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
self.on_key(key);
}
}
}
self.spinner = self.spinner.wrapping_add(1);
}
Ok(())
}
fn poll_background(&mut self) {
if self.phase == Phase::Loading {
if let Some(result) = self.load_rx.as_ref().and_then(|rx| rx.try_recv().ok()) {
self.finish_loading(result);
}
}
if self.phase == Phase::Syncing {
while let Some(event) = self.sync_rx.as_ref().and_then(|rx| rx.try_recv().ok()) {
self.apply_sync_event(event);
}
}
}
fn finish_loading(&mut self, result: std::result::Result<Vec<SyncCandidate>, String>) {
self.load_rx = None;
match result {
Ok(candidates) => {
self.installable = candidates
.iter()
.filter(|c| !c.installed_in_target)
.cloned()
.collect();
self.installed = candidates
.into_iter()
.filter(|c| c.installed_in_target)
.collect();
self.selected = self.installable.iter().map(|c| c.id.clone()).collect();
self.list_state.select(Some(0));
self.phase = Phase::Select;
}
Err(err) => {
self.error = Some(err);
self.phase = Phase::Error;
}
}
}
fn apply_sync_event(&mut self, event: SyncEvent) {
match event {
SyncEvent::ItemStart { index, id } => {
self.progress_current = index + 1;
self.item_status.insert(id, ItemStatus::Downloading);
}
SyncEvent::ItemDone(result) => {
self.item_status.insert(
result.id.clone(),
if result.success {
ItemStatus::Success
} else {
ItemStatus::Failed
},
);
self.results.push(result);
}
SyncEvent::AllDone => {
self.sync_rx = None;
self.phase = Phase::Done;
}
}
}
fn on_key(&mut self, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c')) {
self.quit = true;
return;
}
match self.phase {
Phase::Loading => {
if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) {
self.quit = true;
}
}
Phase::Error | Phase::Done => {
if matches!(key.code, KeyCode::Enter | KeyCode::Esc | KeyCode::Char('q')) {
self.quit = true;
}
}
Phase::Select => self.on_select_key(key),
Phase::Confirm => match key.code {
KeyCode::Enter => self.start_sync(),
KeyCode::Esc | KeyCode::Char('q') => self.phase = Phase::Select,
_ => {}
},
Phase::Syncing => {}
}
}
fn on_select_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Up => self.move_cursor(-1),
KeyCode::Down => self.move_cursor(1),
KeyCode::Char(' ') => self.toggle_current(),
KeyCode::Char('a' | 'A') => self.toggle_all(),
KeyCode::Enter => {
if !self.selected.is_empty() {
self.phase = Phase::Confirm;
}
}
KeyCode::Esc | KeyCode::Char('q') => self.quit = true,
_ => {}
}
}
fn move_cursor(&mut self, delta: isize) {
if let Some(next) = wrapping_index(
self.list_state.selected().unwrap_or(0),
delta,
self.installable.len(),
) {
self.list_state.select(Some(next));
}
}
fn toggle_current(&mut self) {
if let Some(id) = self
.list_state
.selected()
.and_then(|i| self.installable.get(i))
.map(|c| c.id.clone())
{
if !self.selected.remove(&id) {
self.selected.insert(id);
}
}
}
fn toggle_all(&mut self) {
let all = self
.installable
.iter()
.all(|c| self.selected.contains(&c.id));
if all {
self.selected.clear();
} else {
self.selected = self.installable.iter().map(|c| c.id.clone()).collect();
}
}
fn start_sync(&mut self) {
let chosen: Vec<SyncCandidate> = self
.installable
.iter()
.filter(|c| self.selected.contains(&c.id))
.cloned()
.collect();
if chosen.is_empty() {
return;
}
self.chosen = chosen.clone();
self.item_status = chosen
.iter()
.map(|c| (c.id.clone(), ItemStatus::Pending))
.collect();
self.results.clear();
self.progress_total = chosen.len();
self.progress_current = 0;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let tmp = std::env::temp_dir().join(format!("vkit-sync-{}", std::process::id()));
let _ = std::fs::create_dir_all(&tmp);
for (index, candidate) in chosen.iter().enumerate() {
let _ = tx.send(SyncEvent::ItemStart {
index,
id: candidate.id.clone(),
});
let result = sync_extension(&candidate.id, TARGET, &tmp);
let _ = tx.send(SyncEvent::ItemDone(result));
}
let _ = std::fs::remove_dir_all(&tmp);
let _ = tx.send(SyncEvent::AllDone);
});
self.sync_rx = Some(rx);
self.phase = Phase::Syncing;
}
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
fn load_candidates() -> std::result::Result<Vec<SyncCandidate>, String> {
let editors = install::find_editors();
if !editors.contains(&SOURCE) {
return Err(
"未找到 code 命令。请在 VS Code 中执行「Shell Command: Install 'code' command in PATH」。"
.to_string(),
);
}
if !editors.contains(&TARGET) {
return Err(
"未找到 cursor 命令。请在 Cursor 中执行「Shell Command: Install 'cursor' command in PATH」。"
.to_string(),
);
}
let source_exts = editors::list_installed(SOURCE).map_err(|e| e.to_string())?;
let target_exts = editors::list_installed(TARGET).map_err(|e| e.to_string())?;
let candidates = build_candidates(&source_exts, &target_exts);
if candidates.is_empty() {
return Err(format!("{} 没有检测到已安装的插件。", SOURCE.label()));
}
if candidates.iter().all(|c| c.installed_in_target) {
return Err(format!(
"{} 已拥有 {} 的全部插件,无需同步。",
TARGET.label(),
SOURCE.label()
));
}
Ok(candidates)
}
fn panel(title: impl Into<String>) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::muted()))
.title(Span::styled(
title.into(),
Style::default()
.fg(theme::accent())
.add_modifier(Modifier::BOLD),
))
}
fn render(frame: &mut Frame, app: &mut App) {
let chunks = Layout::vertical([
Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
.split(frame.area());
let title = Line::from(vec![Span::styled(
format!("同步插件:{} → {}", SOURCE.label(), TARGET.label()),
Style::default()
.fg(theme::accent())
.add_modifier(Modifier::BOLD),
)]);
frame.render_widget(Paragraph::new(title), chunks[0]);
match app.phase {
Phase::Loading => render_message(
frame,
chunks[1],
&format!(
"{} 正在读取 {} 与 {} 的插件列表…",
SPINNER[app.spinner % SPINNER.len()],
SOURCE.label(),
TARGET.label()
),
theme::warning(),
),
Phase::Error => render_message(
frame,
chunks[1],
&format!("⚠ {}", app.error.as_deref().unwrap_or("发生错误")),
theme::warning(),
),
Phase::Select => render_select(frame, app, chunks[1]),
Phase::Confirm => render_confirm(frame, app, chunks[1]),
Phase::Syncing | Phase::Done => render_progress(frame, app, chunks[1]),
}
let hints = match app.phase {
Phase::Loading => LOADING_HINTS,
Phase::Error => ERROR_HINTS,
Phase::Select => SELECT_HINTS,
Phase::Confirm => CONFIRM_HINTS,
Phase::Done => DONE_HINTS,
Phase::Syncing => &[],
};
frame.render_widget(Paragraph::new(key_hint_line(hints)), chunks[2]);
}
fn render_message(frame: &mut Frame, area: Rect, text: &str, color: ratatui::style::Color) {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
text.to_string(),
Style::default().fg(color),
))),
area,
);
}
fn render_select(frame: &mut Frame, app: &mut App, area: Rect) {
let rows = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(area);
let summary = Line::from(vec![
Span::styled("可同步 ", Style::default().fg(theme::text())),
Span::styled(
app.installable.len().to_string(),
Style::default()
.fg(theme::success())
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" 已选 {}", app.selected.len()),
Style::default().fg(theme::muted()),
),
]);
frame.render_widget(Paragraph::new(summary), rows[0]);
let panes =
Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)]).split(rows[1]);
render_installable(frame, app, panes[0]);
render_installed(frame, app, panes[1]);
}
fn render_installable(frame: &mut Frame, app: &mut App, area: Rect) {
let title = format!(" 待安装 ({}) ", app.installable.len());
if app.installable.is_empty() {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
"(无)",
Style::default().fg(theme::muted()),
)))
.block(panel(title)),
area,
);
return;
}
let items: Vec<ListItem> = app
.installable
.iter()
.map(|candidate| {
let checked = app.selected.contains(&candidate.id);
ListItem::new(Line::from(vec![
checkbox(checked),
Span::styled(candidate.id.clone(), item_title_style(checked)),
]))
})
.collect();
let list = List::new(items)
.block(panel(title))
.highlight_symbol(LIST_CURSOR)
.highlight_style(list_cursor_style());
frame.render_stateful_widget(list, area, &mut app.list_state);
}
fn render_installed(frame: &mut Frame, app: &App, area: Rect) {
let title = format!(" 已安装 ({}) ", app.installed.len());
let lines: Vec<Line> = if app.installed.is_empty() {
vec![Line::from(Span::styled(
"(无)",
Style::default().fg(theme::muted()),
))]
} else {
app.installed
.iter()
.map(|candidate| {
Line::from(vec![
Span::styled("✓ ", item_desc_style()),
Span::styled(candidate.id.clone(), item_desc_style()),
])
})
.collect()
};
frame.render_widget(Paragraph::new(lines).block(panel(title)), area);
}
fn render_confirm(frame: &mut Frame, app: &App, area: Rect) {
let chosen: Vec<&SyncCandidate> = app
.installable
.iter()
.filter(|c| app.selected.contains(&c.id))
.collect();
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(vec![
Span::styled("即将安装 ", Style::default().fg(theme::text())),
Span::styled(
chosen.len().to_string(),
Style::default()
.fg(theme::success())
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" 个插件到 {}:", TARGET.label()),
Style::default().fg(theme::text()),
),
]));
lines.push(Line::raw(""));
for candidate in chosen.iter().take(CONFIRM_PREVIEW) {
lines.push(Line::from(Span::styled(
format!(" • {}", candidate.id),
Style::default().fg(theme::success()),
)));
}
if chosen.len() > CONFIRM_PREVIEW {
lines.push(Line::from(Span::styled(
format!(" … 还有 {} 个", chosen.len() - CONFIRM_PREVIEW),
Style::default()
.fg(theme::muted())
.add_modifier(Modifier::DIM),
)));
}
frame.render_widget(Paragraph::new(lines), area);
}
fn render_progress(frame: &mut Frame, app: &App, area: Rect) {
let rows = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(area);
let done = app.phase == Phase::Done;
let header = if done {
let succeeded = app.results.iter().filter(|r| r.success).count();
let failed = app.results.len() - succeeded;
let mut spans = vec![
Span::styled("✓ ", Style::default().fg(theme::success())),
Span::styled("安装完成,成功 ", Style::default().fg(theme::text())),
Span::styled(
succeeded.to_string(),
Style::default()
.fg(theme::success())
.add_modifier(Modifier::BOLD),
),
Span::styled(" 个", Style::default().fg(theme::text())),
];
if failed > 0 {
spans.push(Span::styled(",失败 ", Style::default().fg(theme::text())));
spans.push(Span::styled(
failed.to_string(),
Style::default()
.fg(theme::warning())
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(" 个", Style::default().fg(theme::text())));
}
Line::from(spans)
} else {
Line::from(vec![
Span::styled(
format!("{} ", SPINNER[app.spinner % SPINNER.len()]),
Style::default().fg(theme::warning()),
),
Span::styled("安装中 ", Style::default().fg(theme::text())),
Span::styled(
format!("{}/{}", app.progress_current, app.progress_total),
Style::default()
.fg(theme::accent())
.add_modifier(Modifier::BOLD),
),
])
};
frame.render_widget(Paragraph::new(header), rows[0]);
let items: Vec<ListItem> = app
.chosen
.iter()
.map(|candidate| {
let status = app
.item_status
.get(&candidate.id)
.copied()
.unwrap_or(ItemStatus::Pending);
let (symbol, style) = match status {
ItemStatus::Pending => ("○ ", Style::default().fg(theme::muted())),
ItemStatus::Downloading => ("⋯ ", Style::default().fg(theme::warning())),
ItemStatus::Success => ("✓ ", Style::default().fg(theme::success())),
ItemStatus::Failed => ("✗ ", Style::default().fg(theme::warning())),
};
let mut spans = vec![
Span::styled(symbol, style),
Span::styled(candidate.id.clone(), Style::default().fg(theme::text())),
];
if status == ItemStatus::Failed {
if let Some(result) = app.results.iter().find(|r| r.id == candidate.id) {
spans.push(Span::styled(
format!(" {}", result.message),
Style::default()
.fg(theme::warning())
.add_modifier(Modifier::DIM),
));
}
}
ListItem::new(Line::from(spans))
})
.collect();
frame.render_widget(List::new(items).block(panel(" 同步进度 ")), rows[1]);
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn cand(id: &str, installed: bool) -> SyncCandidate {
SyncCandidate {
id: id.to_string(),
source_version: Some("1.0.0".to_string()),
installed_in_target: installed,
}
}
fn dump(app: &mut App, w: u16, h: u16) -> String {
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
terminal.draw(|frame| render(frame, app)).unwrap();
let buffer = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
out.push_str(buffer[(x, y)].symbol());
}
out.push('\n');
}
out
}
fn select_app() -> App {
let mut app = App::new();
app.load_rx = None;
app.phase = Phase::Select;
app.installable = vec![cand("a.one", false), cand("b.two", false)];
app.installed = vec![cand("c.three", true)];
app.selected = app.installable.iter().map(|c| c.id.clone()).collect();
app.list_state.select(Some(0));
app
}
#[test]
fn select_lists_candidates() {
let mut app = select_app();
let out = dump(&mut app, 72, 14);
assert!(out.contains("a.one"));
assert!(out.contains("b.two"));
assert!(out.contains("[x]"));
assert!(out.contains('❯'));
}
#[test]
fn select_two_pane_shows_both_panels() {
let mut app = select_app();
let out = dump(&mut app, 80, 14);
let compact: String = out.chars().filter(|c| !c.is_whitespace()).collect();
assert!(compact.contains("待安装"), "missing 待安装 title:\n{out}");
assert!(compact.contains("a.one"));
assert!(compact.contains("b.two"));
assert!(compact.contains("已安装"), "missing 已安装 title:\n{out}");
assert!(
compact.contains("c.three"),
"missing installed item:\n{out}"
);
assert!(compact.contains('✓'), "missing installed marker:\n{out}");
}
#[test]
fn cursor_wraps_from_first_to_last() {
let mut app = select_app();
app.list_state.select(Some(0));
app.move_cursor(-1);
assert_eq!(app.list_state.selected(), Some(1));
app.move_cursor(1);
assert_eq!(app.list_state.selected(), Some(0));
}
#[test]
fn toggle_all_clears_and_restores() {
let mut app = select_app();
app.toggle_all();
assert!(app.selected.is_empty());
app.toggle_all();
assert_eq!(app.selected.len(), 2);
}
}