use anyhow::{bail, Context, Result};
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
Frame, Terminal,
};
use std::io;
use std::path::PathBuf;
use std::process::Command;
use crate::config::Config;
use crate::templates::Template;
struct RawModeGuard;
impl Drop for RawModeGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = crossterm::execute!(io::stdout(), LeaveAlternateScreen);
}
}
const CYAN: Color = Color::Cyan;
const DIM: Color = Color::DarkGray;
const GREEN: Color = Color::Green;
const WHITE: Color = Color::White;
const YELLOW: Color = Color::Yellow;
const RED: Color = Color::Red;
#[derive(Clone)]
struct ActionDef {
name: &'static str,
description: &'static str,
kind: ActionKind,
}
#[derive(Clone)]
enum ActionKind {
Direct(Vec<&'static str>),
WithInput {
fields: Vec<FieldDef>,
action_id: ActionId,
},
TemplateBrowser,
}
#[derive(Clone, Copy)]
enum ActionId {
WorkStart,
WorkPr,
SpecNew,
RunTask,
RunLane,
SearchTemplates,
CreateTemplate,
PublishTemplate,
ConfigGet,
ConfigSet,
AskQuestion,
IssueView,
PrView,
PluginInstall,
PluginRemove,
PluginSearch,
PluginRun,
}
#[derive(Clone)]
struct FieldDef {
label: &'static str,
default: &'static str,
required: bool,
}
struct CategoryDef {
name: &'static str,
icon: &'static str,
description: &'static str,
actions: Vec<ActionDef>,
}
fn build_categories() -> Vec<CategoryDef> {
vec![
CategoryDef {
name: "Work",
icon: "🔀",
description: "Branch workflow",
actions: vec![
ActionDef {
name: "Start Branch",
description: "Create a new work branch",
kind: ActionKind::WithInput {
fields: vec![
FieldDef {
label: "Branch name",
default: "",
required: true,
},
FieldDef {
label: "Type (feat/fix/chore/docs/hotfix/refactor)",
default: "feat",
required: false,
},
FieldDef {
label: "Issue number",
default: "",
required: false,
},
FieldDef {
label: "Base branch",
default: "main",
required: false,
},
FieldDef {
label: "Prefix (overrides format, e.g. user/leif)",
default: "",
required: false,
},
],
action_id: ActionId::WorkStart,
},
},
ActionDef {
name: "Create PR",
description: "Open a pull request from current branch",
kind: ActionKind::WithInput {
fields: vec![
FieldDef {
label: "Title (auto if empty)",
default: "",
required: false,
},
FieldDef {
label: "Body",
default: "",
required: false,
},
],
action_id: ActionId::WorkPr,
},
},
ActionDef {
name: "Status",
description: "Show current branch and PR status",
kind: ActionKind::Direct(vec!["work", "status"]),
},
],
},
CategoryDef {
name: "GitHub",
icon: "🐙",
description: "Issues, PRs, CI checks",
actions: vec![
ActionDef {
name: "List Issues",
description: "Show open GitHub issues",
kind: ActionKind::Direct(vec!["issues"]),
},
ActionDef {
name: "View Issue",
description: "View a specific issue by number",
kind: ActionKind::WithInput {
fields: vec![FieldDef {
label: "Issue number",
default: "",
required: true,
}],
action_id: ActionId::IssueView,
},
},
ActionDef {
name: "List PRs",
description: "Show open pull requests",
kind: ActionKind::Direct(vec!["prs"]),
},
ActionDef {
name: "View PR",
description: "View a specific PR by number",
kind: ActionKind::WithInput {
fields: vec![FieldDef {
label: "PR number",
default: "",
required: true,
}],
action_id: ActionId::PrView,
},
},
ActionDef {
name: "CI Checks",
description: "View CI/CD status for current branch",
kind: ActionKind::Direct(vec!["checks"]),
},
],
},
CategoryDef {
name: "Run",
icon: "🚀",
description: "Tasks and lanes",
actions: vec![
ActionDef {
name: "List Tasks",
description: "Show available tasks from fledge.toml",
kind: ActionKind::Direct(vec!["run", "--list"]),
},
ActionDef {
name: "Run Task",
description: "Execute a named task",
kind: ActionKind::WithInput {
fields: vec![FieldDef {
label: "Task name",
default: "",
required: true,
}],
action_id: ActionId::RunTask,
},
},
ActionDef {
name: "List Lanes",
description: "Show available workflow pipelines",
kind: ActionKind::Direct(vec!["lane", "--list"]),
},
ActionDef {
name: "Run Lane",
description: "Execute a workflow pipeline",
kind: ActionKind::WithInput {
fields: vec![FieldDef {
label: "Lane name",
default: "",
required: true,
}],
action_id: ActionId::RunLane,
},
},
ActionDef {
name: "Init Tasks",
description: "Create a starter fledge.toml",
kind: ActionKind::Direct(vec!["run", "--init"]),
},
],
},
CategoryDef {
name: "Specs",
icon: "📋",
description: "Spec-sync management",
actions: vec![
ActionDef {
name: "Check",
description: "Validate specs against source code",
kind: ActionKind::Direct(vec!["spec", "check"]),
},
ActionDef {
name: "Init",
description: "Initialize spec-sync configuration",
kind: ActionKind::Direct(vec!["spec", "init"]),
},
ActionDef {
name: "New Module",
description: "Scaffold a new spec module",
kind: ActionKind::WithInput {
fields: vec![FieldDef {
label: "Module name",
default: "",
required: true,
}],
action_id: ActionId::SpecNew,
},
},
],
},
CategoryDef {
name: "Metrics",
icon: "📊",
description: "Code metrics and deps",
actions: vec![
ActionDef {
name: "Overview",
description: "Lines of code by language",
kind: ActionKind::Direct(vec!["metrics"]),
},
ActionDef {
name: "File Churn",
description: "Most-changed files from git history",
kind: ActionKind::Direct(vec!["metrics", "--churn"]),
},
ActionDef {
name: "Test Ratio",
description: "Test file detection and coverage ratio",
kind: ActionKind::Direct(vec!["metrics", "--tests"]),
},
ActionDef {
name: "Outdated Deps",
description: "Check for outdated dependencies",
kind: ActionKind::Direct(vec!["deps", "--outdated"]),
},
ActionDef {
name: "Security Audit",
description: "Run security audit on dependencies",
kind: ActionKind::Direct(vec!["deps", "--audit"]),
},
ActionDef {
name: "Licenses",
description: "Show dependency licenses",
kind: ActionKind::Direct(vec!["deps", "--licenses"]),
},
],
},
CategoryDef {
name: "Config",
icon: "⚙️",
description: "Settings management",
actions: vec![
ActionDef {
name: "List All",
description: "Show all config values",
kind: ActionKind::Direct(vec!["config", "list"]),
},
ActionDef {
name: "Get Value",
description: "Read a config key",
kind: ActionKind::WithInput {
fields: vec![FieldDef {
label: "Key (e.g. defaults.author)",
default: "",
required: true,
}],
action_id: ActionId::ConfigGet,
},
},
ActionDef {
name: "Set Value",
description: "Write a config key",
kind: ActionKind::WithInput {
fields: vec![
FieldDef {
label: "Key",
default: "",
required: true,
},
FieldDef {
label: "Value",
default: "",
required: true,
},
],
action_id: ActionId::ConfigSet,
},
},
ActionDef {
name: "Config Path",
description: "Show config file location",
kind: ActionKind::Direct(vec!["config", "path"]),
},
],
},
CategoryDef {
name: "Templates",
icon: "📦",
description: "Browse, search, create",
actions: vec![
ActionDef {
name: "Browse & Scaffold",
description: "Interactive template browser",
kind: ActionKind::TemplateBrowser,
},
ActionDef {
name: "List",
description: "Show available templates",
kind: ActionKind::Direct(vec!["list"]),
},
ActionDef {
name: "Search GitHub",
description: "Find templates on GitHub",
kind: ActionKind::WithInput {
fields: vec![FieldDef {
label: "Search query",
default: "",
required: false,
}],
action_id: ActionId::SearchTemplates,
},
},
ActionDef {
name: "Create Template",
description: "Scaffold a new fledge template",
kind: ActionKind::WithInput {
fields: vec![FieldDef {
label: "Template name",
default: "",
required: true,
}],
action_id: ActionId::CreateTemplate,
},
},
ActionDef {
name: "Publish",
description: "Publish a template to GitHub",
kind: ActionKind::WithInput {
fields: vec![
FieldDef {
label: "Template path",
default: ".",
required: false,
},
FieldDef {
label: "Organization (optional)",
default: "",
required: false,
},
],
action_id: ActionId::PublishTemplate,
},
},
ActionDef {
name: "Validate",
description: "Validate templates in current directory",
kind: ActionKind::Direct(vec!["validate-template"]),
},
ActionDef {
name: "Update Project",
description: "Re-apply source template to project",
kind: ActionKind::Direct(vec!["update", "--yes"]),
},
],
},
CategoryDef {
name: "AI",
icon: "🤖",
description: "Code review and Q&A",
actions: vec![
ActionDef {
name: "Code Review",
description: "AI-powered review of current changes",
kind: ActionKind::Direct(vec!["review"]),
},
ActionDef {
name: "Ask Question",
description: "Ask a question about your codebase",
kind: ActionKind::WithInput {
fields: vec![FieldDef {
label: "Question",
default: "",
required: true,
}],
action_id: ActionId::AskQuestion,
},
},
],
},
CategoryDef {
name: "Doctor",
icon: "🩺",
description: "Environment diagnostics",
actions: vec![ActionDef {
name: "Run Diagnostics",
description: "Check project environment health",
kind: ActionKind::Direct(vec!["doctor"]),
}],
},
CategoryDef {
name: "Changelog",
icon: "📝",
description: "Release history",
actions: vec![
ActionDef {
name: "Generate",
description: "Changelog from git tags and commits",
kind: ActionKind::Direct(vec!["changelog"]),
},
ActionDef {
name: "Unreleased",
description: "Changes since latest tag",
kind: ActionKind::Direct(vec!["changelog", "--unreleased"]),
},
],
},
CategoryDef {
name: "Plugins",
icon: "🔌",
description: "Community extensions",
actions: vec![
ActionDef {
name: "List Installed",
description: "Show installed plugins",
kind: ActionKind::Direct(vec!["plugin", "list"]),
},
ActionDef {
name: "Search",
description: "Find plugins on GitHub",
kind: ActionKind::WithInput {
fields: vec![FieldDef {
label: "Search query",
default: "",
required: false,
}],
action_id: ActionId::PluginSearch,
},
},
ActionDef {
name: "Install",
description: "Install a plugin from GitHub (owner/repo)",
kind: ActionKind::WithInput {
fields: vec![FieldDef {
label: "Source (owner/repo)",
default: "",
required: true,
}],
action_id: ActionId::PluginInstall,
},
},
ActionDef {
name: "Remove",
description: "Remove an installed plugin",
kind: ActionKind::WithInput {
fields: vec![FieldDef {
label: "Plugin name",
default: "",
required: true,
}],
action_id: ActionId::PluginRemove,
},
},
ActionDef {
name: "Run Command",
description: "Execute a plugin command",
kind: ActionKind::WithInput {
fields: vec![FieldDef {
label: "Command name",
default: "",
required: true,
}],
action_id: ActionId::PluginRun,
},
},
],
},
]
}
fn field(fields: &[String], idx: usize) -> &str {
fields.get(idx).map(|s| s.as_str()).unwrap_or("")
}
fn build_command(action_id: ActionId, fields: &[String]) -> Vec<String> {
match action_id {
ActionId::WorkStart => {
let name = field(fields, 0);
let mut args = vec!["work".into(), "start".into(), name.to_string()];
let typ = field(fields, 1);
let typ = if typ.is_empty() { "feat" } else { typ };
if typ != "feat" {
args.extend(["--type".into(), typ.to_string()]);
}
let issue = field(fields, 2);
if !issue.is_empty() {
args.extend(["--issue".into(), issue.to_string()]);
}
let base = field(fields, 3);
let base = if base.is_empty() { "main" } else { base };
if base != "main" {
args.extend(["--base".into(), base.to_string()]);
}
let prefix = field(fields, 4);
if !prefix.is_empty() {
args.extend(["--prefix".into(), prefix.to_string()]);
}
args
}
ActionId::WorkPr => {
let mut args = vec!["work".into(), "pr".into()];
let title = field(fields, 0);
if !title.is_empty() {
args.extend(["--title".into(), title.to_string()]);
}
let body = field(fields, 1);
if !body.is_empty() {
args.extend(["--body".into(), body.to_string()]);
}
args
}
ActionId::SpecNew => vec!["spec".into(), "new".into(), field(fields, 0).to_string()],
ActionId::RunTask => vec!["run".into(), field(fields, 0).to_string()],
ActionId::RunLane => vec!["lane".into(), field(fields, 0).to_string()],
ActionId::SearchTemplates => {
let mut args = vec!["search".into()];
let q = field(fields, 0);
if !q.is_empty() {
args.push(q.to_string());
}
args
}
ActionId::CreateTemplate => {
vec!["create-template".into(), field(fields, 0).to_string()]
}
ActionId::PublishTemplate => {
let mut args = vec!["publish".into()];
let path = field(fields, 0);
let path = if path.is_empty() { "." } else { path };
if path != "." {
args.push(path.to_string());
}
let org = field(fields, 1);
if !org.is_empty() {
args.extend(["--org".into(), org.to_string()]);
}
args
}
ActionId::ConfigGet => {
vec!["config".into(), "get".into(), field(fields, 0).to_string()]
}
ActionId::ConfigSet => {
vec![
"config".into(),
"set".into(),
field(fields, 0).to_string(),
field(fields, 1).to_string(),
]
}
ActionId::AskQuestion => {
let mut args = vec!["ask".into()];
args.extend(field(fields, 0).split_whitespace().map(String::from));
args
}
ActionId::IssueView => {
vec!["issues".into(), "view".into(), field(fields, 0).to_string()]
}
ActionId::PrView => {
vec!["prs".into(), "view".into(), field(fields, 0).to_string()]
}
ActionId::PluginInstall => {
vec![
"plugin".into(),
"install".into(),
field(fields, 0).to_string(),
]
}
ActionId::PluginRemove => {
vec![
"plugin".into(),
"remove".into(),
field(fields, 0).to_string(),
]
}
ActionId::PluginSearch => {
let mut args = vec!["plugin".into(), "search".into()];
let q = field(fields, 0);
if !q.is_empty() {
args.push(q.to_string());
}
args
}
ActionId::PluginRun => {
vec!["plugin".into(), "run".into(), field(fields, 0).to_string()]
}
}
}
enum DashFocus {
Categories,
Actions,
}
enum DashScreen {
Browse,
Input,
Output,
}
struct InputField {
label: String,
default: String,
value: String,
required: bool,
}
struct DashboardApp {
categories: Vec<CategoryDef>,
focus: DashFocus,
screen: DashScreen,
cat_state: ListState,
act_state: ListState,
selected_cat: usize,
selected_action: usize,
input_fields: Vec<InputField>,
active_field: usize,
current_action_id: Option<ActionId>,
output_lines: Vec<String>,
output_scroll: usize,
output_visible_height: usize,
error_message: Option<String>,
should_quit: bool,
}
impl DashboardApp {
fn new() -> Self {
let categories = build_categories();
let mut cat_state = ListState::default();
cat_state.select(Some(0));
let mut act_state = ListState::default();
act_state.select(Some(0));
Self {
categories,
focus: DashFocus::Categories,
screen: DashScreen::Browse,
cat_state,
act_state,
selected_cat: 0,
selected_action: 0,
input_fields: Vec::new(),
active_field: 0,
current_action_id: None,
output_lines: Vec::new(),
output_scroll: 0,
output_visible_height: 0,
error_message: None,
should_quit: false,
}
}
fn current_actions(&self) -> &[ActionDef] {
&self.categories[self.selected_cat].actions
}
fn execute_action(&mut self, action: &ActionDef) {
match &action.kind {
ActionKind::Direct(args) => {
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
self.run_command(&args);
}
ActionKind::WithInput { fields, action_id } => {
self.input_fields = fields
.iter()
.map(|f| InputField {
label: f.label.to_string(),
default: f.default.to_string(),
value: String::new(),
required: f.required,
})
.collect();
self.active_field = 0;
self.current_action_id = Some(*action_id);
self.screen = DashScreen::Input;
}
ActionKind::TemplateBrowser => {}
}
}
fn submit_input(&mut self) {
for field in &self.input_fields {
if field.required && field.value.is_empty() && field.default.is_empty() {
self.error_message = Some(format!("{} is required", field.label));
return;
}
}
let field_values: Vec<String> = self
.input_fields
.iter()
.map(|f| {
if f.value.is_empty() {
f.default.clone()
} else {
f.value.clone()
}
})
.collect();
if let Some(action_id) = self.current_action_id {
let args = build_command(action_id, &field_values);
self.run_command(&args);
}
}
fn run_command(&mut self, args: &[String]) {
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
self.output_lines = vec![format!("Error finding executable: {}", e)];
self.output_scroll = 0;
self.screen = DashScreen::Output;
return;
}
};
let result = Command::new(&exe).args(args).env("NO_COLOR", "1").output();
match result {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let mut lines: Vec<String> = Vec::new();
let cmd_display = format!("fledge {}", args.join(" "));
lines.push(format!("$ {}", cmd_display));
lines.push(String::new());
if !stdout.is_empty() {
for line in stdout.lines() {
lines.push(strip_ansi(line));
}
}
if !stderr.is_empty() {
if !stdout.is_empty() {
lines.push(String::new());
}
for line in stderr.lines() {
lines.push(strip_ansi(line));
}
}
if stdout.is_empty() && stderr.is_empty() {
lines.push("(no output)".into());
}
if !output.status.success() {
lines.push(String::new());
lines.push(format!("Exit code: {}", output.status.code().unwrap_or(-1)));
}
self.output_lines = lines;
}
Err(e) => {
self.output_lines = vec![format!("Failed to run command: {}", e)];
}
}
self.output_scroll = 0;
self.screen = DashScreen::Output;
}
}
fn strip_ansi(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next();
while let Some(&nc) = chars.peek() {
chars.next();
if nc.is_ascii_alphabetic() {
break;
}
}
}
} else {
result.push(c);
}
}
result
}
pub fn run(output_dir: PathBuf, no_git: bool) -> Result<()> {
let mut app = DashboardApp::new();
enable_raw_mode()?;
crossterm::execute!(io::stdout(), EnterAlternateScreen)?;
let _guard = RawModeGuard;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend)?;
dashboard_loop(&mut terminal, &mut app, output_dir, no_git)
}
fn dashboard_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut DashboardApp,
output_dir: PathBuf,
no_git: bool,
) -> Result<()> {
loop {
terminal.draw(|f| draw_dashboard(f, app))?;
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(());
}
app.error_message = None;
match app.screen {
DashScreen::Browse => {
if handle_browse(app, key.code) {
let action = app.current_actions()[app.selected_action].clone();
if matches!(action.kind, ActionKind::TemplateBrowser) {
disable_raw_mode()?;
crossterm::execute!(io::stdout(), LeaveAlternateScreen)?;
let tui_result = run_template_browser(output_dir.clone(), no_git);
enable_raw_mode()?;
crossterm::execute!(io::stdout(), EnterAlternateScreen)?;
*terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
if let Err(e) = tui_result {
app.error_message = Some(format!("{}", e));
}
} else {
app.execute_action(&action);
}
}
}
DashScreen::Input => handle_input(app, key.code),
DashScreen::Output => handle_output(app, key.code),
}
if app.should_quit {
return Ok(());
}
}
}
}
fn handle_browse(app: &mut DashboardApp, key: KeyCode) -> bool {
match app.focus {
DashFocus::Categories => match key {
KeyCode::Up | KeyCode::Char('k') => {
let total = app.categories.len();
let cur = app.selected_cat;
app.selected_cat = if cur == 0 { total - 1 } else { cur - 1 };
app.cat_state.select(Some(app.selected_cat));
app.selected_action = 0;
app.act_state.select(Some(0));
false
}
KeyCode::Down | KeyCode::Char('j') => {
let total = app.categories.len();
let cur = app.selected_cat;
app.selected_cat = if cur >= total - 1 { 0 } else { cur + 1 };
app.cat_state.select(Some(app.selected_cat));
app.selected_action = 0;
app.act_state.select(Some(0));
false
}
KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') | KeyCode::Tab => {
app.focus = DashFocus::Actions;
false
}
KeyCode::Char('q') | KeyCode::Esc => {
app.should_quit = true;
false
}
_ => false,
},
DashFocus::Actions => match key {
KeyCode::Up | KeyCode::Char('k') => {
let total = app.current_actions().len();
let cur = app.selected_action;
app.selected_action = if cur == 0 { total - 1 } else { cur - 1 };
app.act_state.select(Some(app.selected_action));
false
}
KeyCode::Down | KeyCode::Char('j') => {
let total = app.current_actions().len();
let cur = app.selected_action;
app.selected_action = if cur >= total - 1 { 0 } else { cur + 1 };
app.act_state.select(Some(app.selected_action));
false
}
KeyCode::Enter => true,
KeyCode::Left | KeyCode::Char('h') | KeyCode::Esc | KeyCode::BackTab => {
app.focus = DashFocus::Categories;
false
}
KeyCode::Char('q') => {
app.should_quit = true;
false
}
_ => false,
},
}
}
fn handle_input(app: &mut DashboardApp, key: KeyCode) {
match key {
KeyCode::Up => {
if app.active_field > 0 {
app.active_field -= 1;
}
}
KeyCode::Down => {
if app.active_field < app.input_fields.len() - 1 {
app.active_field += 1;
}
}
KeyCode::Tab => {
app.active_field = (app.active_field + 1) % app.input_fields.len();
}
KeyCode::BackTab => {
app.active_field = if app.active_field == 0 {
app.input_fields.len() - 1
} else {
app.active_field - 1
};
}
KeyCode::Char(c) => {
if let Some(field) = app.input_fields.get_mut(app.active_field) {
field.value.push(c);
}
}
KeyCode::Backspace => {
if let Some(field) = app.input_fields.get_mut(app.active_field) {
field.value.pop();
}
}
KeyCode::Enter => {
app.submit_input();
}
KeyCode::Esc => {
app.screen = DashScreen::Browse;
app.focus = DashFocus::Actions;
}
_ => {}
}
}
fn handle_output(app: &mut DashboardApp, key: KeyCode) {
match key {
KeyCode::Up | KeyCode::Char('k') => {
app.output_scroll = app.output_scroll.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
let max_scroll = app
.output_lines
.len()
.saturating_sub(app.output_visible_height);
if app.output_scroll < max_scroll {
app.output_scroll += 1;
}
}
KeyCode::PageUp => {
app.output_scroll = app.output_scroll.saturating_sub(20);
}
KeyCode::PageDown => {
let max_scroll = app
.output_lines
.len()
.saturating_sub(app.output_visible_height);
app.output_scroll = (app.output_scroll + 20).min(max_scroll);
}
KeyCode::Home | KeyCode::Char('g') => {
app.output_scroll = 0;
}
KeyCode::End | KeyCode::Char('G') => {
app.output_scroll = app
.output_lines
.len()
.saturating_sub(app.output_visible_height);
}
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Backspace => {
app.screen = DashScreen::Browse;
app.focus = DashFocus::Actions;
}
_ => {}
}
}
fn draw_dashboard(f: &mut Frame, app: &mut DashboardApp) {
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(f.area());
draw_dash_header(f, outer[0]);
match app.screen {
DashScreen::Browse => draw_browse_panels(f, app, outer[1]),
DashScreen::Input => draw_input_form(f, app, outer[1]),
DashScreen::Output => draw_output_panel(f, app, outer[1]),
}
draw_dash_footer(f, app, outer[2]);
if let Some(ref msg) = app.error_message {
draw_error_popup(f, msg);
}
}
fn draw_dash_header(f: &mut Frame, area: Rect) {
let title = Line::from(vec![
Span::styled(
" fledge ",
Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
),
Span::styled("— dev lifecycle dashboard", Style::default().fg(DIM)),
]);
let block = Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(DIM));
f.render_widget(Paragraph::new(title).block(block), area);
}
fn draw_dash_footer(f: &mut Frame, app: &DashboardApp, area: Rect) {
let hints = match app.screen {
DashScreen::Browse => match app.focus {
DashFocus::Categories => "↑↓ navigate ⏎/→ open category q quit",
DashFocus::Actions => "↑↓ navigate ⏎ run ←/Esc back q quit",
},
DashScreen::Input => "↑↓/Tab fields type to edit ⏎ submit Esc back",
DashScreen::Output => "↑↓ scroll PgUp/PgDn g/G top/bottom Esc back",
};
let footer = Paragraph::new(Line::from(Span::styled(hints, Style::default().fg(DIM)))).block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(DIM)),
);
f.render_widget(footer, area);
}
fn draw_browse_panels(f: &mut Frame, app: &mut DashboardApp, area: Rect) {
let panels = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(35), Constraint::Percentage(65)])
.split(area);
draw_category_list(f, app, panels[0]);
draw_action_list(f, app, panels[1]);
}
fn draw_category_list(f: &mut Frame, app: &mut DashboardApp, area: Rect) {
let is_focused = matches!(app.focus, DashFocus::Categories);
let border_color = if is_focused { CYAN } else { DIM };
let items: Vec<ListItem> = app
.categories
.iter()
.map(|cat| {
let line = Line::from(vec![
Span::styled(format!(" {} ", cat.icon), Style::default().fg(DIM)),
Span::styled(format!("{:<12}", cat.name), Style::default().fg(GREEN)),
Span::styled(cat.description, Style::default().fg(DIM)),
]);
ListItem::new(line)
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title(" Categories ")
.title_style(
Style::default()
.fg(if is_focused { CYAN } else { DIM })
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.highlight_style(Style::default().fg(CYAN).add_modifier(Modifier::BOLD))
.highlight_symbol("👉 ");
f.render_stateful_widget(list, area, &mut app.cat_state);
}
fn draw_action_list(f: &mut Frame, app: &mut DashboardApp, area: Rect) {
let is_focused = matches!(app.focus, DashFocus::Actions);
let border_color = if is_focused { CYAN } else { DIM };
let cat_name = app.categories[app.selected_cat].name;
let actions = app.current_actions();
let items: Vec<ListItem> = actions
.iter()
.map(|act| {
let tag = match &act.kind {
ActionKind::Direct(_) => Span::styled(" ▶️ ", Style::default().fg(GREEN)),
ActionKind::WithInput { .. } => Span::styled(" ✏️ ", Style::default().fg(YELLOW)),
ActionKind::TemplateBrowser => Span::styled(" 📂 ", Style::default().fg(YELLOW)),
};
let line = Line::from(vec![
tag,
Span::styled(format!("{:<20}", act.name), Style::default().fg(GREEN)),
Span::styled(act.description, Style::default().fg(DIM)),
]);
ListItem::new(line)
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title(format!(" {} ", cat_name))
.title_style(
Style::default()
.fg(if is_focused { CYAN } else { DIM })
.add_modifier(Modifier::BOLD),
)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.highlight_style(Style::default().fg(CYAN).add_modifier(Modifier::BOLD))
.highlight_symbol("👉 ");
f.render_stateful_widget(list, area, &mut app.act_state);
}
fn draw_input_form(f: &mut Frame, app: &DashboardApp, area: Rect) {
let cat_name = app.categories[app.selected_cat].name;
let act_name = app.current_actions()[app.selected_action].name;
let block = Block::default()
.title(format!(" {} — {} ", cat_name, act_name))
.title_style(Style::default().fg(CYAN).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_style(Style::default().fg(CYAN));
let inner = block.inner(area);
f.render_widget(block, area);
let field_height = 2u16;
let scroll_offset = if app.active_field as u16 * field_height > inner.height.saturating_sub(2) {
(app.active_field as u16 * field_height).saturating_sub(inner.height.saturating_sub(2))
} else {
0
};
for (i, field) in app.input_fields.iter().enumerate() {
let field_y = (i as u16 * field_height).saturating_sub(scroll_offset);
if field_y + field_height > inner.height || (i as u16 * field_height) < scroll_offset {
continue;
}
let actual_y = inner.y + field_y;
if actual_y >= inner.y + inner.height {
break;
}
let is_active = i == app.active_field;
let label_style = if is_active {
Style::default().fg(CYAN).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(WHITE)
};
let req_marker = if field.required { "*" } else { "" };
let label_area = Rect::new(inner.x + 1, actual_y, inner.width.saturating_sub(2), 1);
let label = Paragraph::new(Line::from(vec![
Span::styled(&field.label, label_style),
Span::styled(req_marker, Style::default().fg(RED)),
Span::styled(": ", label_style),
]));
f.render_widget(label, label_area);
let prefix_len = field.label.len() + req_marker.len() + 2;
let input_x = inner.x + 1 + prefix_len as u16;
let input_width = inner.width.saturating_sub(prefix_len as u16 + 2);
let input_area = Rect::new(input_x, actual_y, input_width, 1);
let display_val = if field.value.is_empty() && !field.default.is_empty() {
Span::styled(&field.default, Style::default().fg(DIM))
} else {
Span::styled(&field.value, Style::default().fg(WHITE))
};
let mut spans = vec![display_val];
if is_active {
spans.push(Span::styled("▎", Style::default().fg(CYAN)));
}
f.render_widget(Paragraph::new(Line::from(spans)), input_area);
}
}
fn draw_output_panel(f: &mut Frame, app: &mut DashboardApp, area: Rect) {
let visible_height = area.height.saturating_sub(2) as usize;
app.output_visible_height = visible_height;
let total = app.output_lines.len();
let scroll_info = if total > visible_height {
format!(
" {}/{} ",
app.output_scroll + 1,
total.saturating_sub(visible_height) + 1
)
} else {
String::new()
};
let lines: Vec<Line> = app
.output_lines
.iter()
.skip(app.output_scroll)
.take(visible_height)
.map(|line| {
if line.starts_with("$ ") {
Line::from(Span::styled(
line.as_str(),
Style::default().fg(YELLOW).add_modifier(Modifier::BOLD),
))
} else if line.starts_with("Exit code:") || line.starts_with("error:") {
Line::from(Span::styled(line.as_str(), Style::default().fg(RED)))
} else if line.starts_with("(no output)") {
Line::from(Span::styled(line.as_str(), Style::default().fg(DIM)))
} else {
Line::from(Span::styled(line.as_str(), Style::default().fg(WHITE)))
}
})
.collect();
let block = Block::default()
.title(" Output ")
.title_style(Style::default().fg(CYAN).add_modifier(Modifier::BOLD))
.title_bottom(Line::from(Span::styled(
scroll_info,
Style::default().fg(DIM),
)))
.borders(Borders::ALL)
.border_style(Style::default().fg(CYAN));
f.render_widget(Paragraph::new(lines).block(block), area);
}
fn draw_error_popup(f: &mut Frame, msg: &str) {
let area = f.area();
let popup_width = (msg.chars().count() as u16 + 6).min(area.width.saturating_sub(4));
let popup_height = 5;
let x = (area.width.saturating_sub(popup_width)) / 2;
let y = (area.height.saturating_sub(popup_height)) / 2;
let popup_area = Rect::new(x, y, popup_width, popup_height);
f.render_widget(Clear, popup_area);
let block = Block::default()
.title(" Error ")
.title_style(Style::default().fg(RED).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_style(Style::default().fg(RED));
let text = Paragraph::new(Line::from(Span::styled(msg, Style::default().fg(RED))))
.block(block)
.wrap(Wrap { trim: true });
f.render_widget(text, popup_area);
}
struct VariableField {
key: String,
label: String,
value: String,
default: String,
}
#[derive(PartialEq)]
enum TemplateScreen {
SelectTemplate,
InputVariables,
Confirm,
Done,
}
struct TemplateBrowserApp {
config: Config,
templates: Vec<Template>,
screen: TemplateScreen,
list_state: ListState,
selected_template: Option<usize>,
variables: Vec<VariableField>,
active_field: usize,
project_name: String,
output_dir: PathBuf,
no_git: bool,
created_files: Vec<PathBuf>,
error_message: Option<String>,
should_quit: bool,
}
impl TemplateBrowserApp {
fn new(config: Config, templates: Vec<Template>, output_dir: PathBuf, no_git: bool) -> Self {
let mut list_state = ListState::default();
if !templates.is_empty() {
list_state.select(Some(0));
}
Self {
config,
templates,
screen: TemplateScreen::SelectTemplate,
list_state,
selected_template: None,
variables: Vec::new(),
active_field: 0,
project_name: String::new(),
output_dir,
no_git,
created_files: Vec::new(),
error_message: None,
should_quit: false,
}
}
fn build_variable_fields(&mut self, template: &Template) {
let mut fields = Vec::new();
fields.push(VariableField {
key: "__project_name__".into(),
label: "Project name".into(),
value: String::new(),
default: String::new(),
});
let author_default = self.config.author_or_git().unwrap_or_default();
fields.push(VariableField {
key: "__author__".into(),
label: "Author".into(),
value: String::new(),
default: author_default,
});
let org_default = self
.config
.github_org()
.or_else(|| self.config.author_or_git())
.unwrap_or_default();
fields.push(VariableField {
key: "__github_org__".into(),
label: "GitHub org".into(),
value: String::new(),
default: org_default,
});
let mut prompt_keys: Vec<_> = template.manifest.prompts.keys().collect();
prompt_keys.sort();
for key in prompt_keys {
let prompt_def = &template.manifest.prompts[key];
fields.push(VariableField {
key: key.clone(),
label: prompt_def.message.clone(),
value: String::new(),
default: prompt_def.default.clone().unwrap_or_default(),
});
}
self.variables = fields;
self.active_field = 0;
}
fn effective_value(&self, field: &VariableField) -> String {
if field.value.is_empty() {
field.default.clone()
} else {
field.value.clone()
}
}
fn build_tera_context(&self) -> tera::Context {
let mut ctx = tera::Context::new();
for field in &self.variables {
let val = self.effective_value(field);
match field.key.as_str() {
"__project_name__" => {
ctx.insert("project_name", &val);
ctx.insert("project_name_snake", &crate::utils::to_snake_case(&val));
ctx.insert("project_name_pascal", &crate::utils::to_pascal_case(&val));
}
"__author__" => ctx.insert("author", &val),
"__github_org__" => ctx.insert("github_org", &val),
key => ctx.insert(key, &val),
}
}
let now = chrono::Local::now();
ctx.insert("year", &now.format("%Y").to_string());
ctx.insert("date", &now.format("%Y-%m-%d").to_string());
ctx.insert("license", &self.config.license());
ctx
}
fn scaffold(&mut self) -> Result<()> {
let tpl_idx = self
.selected_template
.ok_or_else(|| anyhow::anyhow!("no template selected"))?;
let template = &self.templates[tpl_idx];
let project_name = self.project_name.clone();
let target_dir = self.output_dir.join(&project_name);
if target_dir.exists() {
bail!("Directory '{}' already exists.", target_dir.display());
}
let ctx = self.build_tera_context();
std::fs::create_dir_all(&target_dir)
.with_context(|| format!("creating directory {}", target_dir.display()))?;
self.created_files = crate::templates::render_template(template, &target_dir, &ctx)?;
if !self.no_git {
crate::init::init_git_for_tui(&target_dir)?;
}
Ok(())
}
}
fn run_template_browser(output_dir: PathBuf, no_git: bool) -> Result<()> {
let config = Config::load().context("loading config")?;
let extra_paths = config.extra_template_paths();
let token = config.github_token();
let templates = crate::templates::discover_templates_with_repos(
&extra_paths,
config.template_repos(),
token.as_deref(),
)?;
if templates.is_empty() {
bail!("No templates found.");
}
let mut app = TemplateBrowserApp::new(config, templates, output_dir, no_git);
enable_raw_mode()?;
crossterm::execute!(io::stdout(), EnterAlternateScreen)?;
let _guard = RawModeGuard;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend)?;
template_browser_loop(&mut terminal, &mut app)
}
fn template_browser_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut TemplateBrowserApp,
) -> Result<()> {
loop {
terminal.draw(|f| draw_template_browser(f, app))?;
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
app.should_quit = true;
}
if app.should_quit {
return Ok(());
}
app.error_message = None;
match app.screen {
TemplateScreen::SelectTemplate => handle_select_template(app, key.code),
TemplateScreen::InputVariables => handle_input_variables(app, key.code),
TemplateScreen::Confirm => handle_confirm(app, key.code),
TemplateScreen::Done => {
if matches!(key.code, KeyCode::Enter | KeyCode::Char('q') | KeyCode::Esc) {
return Ok(());
}
}
}
}
if app.should_quit {
return Ok(());
}
}
}
fn handle_select_template(app: &mut TemplateBrowserApp, key: KeyCode) {
match key {
KeyCode::Up | KeyCode::Char('k') => {
let i = app.list_state.selected().unwrap_or(0);
let new = if i == 0 {
app.templates.len() - 1
} else {
i - 1
};
app.list_state.select(Some(new));
}
KeyCode::Down | KeyCode::Char('j') => {
let i = app.list_state.selected().unwrap_or(0);
let new = if i >= app.templates.len() - 1 {
0
} else {
i + 1
};
app.list_state.select(Some(new));
}
KeyCode::Enter => {
if let Some(idx) = app.list_state.selected() {
app.selected_template = Some(idx);
let tpl = &app.templates[idx];
let tpl_clone = clone_template_for_fields(tpl);
app.build_variable_fields(&tpl_clone);
app.screen = TemplateScreen::InputVariables;
}
}
KeyCode::Esc | KeyCode::Char('q') => {
app.should_quit = true;
}
_ => {}
}
}
fn clone_template_for_fields(tpl: &Template) -> Template {
use crate::templates::*;
use std::collections::HashMap;
let mut prompts = HashMap::new();
for (k, v) in &tpl.manifest.prompts {
prompts.insert(
k.clone(),
PromptDef {
message: v.message.clone(),
default: v.default.clone(),
},
);
}
Template {
name: tpl.name.clone(),
description: tpl.description.clone(),
path: tpl.path.clone(),
manifest: TemplateManifest {
template: TemplateInfo {
name: tpl.manifest.template.name.clone(),
description: tpl.manifest.template.description.clone(),
min_fledge_version: tpl.manifest.template.min_fledge_version.clone(),
version: tpl.manifest.template.version.clone(),
requires: tpl.manifest.template.requires.clone(),
},
prompts,
files: FileRules {
render: tpl.manifest.files.render.clone(),
copy: tpl.manifest.files.copy.clone(),
ignore: tpl.manifest.files.ignore.clone(),
},
hooks: tpl.manifest.hooks.clone(),
},
}
}
fn handle_input_variables(app: &mut TemplateBrowserApp, key: KeyCode) {
match key {
KeyCode::Up => {
if app.active_field > 0 {
app.active_field -= 1;
}
}
KeyCode::Down => {
if app.active_field < app.variables.len() - 1 {
app.active_field += 1;
}
}
KeyCode::Tab => {
app.active_field = (app.active_field + 1) % app.variables.len();
}
KeyCode::BackTab => {
app.active_field = if app.active_field == 0 {
app.variables.len() - 1
} else {
app.active_field - 1
};
}
KeyCode::Char(c) => {
if let Some(field) = app.variables.get_mut(app.active_field) {
field.value.push(c);
}
}
KeyCode::Backspace => {
if let Some(field) = app.variables.get_mut(app.active_field) {
field.value.pop();
}
}
KeyCode::Enter => {
let project_name = app.effective_value(&app.variables[0]);
if project_name.is_empty() {
app.error_message = Some("Project name is required.".into());
app.active_field = 0;
return;
}
app.project_name = project_name;
app.screen = TemplateScreen::Confirm;
}
KeyCode::Esc => {
app.screen = TemplateScreen::SelectTemplate;
}
_ => {}
}
}
fn handle_confirm(app: &mut TemplateBrowserApp, key: KeyCode) {
match key {
KeyCode::Enter | KeyCode::Char('y') => match app.scaffold() {
Ok(()) => {
app.screen = TemplateScreen::Done;
}
Err(e) => {
app.error_message = Some(format!("Error: {}", e));
}
},
KeyCode::Esc | KeyCode::Char('n') => {
app.screen = TemplateScreen::InputVariables;
}
KeyCode::Char('q') => {
app.should_quit = true;
}
_ => {}
}
}
fn draw_template_browser(f: &mut Frame, app: &mut TemplateBrowserApp) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(f.area());
draw_tb_header(f, chunks[0]);
match app.screen {
TemplateScreen::SelectTemplate => draw_template_list(f, app, chunks[1]),
TemplateScreen::InputVariables => draw_variable_form(f, app, chunks[1]),
TemplateScreen::Confirm => draw_tb_confirm(f, app, chunks[1]),
TemplateScreen::Done => draw_tb_done(f, app, chunks[1]),
}
draw_tb_footer(f, app, chunks[2]);
if let Some(ref msg) = app.error_message {
draw_error_popup(f, msg);
}
}
fn draw_tb_header(f: &mut Frame, area: Rect) {
let title = Line::from(vec![
Span::styled(
" fledge ",
Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
),
Span::styled("— template browser", Style::default().fg(DIM)),
]);
let block = Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(DIM));
f.render_widget(Paragraph::new(title).block(block), area);
}
fn draw_tb_footer(f: &mut Frame, app: &TemplateBrowserApp, area: Rect) {
let hints = match app.screen {
TemplateScreen::SelectTemplate => "↑↓ navigate ⏎ select q quit",
TemplateScreen::InputVariables => "↑↓/Tab navigate type to edit ⏎ continue Esc back",
TemplateScreen::Confirm => "⏎/y scaffold Esc back q quit",
TemplateScreen::Done => "⏎/q exit",
};
let footer = Paragraph::new(Line::from(Span::styled(hints, Style::default().fg(DIM)))).block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(DIM)),
);
f.render_widget(footer, area);
}
fn draw_template_list(f: &mut Frame, app: &mut TemplateBrowserApp, area: Rect) {
let items: Vec<ListItem> = app
.templates
.iter()
.map(|t| {
let line = Line::from(vec![
Span::styled(format!("{:<16}", t.name), Style::default().fg(GREEN)),
Span::styled(&t.description, Style::default().fg(DIM)),
]);
ListItem::new(line)
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title(" Select a template ")
.title_style(Style::default().fg(CYAN).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_style(Style::default().fg(DIM)),
)
.highlight_style(Style::default().fg(CYAN).add_modifier(Modifier::BOLD))
.highlight_symbol("👉 ");
f.render_stateful_widget(list, area, &mut app.list_state);
}
fn draw_variable_form(f: &mut Frame, app: &TemplateBrowserApp, area: Rect) {
let Some(tpl_idx) = app.selected_template else {
return;
};
let tpl_name = &app.templates[tpl_idx].name;
let block = Block::default()
.title(format!(" {} — configure ", tpl_name))
.title_style(Style::default().fg(CYAN).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_style(Style::default().fg(DIM));
let inner = block.inner(area);
f.render_widget(block, area);
let field_height = 2u16;
let scroll_offset = if app.active_field as u16 * field_height > inner.height.saturating_sub(2) {
(app.active_field as u16 * field_height).saturating_sub(inner.height.saturating_sub(2))
} else {
0
};
for (i, field) in app.variables.iter().enumerate() {
let field_y = (i as u16 * field_height).saturating_sub(scroll_offset);
if field_y + field_height > inner.height || (i as u16 * field_height) < scroll_offset {
continue;
}
let actual_y = inner.y + field_y;
if actual_y >= inner.y + inner.height {
break;
}
let is_active = i == app.active_field;
let label_style = if is_active {
Style::default().fg(CYAN).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(WHITE)
};
let label_area = Rect::new(inner.x + 1, actual_y, inner.width.saturating_sub(2), 1);
let label = Paragraph::new(Line::from(vec![
Span::styled(&field.label, label_style),
Span::styled(": ", label_style),
]));
f.render_widget(label, label_area);
let input_x = inner.x + 1 + field.label.len() as u16 + 2;
let input_width = inner.width.saturating_sub(field.label.len() as u16 + 4);
let input_area = Rect::new(input_x, actual_y, input_width, 1);
let display_val = if field.value.is_empty() && !field.default.is_empty() {
Span::styled(&field.default, Style::default().fg(DIM))
} else {
Span::styled(&field.value, Style::default().fg(WHITE))
};
let mut spans = vec![display_val];
if is_active {
spans.push(Span::styled("▎", Style::default().fg(CYAN)));
}
let input = Paragraph::new(Line::from(spans));
f.render_widget(input, input_area);
}
}
fn draw_tb_confirm(f: &mut Frame, app: &TemplateBrowserApp, area: Rect) {
let Some(tpl_idx) = app.selected_template else {
return;
};
let tpl = &app.templates[tpl_idx];
let mut lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(" Template: ", Style::default().fg(DIM)),
Span::styled(
&tpl.name,
Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" Project: ", Style::default().fg(DIM)),
Span::styled(
&app.project_name,
Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" Location: ", Style::default().fg(DIM)),
Span::styled(
app.output_dir.join(&app.project_name).display().to_string(),
Style::default().fg(WHITE),
),
]),
Line::from(vec![
Span::styled(" Git init: ", Style::default().fg(DIM)),
Span::styled(
if app.no_git { "no" } else { "yes" },
Style::default().fg(WHITE),
),
]),
Line::from(""),
];
for field in &app.variables {
if field.key.starts_with("__") {
continue;
}
lines.push(Line::from(vec![
Span::styled(format!(" {}: ", field.label), Style::default().fg(DIM)),
Span::styled(app.effective_value(field), Style::default().fg(WHITE)),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" Press ", Style::default().fg(DIM)),
Span::styled(
"Enter",
Style::default().fg(YELLOW).add_modifier(Modifier::BOLD),
),
Span::styled(" to scaffold, ", Style::default().fg(DIM)),
Span::styled(
"Esc",
Style::default().fg(YELLOW).add_modifier(Modifier::BOLD),
),
Span::styled(" to go back", Style::default().fg(DIM)),
]));
let block = Block::default()
.title(" Confirm ")
.title_style(Style::default().fg(CYAN).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_style(Style::default().fg(DIM));
f.render_widget(Paragraph::new(lines).block(block), area);
}
fn draw_tb_done(f: &mut Frame, app: &TemplateBrowserApp, area: Rect) {
let mut lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(
" ✅ ",
Style::default().fg(GREEN).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("Created {} ", app.project_name),
Style::default().fg(WHITE).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("in {}", app.output_dir.join(&app.project_name).display()),
Style::default().fg(DIM),
),
]),
Line::from(""),
];
let max_files = (area.height as usize)
.saturating_sub(8)
.min(app.created_files.len());
for file in app.created_files.iter().take(max_files) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(file.display().to_string(), Style::default().fg(DIM)),
]));
}
if app.created_files.len() > max_files {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("... and {} more", app.created_files.len() - max_files),
Style::default().fg(DIM),
),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(
format!("{} files created", app.created_files.len()),
Style::default().fg(WHITE),
),
]));
if !app.no_git {
lines.push(Line::from(vec![Span::styled(
" git repo initialized with initial commit",
Style::default().fg(DIM),
)]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" cd ", Style::default().fg(DIM)),
Span::styled(&app.project_name, Style::default().fg(CYAN)),
Span::styled(" && get started!", Style::default().fg(DIM)),
]));
let block = Block::default()
.title(" Done ")
.title_style(Style::default().fg(GREEN).add_modifier(Modifier::BOLD))
.borders(Borders::ALL)
.border_style(Style::default().fg(DIM));
f.render_widget(Paragraph::new(lines).block(block), area);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_ansi_plain() {
assert_eq!(strip_ansi("hello"), "hello");
}
#[test]
fn test_strip_ansi_color_code() {
assert_eq!(strip_ansi("\x1b[32mgreen\x1b[0m"), "green");
}
#[test]
fn test_strip_ansi_bold() {
assert_eq!(strip_ansi("\x1b[1mbold\x1b[0m"), "bold");
}
#[test]
fn test_strip_ansi_empty() {
assert_eq!(strip_ansi(""), "");
}
#[test]
fn test_strip_ansi_mixed() {
assert_eq!(strip_ansi("ok \x1b[31merror\x1b[0m done"), "ok error done");
}
#[test]
fn test_build_command_work_start_defaults() {
let args = build_command(
ActionId::WorkStart,
&[
"my-feature".into(),
"".into(),
"".into(),
"".into(),
"".into(),
],
);
assert_eq!(args, vec!["work", "start", "my-feature"]);
}
#[test]
fn test_build_command_work_start_fix_type() {
let args = build_command(
ActionId::WorkStart,
&[
"crash".into(),
"fix".into(),
"".into(),
"".into(),
"".into(),
],
);
assert!(args.contains(&"--type".to_string()));
assert!(args.contains(&"fix".to_string()));
}
#[test]
fn test_build_command_work_start_with_issue() {
let args = build_command(
ActionId::WorkStart,
&[
"bug".into(),
"fix".into(),
"42".into(),
"".into(),
"".into(),
],
);
assert!(args.contains(&"--issue".to_string()));
assert!(args.contains(&"42".to_string()));
}
#[test]
fn test_build_command_work_start_with_prefix() {
let args = build_command(
ActionId::WorkStart,
&[
"feat".into(),
"".into(),
"".into(),
"".into(),
"user/leif".into(),
],
);
assert!(args.contains(&"--prefix".to_string()));
assert!(args.contains(&"user/leif".to_string()));
}
#[test]
fn test_build_command_work_start_with_base() {
let args = build_command(
ActionId::WorkStart,
&[
"feat".into(),
"".into(),
"".into(),
"develop".into(),
"".into(),
],
);
assert!(args.contains(&"--base".to_string()));
assert!(args.contains(&"develop".to_string()));
}
#[test]
fn test_build_command_work_pr_empty() {
let args = build_command(ActionId::WorkPr, &["".into(), "".into()]);
assert_eq!(args, vec!["work", "pr"]);
}
#[test]
fn test_build_command_work_pr_with_title() {
let args = build_command(ActionId::WorkPr, &["Add feature".into(), "".into()]);
assert!(args.contains(&"--title".to_string()));
}
#[test]
fn test_build_command_spec_new() {
let args = build_command(ActionId::SpecNew, &["auth".into()]);
assert_eq!(args, vec!["spec", "new", "auth"]);
}
#[test]
fn test_build_command_run_task() {
let args = build_command(ActionId::RunTask, &["test".into()]);
assert_eq!(args, vec!["run", "test"]);
}
#[test]
fn test_build_command_run_lane() {
let args = build_command(ActionId::RunLane, &["ci".into()]);
assert_eq!(args, vec!["lane", "ci"]);
}
#[test]
fn test_build_command_search_templates_empty() {
let args = build_command(ActionId::SearchTemplates, &["".into()]);
assert_eq!(args, vec!["search"]);
}
#[test]
fn test_build_command_search_templates_query() {
let args = build_command(ActionId::SearchTemplates, &["rust".into()]);
assert_eq!(args, vec!["search", "rust"]);
}
#[test]
fn test_build_command_create_template() {
let args = build_command(ActionId::CreateTemplate, &["my-tpl".into()]);
assert_eq!(args, vec!["create-template", "my-tpl"]);
}
#[test]
fn test_build_command_publish_template_dot() {
let args = build_command(ActionId::PublishTemplate, &["".into(), "".into()]);
assert_eq!(args, vec!["publish"]);
}
#[test]
fn test_build_command_publish_template_path() {
let args = build_command(
ActionId::PublishTemplate,
&["./tmpl".into(), "myorg".into()],
);
assert!(args.contains(&"./tmpl".to_string()));
assert!(args.contains(&"--org".to_string()));
}
#[test]
fn test_build_command_config_get() {
let args = build_command(ActionId::ConfigGet, &["defaults.author".into()]);
assert_eq!(args, vec!["config", "get", "defaults.author"]);
}
#[test]
fn test_build_command_config_set() {
let args = build_command(
ActionId::ConfigSet,
&["defaults.author".into(), "leif".into()],
);
assert_eq!(args, vec!["config", "set", "defaults.author", "leif"]);
}
#[test]
fn test_build_command_ask_question() {
let args = build_command(ActionId::AskQuestion, &["how does auth work".into()]);
assert_eq!(args, vec!["ask", "how", "does", "auth", "work"]);
}
#[test]
fn test_build_command_issue_view() {
let args = build_command(ActionId::IssueView, &["42".into()]);
assert_eq!(args, vec!["issues", "view", "42"]);
}
#[test]
fn test_build_command_pr_view() {
let args = build_command(ActionId::PrView, &["7".into()]);
assert_eq!(args, vec!["prs", "view", "7"]);
}
#[test]
fn test_build_command_plugin_install() {
let args = build_command(ActionId::PluginInstall, &["owner/repo".into()]);
assert_eq!(args, vec!["plugin", "install", "owner/repo"]);
}
#[test]
fn test_build_command_plugin_remove() {
let args = build_command(ActionId::PluginRemove, &["myplugin".into()]);
assert_eq!(args, vec!["plugin", "remove", "myplugin"]);
}
#[test]
fn test_build_command_plugin_search_empty() {
let args = build_command(ActionId::PluginSearch, &["".into()]);
assert_eq!(args, vec!["plugin", "search"]);
}
#[test]
fn test_build_command_plugin_run() {
let args = build_command(ActionId::PluginRun, &["deploy".into()]);
assert_eq!(args, vec!["plugin", "run", "deploy"]);
}
#[test]
fn test_build_command_bounds_empty_fields() {
let args = build_command(ActionId::WorkStart, &[]);
assert_eq!(args, vec!["work", "start", ""]);
let args = build_command(ActionId::RunTask, &[]);
assert_eq!(args, vec!["run", ""]);
}
}