vkit 0.1.1

Fast Rust dev CLI: manage Node ports, run scripts, install & sync VS Code / Cursor extensions.
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 tui_big_text::{BigText, PixelSize};

use crate::ports;
use crate::run;
use crate::sync;
use crate::theme;
use crate::vsix;
use crate::widgets::{
    item_desc_style, item_title_style, key_hint_line, list_cursor_style, wrapping_index,
    LIST_CURSOR,
};

/// 面板内容宽度(logo 除外),用于居中对齐。
const PANEL_WIDTH: u16 = 64;
/// 每个菜单项占的行数:标题 + 描述 + 间隔。
const ITEM_HEIGHT: u16 = 3;

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: "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 status: Option<String> = None;
    let mut show_help = false;

    loop {
        terminal.draw(|frame| render(frame, &mut state, status.as_deref(), show_help))?;

        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));
                    status = launch_tool(terminal, index);
                }
            }
            KeyCode::Enter => {
                status = launch_tool(terminal, state.selected().unwrap_or(0));
            }
            _ => {}
        }
    }
}

/// 进入某个工具(复用同一终端会话);返回需要展示的状态信息(错误 / 提示)。
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),
        "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, status: Option<&str>, show_help: bool) {
    let menu_height = TOOLS.len() as u16 * ITEM_HEIGHT + 2 /* 边框 */ + 1 /* 顶部内边距 */;
    // 子命令列表内联在菜单下方(非悬浮):base + 每个工具一行 + 边框 2 + 内边距 2。
    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, _gap, menu_area, _gap2, help_area, _spacer, footer_area] =
        Layout::vertical([
            Constraint::Length(4),           // logo
            Constraint::Length(1),           // tagline
            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_menu(frame, state, center(menu_area, PANEL_WIDTH));
    if show_help {
        render_help(frame, center(help_area, PANEL_WIDTH));
    }
    // 提示行用整行宽度居中,避免 keycap 变长后被 PANEL_WIDTH 截断。
    render_footer(frame, footer_area, status);
}

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();
    // Quadrant 下每个字形约 4 列宽;"vkit" 4 字 ≈ 16 列。
    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("fast dev CLI", 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_menu(frame: &mut Frame, state: &mut ListState, area: Rect) {
    let selected = state.selected();
    // 内容可用宽度:边框 2 + 左右内边距 2;名称前有「竖条 2 + 编号 3」= 5 列缩进。
    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));

    // 无底色:光标行以 ❯ 指示 + 高亮色文字(竖条/名称在 item 内自绘)。
    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()),
    );
    // 未选中标题弱化为 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");
    // 每行:`vkit <cmd>` (accent)+ 说明(muted)。
    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, status: Option<&str>) {
    if let Some(status) = status {
        let widget = Paragraph::new(status.to_string())
            .style(Style::default().fg(theme::warning()))
            .alignment(Alignment::Center);
        frame.render_widget(widget, area);
        return;
    }

    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, None, show_help))
            .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
    }

    // 注:TestBackend 里 CJK 宽字符会被拆到相邻单元,无法用整词子串断言,
    // 故这里只断言 ASCII 片段(macOS / scripts / 命令名)。
    #[test]
    fn renders_menu_and_chrome() {
        let out = render_to_string(72, 24, false);
        assert!(out.contains("fast dev CLI"));
        assert!(out.contains("Tools"));
        assert!(out.contains("macOS")); // port 描述里的 ASCII
        assert!(out.contains("scripts")); // run 描述里的 ASCII
                                          // 光标行箭头。
        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"));
    }
}