use std::sync::mpsc::TryRecvError;
use std::time::Duration;
use anyhow::Result;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Margin, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, List, ListItem, ListState, Padding, Paragraph};
use ratatui::{DefaultTerminal, Frame};
use ratatui_notifications::Notifications;
use tui_big_text::{BigText, PixelSize};
use crate::ports;
use crate::run;
use crate::sync;
use crate::theme;
use crate::vsix;
use crate::worktree;
use crate::widgets::{
item_desc_style, item_title_style, key_hint_line, list_cursor_style, render_toasts,
tick_toasts, toast_error, toast_warn, wrapping_index, LIST_CURSOR,
};
const PANEL_WIDTH: u16 = 64;
const ITEM_HEIGHT: u16 = 3;
const POLL_INTERVAL: Duration = Duration::from_millis(120);
struct Tool {
id: &'static str,
name: &'static str,
description: &'static str,
available: bool,
}
const TOOLS: &[Tool] = &[
Tool {
id: "port",
name: "管理 Node 端口",
description: "列出监听端口及进程,多选一键关闭 · 仅 macOS",
available: true,
},
Tool {
id: "run",
name: "运行 Scripts",
description: "扫描 package.json,过滤 + 批量运行 scripts",
available: true,
},
Tool {
id: "wt",
name: "管理 Worktree",
description: "创建 / 进入 / Remove / Purge · vkit worktree",
available: true,
},
Tool {
id: "vsix",
name: "安装插件 (VSIX)",
description: "从 Marketplace 下载 VSIX 并安装到 Cursor / VS Code",
available: true,
},
Tool {
id: "sync",
name: "同步插件",
description: "把 VS Code 已装扩展批量同步到 Cursor",
available: true,
},
];
const HINTS: &[(&str, &str)] = &[
("↑/↓", "选择"),
("Enter", "进入"),
("1-9", "直达"),
("?", "帮助"),
("q", "退出"),
];
pub fn run() -> Result<()> {
let mut terminal = ratatui::init();
let result = run_loop(&mut terminal);
ratatui::restore();
result
}
fn run_loop(terminal: &mut DefaultTerminal) -> Result<()> {
let mut state = ListState::default();
state.select(Some(0));
let mut toasts = Notifications::new();
let mut show_help = false;
let update_rx = crate::update::check_in_background();
let mut update: Option<String> = None;
let mut update_pending = true;
loop {
if update_pending {
match update_rx.try_recv() {
Ok(version) => {
update = Some(version);
update_pending = false;
}
Err(TryRecvError::Disconnected) => update_pending = false,
Err(TryRecvError::Empty) => {}
}
}
tick_toasts(&mut toasts, POLL_INTERVAL);
terminal.draw(|frame| {
render(frame, &mut state, show_help, update.as_deref());
render_toasts(&mut toasts, frame, frame.area());
})?;
if (update_pending || toasts.has_notification())
&& !event::poll(Duration::from_millis(200))?
{
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if show_help {
if matches!(
key.code,
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') | KeyCode::Char('q')
) {
show_help = false;
}
continue;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
KeyCode::Char('c') if ctrl => return Ok(()),
KeyCode::Char('?') => show_help = true,
KeyCode::Up => move_selection(&mut state, -1),
KeyCode::Down => move_selection(&mut state, 1),
KeyCode::Char(c @ '1'..='9') => {
let index = c as usize - '1' as usize;
if index < TOOLS.len() {
state.select(Some(index));
if let Some(msg) = launch_tool(terminal, index) {
push_launch_toast(&mut toasts, &msg);
}
}
}
KeyCode::Enter => {
if let Some(msg) = launch_tool(terminal, state.selected().unwrap_or(0)) {
push_launch_toast(&mut toasts, &msg);
}
}
_ => {}
}
}
}
fn push_launch_toast(toasts: &mut Notifications, message: &str) {
if message.contains("敬请期待") {
toast_warn(toasts, message);
} else {
toast_error(toasts, message);
}
}
fn launch_tool(terminal: &mut DefaultTerminal, index: usize) -> Option<String> {
let tool = TOOLS.get(index)?;
if !tool.available {
return Some("敬请期待……".to_string());
}
let outcome = match tool.id {
"port" => ports::run_in_terminal(terminal),
"run" => run::run_in_terminal(terminal),
"wt" => worktree::run_in_terminal(terminal),
"vsix" => vsix::run_in_terminal(terminal),
"sync" => sync::run_in_terminal(terminal),
_ => Ok(()),
};
outcome.err().map(|err| err.to_string())
}
fn move_selection(state: &mut ListState, delta: isize) {
if let Some(next) = wrapping_index(state.selected().unwrap_or(0), delta, TOOLS.len()) {
state.select(Some(next));
}
}
fn center(area: Rect, width: u16) -> Rect {
let [inner] = Layout::horizontal([Constraint::Length(width.min(area.width))])
.flex(Flex::Center)
.areas(area);
inner
}
fn render(
frame: &mut Frame,
state: &mut ListState,
show_help: bool,
update: Option<&str>,
) {
let menu_height = TOOLS.len() as u16 * ITEM_HEIGHT + 2 + 1 ;
let help_height = if show_help {
(TOOLS.len() as u16 + 1) + 4
} else {
0
};
let area = frame.area().inner(Margin::new(2, 1));
let [logo_area, tagline_area, notice_area, menu_area, _gap2, help_area, _spacer, footer_area] =
Layout::vertical([
Constraint::Length(4), Constraint::Length(1), Constraint::Length(2), Constraint::Length(menu_height), Constraint::Length(1), Constraint::Length(help_height), Constraint::Min(0), Constraint::Length(1), ])
.flex(Flex::Center)
.areas(area);
render_logo(frame, logo_area);
render_tagline(frame, tagline_area);
render_update_notice(frame, notice_area, update);
render_menu(frame, state, center(menu_area, PANEL_WIDTH));
if show_help {
render_help(frame, center(help_area, PANEL_WIDTH));
}
render_footer(frame, footer_area);
}
fn render_logo(frame: &mut Frame, area: Rect) {
let logo = BigText::builder()
.pixel_size(PixelSize::Quadrant)
.style(
Style::default()
.fg(theme::accent())
.add_modifier(Modifier::BOLD),
)
.lines(vec!["vkit".into()])
.build();
frame.render_widget(logo, center(area, 16));
}
fn render_tagline(frame: &mut Frame, area: Rect) {
let muted = Style::default().fg(theme::muted());
let line = Line::from(vec![
Span::styled("CLI Toolkit for FE Developer", muted),
Span::styled(" · ", muted),
Span::styled(format!("v{}", env!("CARGO_PKG_VERSION")), muted),
]);
frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), area);
}
fn render_update_notice(frame: &mut Frame, area: Rect, update: Option<&str>) {
let Some(latest) = update else {
return;
};
let line = Line::from(vec![
Span::styled(
format!("↑ 新版本 v{latest} 可用"),
Style::default()
.fg(theme::accent())
.add_modifier(Modifier::BOLD),
),
Span::styled(" · ", Style::default().fg(theme::muted())),
Span::styled("运行 vkit update 升级", Style::default().fg(theme::muted())),
]);
frame.render_widget(Paragraph::new(line).alignment(Alignment::Center), area);
}
fn render_menu(frame: &mut Frame, state: &mut ListState, area: Rect) {
let selected = state.selected();
let inner = area.width.saturating_sub(4) as usize;
let desc_budget = inner.saturating_sub(5).max(1);
let items: Vec<ListItem> = TOOLS
.iter()
.enumerate()
.map(|(index, tool)| menu_item(index, tool, selected == Some(index), desc_budget))
.collect();
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::muted()))
.title(Span::styled(
" Tools ",
Style::default()
.fg(theme::accent())
.add_modifier(Modifier::BOLD),
))
.padding(Padding::new(1, 1, 1, 0));
let list = List::new(items).block(block);
frame.render_stateful_widget(list, area, state);
}
fn menu_item(index: usize, tool: &Tool, selected: bool, desc_budget: usize) -> ListItem<'static> {
let arrow = if selected {
Span::styled(LIST_CURSOR, list_cursor_style())
} else {
Span::raw(" ")
};
let number = Span::styled(
format!("{} ", index + 1),
Style::default().fg(theme::muted()),
);
let name_style = if selected {
list_cursor_style()
} else {
item_title_style(false)
};
let mut title = vec![arrow, number, Span::styled(tool.name, name_style)];
if !tool.available {
title.push(Span::styled(
" (coming soon)",
Style::default().fg(theme::muted()),
));
}
let description = truncate(tool.description, desc_budget);
let desc = Line::from(vec![
Span::raw(" "),
Span::styled(description, item_desc_style()),
]);
ListItem::new(vec![Line::from(title), desc, Line::raw("")])
}
fn render_help(frame: &mut Frame, area: Rect) {
let base = env!("CARGO_PKG_NAME");
let mut rows: Vec<(String, &str)> = vec![(base.to_string(), "进入此入口菜单")];
for tool in TOOLS {
rows.push((format!("{base} {}", tool.id), tool.name));
}
let cmd_width = rows
.iter()
.map(|(cmd, _)| cmd.chars().count())
.max()
.unwrap_or(0);
let lines: Vec<Line> = rows
.iter()
.map(|(cmd, desc)| {
Line::from(vec![
Span::styled(
format!("{cmd:<width$}", width = cmd_width),
Style::default().fg(theme::accent()),
),
Span::raw(" "),
Span::styled(desc.to_string(), Style::default().fg(theme::muted())),
])
})
.collect();
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::muted()))
.title(Span::styled(
" 子命令 ",
Style::default()
.fg(theme::accent())
.add_modifier(Modifier::BOLD),
))
.padding(Padding::uniform(1));
frame.render_widget(Paragraph::new(lines).block(block), area);
}
fn render_footer(frame: &mut Frame, area: Rect) {
frame.render_widget(
Paragraph::new(key_hint_line(HINTS)).alignment(Alignment::Center),
area,
);
}
fn truncate(text: &str, max: usize) -> String {
crate::ports::lsof::truncate_text(text, max)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn render_to_string(width: u16, height: u16, show_help: bool) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
let mut state = ListState::default();
state.select(Some(0));
terminal
.draw(|frame| render(frame, &mut state, show_help, None))
.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 renders_menu_and_chrome() {
let out = render_to_string(72, 24, false);
assert!(out.contains("CLI Toolkit for FE Developer"));
assert!(out.contains("Tools"));
assert!(out.contains("macOS")); assert!(out.contains("scripts")); assert!(out.contains('❯'));
}
#[test]
fn help_popup_lists_subcommands() {
let out = render_to_string(72, 24, true);
assert!(out.contains("vkit port"));
assert!(out.contains("vkit run"));
assert!(out.contains("vkit wt"));
}
#[test]
fn tagline_shows_update_notice() {
let mut terminal = Terminal::new(TestBackend::new(72, 24)).unwrap();
let mut state = ListState::default();
state.select(Some(0));
terminal
.draw(|frame| render(frame, &mut state, false, Some("9.9.9")))
.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());
}
}
assert!(out.contains("9.9.9"));
assert!(out.contains("vkit update"));
}
}