use std::collections::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::download::{self, Downloaded};
use super::install::{self, Editor};
use crate::theme;
use crate::widgets::{
checkbox, input_paragraph, item_title_style, key_hint_line, list_cursor_style, status_icon,
wrapping_index, LIST_CURSOR,
};
const POLL_INTERVAL: Duration = Duration::from_millis(120);
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const EXAMPLES: &str = "ms-python.python · esbenp.prettier-vscode · dbaeumer.vscode-eslint";
const INPUT_HINTS: &[(&str, &str)] = &[("Enter", "下载"), ("Esc", "退出")];
const SELECT_HINTS: &[(&str, &str)] = &[
("↑/↓", "移动"),
("Space", "选择"),
("A", "全选"),
("Enter", "安装"),
("Esc", "跳过"),
];
enum Mode {
Input,
Downloading,
SelectEditors,
Installing,
}
pub struct App {
input: String,
mode: Mode,
status: Option<String>,
status_ok: bool,
quit: bool,
spinner: usize,
dl_rx: Option<Receiver<std::result::Result<Downloaded, String>>>,
downloaded: Option<Downloaded>,
editors: Vec<Editor>,
selected: HashSet<Editor>,
editor_state: ListState,
install_rx: Option<Receiver<(bool, String)>>,
}
impl App {
pub fn new() -> Self {
Self {
input: String::new(),
mode: Mode::Input,
status: None,
status_ok: true,
quit: false,
spinner: 0,
dl_rx: None,
downloaded: None,
editors: Vec::new(),
selected: HashSet::new(),
editor_state: ListState::default(),
install_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 matches!(self.mode, Mode::Downloading) {
if let Some(result) = self.dl_rx.as_ref().and_then(|rx| rx.try_recv().ok()) {
self.finish_download(result);
}
}
if matches!(self.mode, Mode::Installing) {
if let Some(result) = self.install_rx.as_ref().and_then(|rx| rx.try_recv().ok()) {
self.finish_install(result);
}
}
}
fn on_key(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctrl && matches!(key.code, KeyCode::Char('c')) {
self.quit = true;
return;
}
match self.mode {
Mode::Input => self.on_input_key(key),
Mode::SelectEditors => self.on_select_key(key),
Mode::Downloading | Mode::Installing => {}
}
}
fn on_input_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => self.quit = true,
KeyCode::Enter => {
if !self.input.trim().is_empty() {
self.start_download();
}
}
KeyCode::Backspace => {
self.input.pop();
}
KeyCode::Char(c) => self.input.push(c),
_ => {}
}
}
fn on_select_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Up => self.move_editor(-1),
KeyCode::Down => self.move_editor(1),
KeyCode::Char(' ') => self.toggle_current_editor(),
KeyCode::Char('a' | 'A') => self.toggle_all_editors(),
KeyCode::Enter => self.start_install(),
KeyCode::Esc => {
self.status = Some("已跳过安装".to_string());
self.status_ok = true;
self.downloaded = None;
self.mode = Mode::Input;
}
_ => {}
}
}
fn move_editor(&mut self, delta: isize) {
if let Some(next) = wrapping_index(
self.editor_state.selected().unwrap_or(0),
delta,
self.editors.len(),
) {
self.editor_state.select(Some(next));
}
}
fn toggle_current_editor(&mut self) {
if let Some(editor) = self
.editor_state
.selected()
.and_then(|i| self.editors.get(i))
.copied()
{
if !self.selected.remove(&editor) {
self.selected.insert(editor);
}
}
}
fn toggle_all_editors(&mut self) {
let all = self.editors.iter().all(|e| self.selected.contains(e));
if all {
self.selected.clear();
} else {
self.selected = self.editors.iter().copied().collect();
}
}
fn start_download(&mut self) {
let input = self.input.trim().to_string();
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let outcome = download::resolve_and_download(&input).map_err(|err| err.to_string());
let _ = tx.send(outcome);
});
self.dl_rx = Some(rx);
self.status = None;
self.mode = Mode::Downloading;
}
fn finish_download(&mut self, result: std::result::Result<Downloaded, String>) {
self.dl_rx = None;
match result {
Ok(downloaded) => {
self.editors = install::find_editors();
let size = download::human_size(&downloaded.path);
let saved = format!(
"已下载 {} v{}({size})",
downloaded.label, downloaded.version
);
if self.editors.is_empty() {
self.status = Some(format!("{saved};未找到 cursor/code,请手动安装"));
self.status_ok = true;
self.downloaded = None;
self.mode = Mode::Input;
} else {
self.selected = self.editors.iter().copied().collect();
self.editor_state.select(Some(0));
self.status = Some(saved);
self.status_ok = true;
self.downloaded = Some(downloaded);
self.mode = Mode::SelectEditors;
}
}
Err(err) => {
self.status = Some(err);
self.status_ok = false;
self.mode = Mode::Input;
}
}
}
fn start_install(&mut self) {
let chosen: Vec<Editor> = self
.editors
.iter()
.filter(|editor| self.selected.contains(editor))
.copied()
.collect();
if chosen.is_empty() {
self.status = Some("已跳过安装".to_string());
self.status_ok = true;
self.downloaded = None;
self.mode = Mode::Input;
return;
}
let Some(downloaded) = self.downloaded.as_ref() else {
self.mode = Mode::Input;
return;
};
let path = downloaded.path.clone();
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let outcome = install::install_to_editors(&chosen, &path);
let _ = tx.send(outcome);
});
self.install_rx = Some(rx);
self.status = None;
self.mode = Mode::Installing;
}
fn finish_install(&mut self, result: (bool, String)) {
self.install_rx = None;
let (ok, message) = result;
self.status = Some(message);
self.status_ok = ok;
self.downloaded = None;
self.input.clear();
self.mode = Mode::Input;
}
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
fn panel(title: impl Into<String>, focused: bool) -> Block<'static> {
let border = if focused {
theme::accent()
} else {
theme::muted()
};
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border))
.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(3), Constraint::Min(1), Constraint::Length(1), ])
.split(frame.area());
render_input(frame, app, chunks[0]);
render_body(frame, app, chunks[1]);
render_hints(frame, app, chunks[2]);
}
fn render_input(frame: &mut Frame, app: &App, area: Rect) {
let widget = input_paragraph(
&app.input,
"输入名称或 Marketplace 链接",
matches!(app.mode, Mode::Input),
" 插件 ",
);
frame.render_widget(widget, area);
}
fn status_line(app: &App) -> Option<Line<'static>> {
app.status.as_ref().map(|status| {
let color = if app.status_ok {
theme::success()
} else {
theme::warning()
};
Line::from(vec![
status_icon(app.status_ok),
Span::styled(status.clone(), Style::default().fg(color)),
])
})
}
fn render_body(frame: &mut Frame, app: &mut App, area: Rect) {
match app.mode {
Mode::Input => {
let mut lines = Vec::new();
if let Some(status) = status_line(app) {
lines.push(status);
lines.push(Line::raw(""));
}
lines.push(Line::from(Span::styled(
"输入扩展 id 或链接,回车下载最新版本到 ~/Downloads",
Style::default().fg(theme::muted()),
)));
lines.push(Line::from(Span::styled(
format!("示例:{EXAMPLES}"),
Style::default()
.fg(theme::muted())
.add_modifier(Modifier::DIM),
)));
frame.render_widget(Paragraph::new(lines), area);
}
Mode::Downloading => render_progress(frame, app, area, "正在查询版本并下载…"),
Mode::Installing => render_progress(frame, app, area, "正在安装到编辑器…"),
Mode::SelectEditors => {
let rows = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(area);
let lines: Vec<Line> = status_line(app).into_iter().collect();
frame.render_widget(Paragraph::new(lines), rows[0]);
render_editor_list(frame, app, rows[1]);
}
}
}
fn render_progress(frame: &mut Frame, app: &App, area: Rect, text: &str) {
let spinner = SPINNER[app.spinner % SPINNER.len()];
let line = Line::from(vec![
Span::styled(format!("{spinner} "), Style::default().fg(theme::warning())),
Span::styled(text.to_string(), Style::default().fg(theme::warning())),
]);
frame.render_widget(Paragraph::new(line), area);
}
fn render_editor_list(frame: &mut Frame, app: &mut App, area: Rect) {
let items: Vec<ListItem> = app
.editors
.iter()
.map(|editor| {
let checked = app.selected.contains(editor);
ListItem::new(Line::from(vec![
checkbox(checked),
Span::styled(editor.label(), item_title_style(checked)),
]))
})
.collect();
let list = List::new(items)
.block(panel(" 目标编辑器 ", false))
.highlight_symbol(LIST_CURSOR)
.highlight_style(list_cursor_style());
frame.render_stateful_widget(list, area, &mut app.editor_state);
}
fn render_hints(frame: &mut Frame, app: &App, area: Rect) {
let hints = match app.mode {
Mode::Input => INPUT_HINTS,
Mode::SelectEditors => SELECT_HINTS,
Mode::Downloading | Mode::Installing => return,
};
frame.render_widget(Paragraph::new(key_hint_line(hints)), area);
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
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
}
#[test]
fn input_mode_shows_placeholder_and_examples() {
let mut app = App::new();
let out = dump(&mut app, 72, 12);
assert!(out.contains("ms-python.python"));
assert!(out.contains("Marketplace"));
}
#[test]
fn select_mode_lists_editors_with_checkboxes() {
let mut app = App::new();
app.mode = Mode::SelectEditors;
app.editors = vec![Editor::Cursor, Editor::Code];
app.selected.insert(Editor::Cursor);
app.editor_state.select(Some(0));
let out = dump(&mut app, 72, 12);
assert!(out.contains("Cursor"));
assert!(out.contains("VS Code"));
assert!(out.contains("[x]"));
assert!(out.contains("[ ]"));
assert!(out.contains('❯'));
}
#[test]
fn toggle_all_editors_flips() {
let mut app = App::new();
app.editors = vec![Editor::Cursor, Editor::Code];
app.toggle_all_editors();
assert_eq!(app.selected.len(), 2);
app.toggle_all_editors();
assert!(app.selected.is_empty());
}
}