mod pathpick;
mod ui;
use crate::backends::settings_defs;
use crate::brew::Brew;
use crate::config::{load_config, save_config, Config};
use crate::daemon::{self, Status};
use crate::doctor::{self, Health};
use crate::logs;
use crate::ops;
use crate::php;
use crate::ssl;
use crate::state::{load_state, Backend, Framework, State, XdebugMode};
use anyhow::Result;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
MouseButton, MouseEvent, MouseEventKind,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, layout::Rect, Terminal};
use std::cell::RefCell;
use std::io::{self, Stdout, Write as _};
use std::time::Duration;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Panel {
Servers,
Php,
Vhosts,
Parked,
Services,
}
#[derive(Clone, Copy)]
pub struct PanelHit {
pub panel: Panel,
pub rect: Rect,
pub offset: usize,
pub len: usize,
}
pub struct HyperLink {
pub x: u16,
pub y: u16,
pub text: String,
pub url: String,
pub reversed: bool,
pub bold: bool,
}
pub struct App {
pub config: Config,
pub state: State,
pub focus: Panel,
pub sel_server: usize,
pub sel_php: usize,
pub sel_vhost: usize,
pub vhost_sort_col: usize,
pub vhost_sort_asc: bool,
pub sel_service: usize,
pub sel_parked: usize,
pub hit_rects: RefCell<Vec<PanelHit>>,
pub links: RefCell<Vec<HyperLink>>,
pub anonymize: bool,
pub anon_home: Option<String>,
pub server_status: Vec<crate::ops::ServeState>,
pub php_status: Vec<Status>,
pub service_status: Vec<Status>,
pub parked_vhosts: Vec<crate::state::Vhost>,
pub message: String,
pub wizard: Option<VhostWizard>,
pub confirm_remove: Option<String>,
pub server_wizard: Option<ServerWizard>,
pub settings_modal: Option<SettingsModal>,
pub php_install: Option<PhpInstallModal>,
pub confirm_remove_php: Option<String>,
pub pending_install: Option<String>,
pub dns_ok: bool,
pub confirm_remove_server: Option<String>,
pub ext_modal: Option<ExtModal>,
pub pending_ext: Option<PendingExt>,
pub config_modal: Option<ConfigModal>,
pub php_settings: Option<PhpSettingsModal>,
pub pending_xdebug: Option<(String, XdebugMode)>,
pub service_picker: Option<ServicePicker>,
pub park_modal: Option<ParkModal>,
pub pending_service: Option<crate::state::ServiceKind>,
pub pending_server: Option<String>,
pub log_modal: Option<LogModal>,
pub doctor_modal: Option<DoctorModal>,
pub health: Health,
force_clear: bool,
should_quit: bool,
}
pub struct LogModal {
pub label: String,
pub lines: Vec<String>,
pub scroll: usize,
}
pub struct DoctorModal {
pub lines: Vec<(Health, String)>,
}
pub const LOG_TAIL_LINES: usize = 500;
pub struct ConfigModal {
pub tld: String,
pub sites_root: String,
pub backend_idx: usize,
pub field: usize,
pub error: Option<String>,
pub orig_tld: String,
}
pub const CONFIG_FIELDS: usize = 3;
pub struct PhpInstallModal {
pub version: String,
pub error: Option<String>,
}
pub struct ExtModal {
pub version: String,
pub loaded: Vec<String>,
pub input: String,
pub sel: usize,
pub error: Option<String>,
}
pub enum ExtAction {
Add(String),
Remove(String),
}
pub struct PendingExt {
pub version: String,
pub action: ExtAction,
}
pub struct SettingsModal {
pub server_name: String,
pub backend: Backend,
pub values: Vec<String>,
pub field: usize,
pub error: Option<String>,
}
pub struct ServicePicker {
pub sel: usize,
}
pub const PARK_FIELDS: usize = 5;
pub struct ParkModal {
pub dir: String,
pub server_idx: usize,
pub php_idx: usize,
pub ssl: bool,
pub sel_park: usize,
pub field: usize,
pub error: Option<String>,
}
pub struct PhpSettingsModal {
pub version: String,
pub values: Vec<String>,
pub field: usize,
pub error: Option<String>,
}
pub const BACKENDS: [Backend; 4] = [
Backend::Caddy,
Backend::Apache,
Backend::Nginx,
Backend::Ols,
];
pub const SERVER_FIELDS: usize = 6;
pub struct ServerWizard {
pub backend_idx: usize,
pub http: String,
pub https: String,
pub default_site: bool,
pub preset_idx: usize,
pub default_root: String,
pub field: usize,
pub error: Option<String>,
pub editing: Option<String>,
pub installed: [bool; 4],
}
pub const WIZARD_FIELDS: usize = 7;
const FIELD_PROXY: usize = 6;
pub struct VhostWizard {
pub server_name: String,
pub docroot: String,
pub php_idx: usize,
pub server_idx: usize,
pub ssl: bool,
pub field: usize,
pub error: Option<String>,
pub editing: Option<String>,
pub dropdown_open: bool,
pub path_sel: Option<usize>,
pub preset: Framework,
pub proxy: String,
}
impl VhostWizard {
pub fn path_suggestions(&self) -> Vec<pathpick::PathEntry> {
pathpick::read_dir_filtered(&self.docroot, PATH_SUGGESTION_CAP)
}
fn dropdown_active(&self) -> bool {
self.field == FIELD_DOCROOT && self.dropdown_open
}
fn goto_field(&mut self, field: usize) {
self.field = field;
self.path_sel = None;
if field == FIELD_DOCROOT {
self.dropdown_open = true;
}
}
fn next_field(&mut self) {
self.goto_field((self.field + 1) % WIZARD_FIELDS);
}
fn prev_field(&mut self) {
self.goto_field((self.field + WIZARD_FIELDS - 1) % WIZARD_FIELDS);
}
}
pub const PATH_SUGGESTION_CAP: usize = 8;
impl App {
fn load() -> Result<Self> {
let mut app = App {
config: load_config().unwrap_or_default(),
state: State::default(),
focus: Panel::Servers,
sel_server: 0,
sel_php: 0,
sel_vhost: 0,
vhost_sort_col: 0,
vhost_sort_asc: true,
sel_service: 0,
sel_parked: 0,
hit_rects: RefCell::new(Vec::new()),
links: RefCell::new(Vec::new()),
anonymize: false,
anon_home: std::env::var("HOME").ok().filter(|h| !h.is_empty()),
server_status: Vec::new(),
php_status: Vec::new(),
service_status: Vec::new(),
parked_vhosts: Vec::new(),
message: "ready".into(),
wizard: None,
confirm_remove: None,
server_wizard: None,
settings_modal: None,
php_install: None,
confirm_remove_php: None,
pending_install: None,
dns_ok: false,
confirm_remove_server: None,
ext_modal: None,
pending_ext: None,
config_modal: None,
php_settings: None,
pending_xdebug: None,
service_picker: None,
park_modal: None,
pending_service: None,
pending_server: None,
log_modal: None,
doctor_modal: None,
health: Health::Ok,
force_clear: false,
should_quit: false,
};
app.refresh();
Ok(app)
}
fn refresh(&mut self) {
self.state = load_state().unwrap_or_default();
self.state.sort_php();
self.sort_vhosts();
self.config = load_config().unwrap_or_default();
self.server_status = self
.state
.servers
.iter()
.map(crate::ops::serve_state)
.collect();
self.php_status = self
.state
.php_versions
.iter()
.map(|p| daemon::status(&php::service_id(&p.version)))
.collect();
self.service_status = self
.state
.services
.iter()
.map(|s| daemon::status(&crate::services::service_id(s.kind)))
.collect();
self.parked_vhosts = crate::park::expand_all(&self.state);
self.dns_ok = crate::dns::resolver_ok_all(&self.config.local_tlds);
let brew = Brew::detect().ok();
self.health = doctor::summary(&doctor::run(brew.as_ref(), &self.config, &self.state));
self.clamp();
}
fn sort_vhosts(&mut self) {
let col = self.vhost_sort_col;
self.state.vhosts.sort_by(|a, b| {
let o = match col {
1 => a.server.cmp(&b.server),
2 => crate::state::version_key(&a.php_version)
.cmp(&crate::state::version_key(&b.php_version)),
3 => a.docroot.cmp(&b.docroot),
_ => a.server_name.cmp(&b.server_name),
}
.then_with(|| a.server_name.cmp(&b.server_name));
if self.vhost_sort_asc {
o
} else {
o.reverse()
}
});
}
fn clamp(&mut self) {
self.sel_server = self
.sel_server
.min(self.state.servers.len().saturating_sub(1));
self.sel_php = self
.sel_php
.min(self.state.php_versions.len().saturating_sub(1));
self.sel_vhost = self
.sel_vhost
.min(self.state.vhosts.len().saturating_sub(1));
self.sel_service = self
.sel_service
.min(self.state.services.len().saturating_sub(1));
self.sel_parked = self
.sel_parked
.min(self.parked_vhosts.len().saturating_sub(1));
}
fn focus_len(&self) -> usize {
self.panel_len(self.focus)
}
fn panel_len(&self, panel: Panel) -> usize {
match panel {
Panel::Servers => self.state.servers.len(),
Panel::Php => self.state.php_versions.len(),
Panel::Vhosts => self.state.vhosts.len(),
Panel::Parked => self.parked_vhosts.len(),
Panel::Services => self.state.services.len(),
}
}
fn sel_mut(&mut self) -> &mut usize {
self.sel_mut_of(self.focus)
}
fn sel_mut_of(&mut self, panel: Panel) -> &mut usize {
match panel {
Panel::Servers => &mut self.sel_server,
Panel::Php => &mut self.sel_php,
Panel::Vhosts => &mut self.sel_vhost,
Panel::Parked => &mut self.sel_parked,
Panel::Services => &mut self.sel_service,
}
}
fn move_sel(&mut self, delta: isize) {
let len = self.focus_len();
if len == 0 {
return;
}
let sel = self.sel_mut();
let new = (*sel as isize + delta).rem_euclid(len as isize) as usize;
*sel = new;
}
fn move_sel_clamped(&mut self, panel: Panel, delta: isize) {
let len = self.panel_len(panel);
if len == 0 {
return;
}
let sel = self.sel_mut_of(panel);
let new = (*sel as isize + delta).clamp(0, len as isize - 1) as usize;
*sel = new;
}
fn cycle_focus(&mut self, forward: bool) {
self.focus = match (self.focus, forward) {
(Panel::Servers, true) => Panel::Vhosts,
(Panel::Vhosts, true) => Panel::Parked,
(Panel::Parked, true) => Panel::Php,
(Panel::Php, true) => Panel::Services,
(Panel::Services, true) => Panel::Servers,
(Panel::Servers, false) => Panel::Services,
(Panel::Services, false) => Panel::Php,
(Panel::Php, false) => Panel::Parked,
(Panel::Parked, false) => Panel::Vhosts,
(Panel::Vhosts, false) => Panel::Servers,
};
}
fn handle_mouse(&mut self, ev: MouseEvent) {
if self.modal_open() {
return;
}
match ev.kind {
MouseEventKind::ScrollDown => self.scroll_at(ev.column, ev.row, 1),
MouseEventKind::ScrollUp => self.scroll_at(ev.column, ev.row, -1),
MouseEventKind::Down(MouseButton::Left) => self.click_at(ev.column, ev.row),
_ => {}
}
}
fn scroll_at(&mut self, col: u16, row: u16, delta: isize) {
if let Some(hit) = self.hit_at(col, row) {
self.focus = hit.panel;
self.move_sel_clamped(hit.panel, delta);
}
}
fn click_at(&mut self, col: u16, row: u16) {
let Some(hit) = self.hit_at(col, row) else {
return;
};
self.focus = hit.panel;
if hit.panel == Panel::Php || hit.len == 0 {
return;
}
let content_top = hit.rect.y + 1;
if row >= content_top {
let visible = (row - content_top) as usize;
let idx = hit.offset + visible;
if idx < hit.len {
*self.sel_mut_of(hit.panel) = idx;
}
}
}
fn hit_at(&self, col: u16, row: u16) -> Option<PanelHit> {
self.hit_rects.borrow().iter().copied().find(|h| {
col >= h.rect.x
&& col < h.rect.x + h.rect.width
&& row >= h.rect.y
&& row < h.rect.y + h.rect.height
})
}
fn modal_open(&self) -> bool {
self.wizard.is_some()
|| self.server_wizard.is_some()
|| self.settings_modal.is_some()
|| self.php_install.is_some()
|| self.ext_modal.is_some()
|| self.config_modal.is_some()
|| self.php_settings.is_some()
|| self.service_picker.is_some()
|| self.park_modal.is_some()
|| self.log_modal.is_some()
|| self.doctor_modal.is_some()
|| self.confirm_remove.is_some()
|| self.confirm_remove_php.is_some()
|| self.confirm_remove_server.is_some()
}
fn act(&mut self, label: &str, result: Result<String>) {
self.message = match result {
Ok(msg) => format!("✓ {msg}"),
Err(e) => format!("✗ {label}: {e}"),
};
self.refresh();
}
pub fn anon(&self, s: &str) -> String {
match (self.anonymize, &self.anon_home) {
(true, Some(home)) => s.replace(home.as_str(), "/Users/andy"),
(false, Some(home)) => {
if s == home {
"~".to_string()
} else {
s.replace(&format!("{home}/"), "~/")
}
}
_ => s.to_string(),
}
}
fn selected_server_name(&self) -> Option<String> {
self.state
.servers
.get(self.sel_server)
.map(|s| s.name.clone())
}
fn selected_php_version(&self) -> Option<String> {
self.state
.php_versions
.get(self.sel_php)
.map(|p| p.version.clone())
}
fn selected_service(&self) -> Option<crate::state::ServiceKind> {
self.state.services.get(self.sel_service).map(|s| s.kind)
}
fn selected_vhost_name(&self) -> Option<String> {
self.state
.vhosts
.get(self.sel_vhost)
.map(|v| v.server_name.clone())
}
}
pub fn snapshot(width: u16, height: u16, modal: &str) -> Result<String> {
let mut app = App::load()?;
match modal {
"wizard" => open_wizard_for_snapshot(&mut app),
"server" => open_edit_server(&mut app),
"newserver" => open_new_server(&mut app),
"settings" => open_settings(&mut app),
"php" => {
app.php_install = Some(PhpInstallModal {
version: "8.".into(),
error: None,
})
}
"ext" => open_ext_modal(&mut app),
"phpsettings" => open_php_settings(&mut app),
"service" => app.service_picker = Some(ServicePicker { sel: 0 }),
"park" => open_park_modal(&mut app),
"doctor" => open_doctor(&mut app),
"anon" => app.anonymize = true,
"config" => open_config_modal(&mut app),
"log" => {
app.log_modal = Some(LogModal {
label: "server-caddy".into(),
lines: vec![
"[caddy] serving on :80".into(),
"[caddy] tls handshake ok".into(),
],
scroll: 0,
})
}
_ => {}
}
let backend = ratatui::backend::TestBackend::new(width, height);
let mut terminal = Terminal::new(backend)?;
terminal.draw(|f| ui::render(f, &app))?;
let buf = terminal.backend().buffer();
let mut out = String::new();
for y in 0..height {
for x in 0..width {
if let Some(cell) = buf.cell((x, y)) {
out.push_str(cell.symbol());
}
}
out.push('\n');
}
Ok(out)
}
pub async fn run() -> Result<()> {
if load_config().is_err() {
println!("reeve is not configured. Run `reeve init` first.");
return Ok(());
}
let mut terminal = setup_terminal()?;
let mut app = App::load()?;
let res = run_loop(&mut terminal, &mut app);
restore_terminal(&mut terminal)?;
res
}
fn run_loop(terminal: &mut Terminal<CrosstermBackend<Stdout>>, app: &mut App) -> Result<()> {
loop {
if app.force_clear {
terminal.clear()?;
app.force_clear = false;
}
terminal.draw(|f| ui::render(f, app))?;
emit_hyperlinks(terminal, app)?;
if event::poll(Duration::from_millis(2000))? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
handle_key(app, key.code, key.modifiers);
}
Event::Mouse(ev) => app.handle_mouse(ev),
_ => {}
}
} else {
app.refresh();
}
if let Some(ver) = app.pending_install.take() {
run_install_suspended(terminal, app, &ver)?;
}
if let Some(pe) = app.pending_ext.take() {
run_ext_suspended(terminal, app, pe)?;
}
if let Some((ver, mode)) = app.pending_xdebug.take() {
run_xdebug_suspended(terminal, app, &ver, mode)?;
}
if let Some(kind) = app.pending_service.take() {
run_service_start_suspended(terminal, app, kind)?;
}
if let Some(name) = app.pending_server.take() {
run_server_start_suspended(terminal, app, &name)?;
}
if app.should_quit {
return Ok(());
}
}
}
fn handle_key(app: &mut App, code: KeyCode, mods: KeyModifiers) {
if app.confirm_remove.is_some() {
handle_confirm_key(app, code);
return;
}
if app.wizard.is_some() {
handle_wizard_key(app, code);
return;
}
if app.server_wizard.is_some() {
handle_server_wizard_key(app, code);
return;
}
if app.settings_modal.is_some() {
handle_settings_key(app, code);
return;
}
if app.php_install.is_some() {
handle_php_install_key(app, code);
return;
}
if app.confirm_remove_php.is_some() {
handle_php_confirm_key(app, code);
return;
}
if app.confirm_remove_server.is_some() {
handle_server_confirm_key(app, code);
return;
}
if app.ext_modal.is_some() {
handle_ext_key(app, code);
return;
}
if app.config_modal.is_some() {
handle_config_key(app, code);
return;
}
if app.php_settings.is_some() {
handle_php_settings_key(app, code);
return;
}
if app.service_picker.is_some() {
handle_service_picker_key(app, code);
return;
}
if app.park_modal.is_some() {
handle_park_key(app, code);
return;
}
if app.log_modal.is_some() {
handle_log_key(app, code);
return;
}
if app.doctor_modal.is_some() {
app.doctor_modal = None;
return;
}
match code {
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
KeyCode::Char('c') if mods.contains(KeyModifiers::CONTROL) => app.should_quit = true,
KeyCode::Tab => app.cycle_focus(true),
KeyCode::BackTab => app.cycle_focus(false),
KeyCode::Up | KeyCode::Char('k') | KeyCode::Left | KeyCode::Char('h') => app.move_sel(-1),
KeyCode::Down | KeyCode::Char('j') | KeyCode::Right | KeyCode::Char('l') => app.move_sel(1),
KeyCode::PageUp => app.move_sel_clamped(app.focus, -10),
KeyCode::PageDown => app.move_sel_clamped(app.focus, 10),
KeyCode::Home => app.move_sel_clamped(app.focus, isize::MIN / 2),
KeyCode::End => app.move_sel_clamped(app.focus, isize::MAX / 2),
KeyCode::Enter => match app.focus {
Panel::Servers => start_selected_server(app),
Panel::Php => restart_selected_fpm(app),
Panel::Vhosts | Panel::Parked => {}
Panel::Services => start_selected_service(app),
},
KeyCode::Char('s') if app.focus == Panel::Servers => open_settings(app),
KeyCode::Char('s') if app.focus == Panel::Php => open_php_settings(app),
KeyCode::Char('x') if app.focus == Panel::Servers => {
if let Some(name) = app.selected_server_name() {
let r = ops::stop_server(&name).map(|_| format!("stopped '{name}'"));
app.act("stop", r);
}
}
KeyCode::Char('x') if app.focus == Panel::Php => {
if let Some(ver) = app.selected_php_version() {
let r = ops::stop_fpm(&ver).map(|_| format!("stopped FPM {ver}"));
app.act("stop", r);
}
}
KeyCode::Char('x') if app.focus == Panel::Services => {
if let Some(kind) = app.selected_service() {
let r = ops::stop_service(kind).map(|_| format!("stopped '{kind}'"));
app.act("stop", r);
}
}
KeyCode::Char('X') if app.focus == Panel::Php => toggle_xdebug(app),
KeyCode::Char('r') => match app.focus {
Panel::Servers => {
if let Some(name) = app.selected_server_name() {
let r = ops::restart_server(&name)
.map(|st| format!("restarted '{name}' — {}", st.as_str()));
app.act("restart", r);
}
}
Panel::Php => restart_selected_fpm(app),
Panel::Vhosts | Panel::Parked => {
let r = apply_all().map(|n| format!("applied {n} server(s)"));
app.act("restart", r);
}
Panel::Services => {
if let Some(kind) = app.selected_service() {
let r = ops::restart_service(kind)
.map(|st| format!("restarted '{kind}' — {}", st.as_str()));
app.act("restart", r);
}
}
},
KeyCode::Char('a') => {
let r = apply_all().map(|n| format!("applied {n} server(s)"));
app.act("apply", r);
}
KeyCode::Char('n') => match app.focus {
Panel::Servers => open_new_server(app),
Panel::Vhosts => open_wizard(app),
Panel::Php => {
app.php_install = Some(PhpInstallModal {
version: String::new(),
error: None,
})
}
Panel::Parked => open_park_modal(app),
Panel::Services => app.service_picker = Some(ServicePicker { sel: 0 }),
},
KeyCode::Char('v') => validate_all(app),
KeyCode::Char('p') if matches!(app.focus, Panel::Vhosts | Panel::Parked) => {
open_park_modal(app)
}
KeyCode::Char(c @ '1'..='4') if app.focus == Panel::Vhosts => {
let col = c as usize - '1' as usize;
if app.vhost_sort_col == col {
app.vhost_sort_asc = !app.vhost_sort_asc;
} else {
app.vhost_sort_col = col;
app.vhost_sort_asc = true;
}
app.sort_vhosts();
}
KeyCode::Char('L') => open_log(app),
KeyCode::Char('~') => {
app.anonymize = !app.anonymize;
app.message = if app.anonymize {
"anonymizer on — paths show /Users/andy".into()
} else {
"anonymizer off".into()
};
}
KeyCode::Char('?') => open_doctor(app),
KeyCode::Char('T') => {
app.message = "installing mkcert CA into the trust store…".into();
let r = Brew::detect().and_then(|brew| ssl::ensure_ca(&brew)).map(|newly| {
if newly {
"✓ mkcert CA installed — local HTTPS is now trusted (restart the browser)"
.to_string()
} else {
"✓ mkcert CA already trusted — local HTTPS works (System keychain + Firefox)"
.to_string()
}
});
app.act("ssl trust", r);
app.force_clear = true;
}
KeyCode::Char('c') => open_config_modal(app),
KeyCode::Delete | KeyCode::Backspace | KeyCode::Char('R') => match app.focus {
Panel::Servers => {
if let Some(name) = app.selected_server_name() {
app.confirm_remove_server = Some(name);
}
}
Panel::Vhosts => {
if let Some(name) = app.selected_vhost_name() {
app.confirm_remove = Some(name);
}
}
Panel::Php => {
if let Some(ver) = app.selected_php_version() {
app.confirm_remove_php = Some(ver);
}
}
Panel::Parked => open_park_modal(app),
Panel::Services => {
if let Some(kind) = app.selected_service() {
let r = ops::remove_service(kind).map(|_| format!("removed '{kind}'"));
app.act("remove", r);
}
}
},
KeyCode::Char('d') if app.focus == Panel::Php => {
if let Some(ver) = app.selected_php_version() {
let r = ops::set_default_php(&ver).map(|restarted| {
if restarted.is_empty() {
format!("default PHP set to {ver}")
} else {
format!(
"default PHP set to {ver} — restarted {}",
restarted.join(", ")
)
}
});
app.act("default", r);
}
}
KeyCode::Char('C') if app.focus == Panel::Php => {
if let Some(ver) = app.selected_php_version() {
let r = ops::set_cli_php(&ver).map(|on_path| {
if on_path {
format!("CLI php → {ver}")
} else {
format!("CLI php → {ver} (add ~/.reeve/bin to PATH to activate)")
}
});
app.act("cli php", r);
}
}
KeyCode::Char('e') => match app.focus {
Panel::Vhosts => open_edit_wizard(app),
Panel::Servers => open_edit_server(app),
Panel::Php => open_ext_modal(app),
Panel::Parked | Panel::Services => {}
},
KeyCode::Char('D') => {
let tlds = app.config.local_tlds.clone();
let list = tlds
.iter()
.map(|t| format!("*.{t}"))
.collect::<Vec<_>>()
.join(", ");
app.message = "requesting admin access for DNS setup…".into();
let r = Brew::detect()
.and_then(|brew| crate::dns::setup(&brew, &tlds))
.map(|ok| {
if ok {
format!("{list} now resolve system-wide")
} else {
"dnsmasq running but some /etc/resolver files not set".to_string()
}
});
app.act("dns setup", r);
app.force_clear = true;
}
_ => {}
}
}
fn open_log(app: &mut App) {
let label = match app.focus {
Panel::Servers => app.selected_server_name().map(|n| format!("server-{n}")),
Panel::Php => app.selected_php_version().map(|v| format!("php-{v}")),
Panel::Vhosts => app
.state
.vhosts
.get(app.sel_vhost)
.map(|v| format!("server-{}", v.server)),
Panel::Parked => app
.parked_vhosts
.get(app.sel_parked)
.map(|v| format!("server-{}", v.server)),
Panel::Services => app.selected_service().map(crate::services::service_id),
};
let Some(label) = label else {
app.message = "nothing selected to show a log for".into();
return;
};
match logs::resolve(&app.state, &label) {
Ok(path) => {
let lines = logs::tail_lines(&path, LOG_TAIL_LINES);
app.log_modal = Some(LogModal {
label,
lines,
scroll: 0,
});
}
Err(e) => app.message = format!("✗ log: {e}"),
}
}
fn open_doctor(app: &mut App) {
let brew = Brew::detect().ok();
let checks = doctor::run(brew.as_ref(), &app.config, &app.state);
let lines = checks
.into_iter()
.map(|c| (c.health, format!("{:<16} {}", c.name, c.detail)))
.collect();
app.doctor_modal = Some(DoctorModal { lines });
}
fn handle_log_key(app: &mut App, code: KeyCode) {
let Some(m) = app.log_modal.as_mut() else {
return;
};
let max = m.lines.len().saturating_sub(1);
match code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('L') => app.log_modal = None,
KeyCode::Up | KeyCode::Char('k') => m.scroll = m.scroll.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => m.scroll = (m.scroll + 1).min(max),
KeyCode::PageUp => m.scroll = m.scroll.saturating_sub(20),
KeyCode::PageDown => m.scroll = (m.scroll + 20).min(max),
KeyCode::Home => m.scroll = 0,
KeyCode::End => m.scroll = max,
_ => {}
}
}
fn backends_installed() -> [bool; 4] {
let mut out = [false; 4];
if let Ok(brew) = Brew::detect() {
for (i, b) in BACKENDS.iter().enumerate() {
out[i] = brew.is_installed(crate::backends::backend_for(*b).formula());
}
}
out
}
fn open_edit_server(app: &mut App) {
let Some(s) = app.state.servers.get(app.sel_server).cloned() else {
return;
};
let backend_idx = BACKENDS.iter().position(|b| *b == s.backend).unwrap_or(0);
app.server_wizard = Some(ServerWizard {
backend_idx,
http: s.http_port.to_string(),
https: s.https_port.to_string(),
default_site: s.default_site,
preset_idx: crate::state::Framework::all()
.iter()
.position(|f| *f == s.default_preset)
.unwrap_or(0),
default_root: s.default_root.clone().unwrap_or_default(),
field: 0,
error: None,
editing: Some(s.name),
installed: backends_installed(),
});
}
fn open_new_server(app: &mut App) {
app.server_wizard = Some(ServerWizard {
backend_idx: 0,
http: "80".into(),
https: "443".into(),
default_site: false,
preset_idx: 0,
default_root: String::new(),
field: 0,
error: None,
editing: None,
installed: backends_installed(),
});
}
fn open_park_modal(app: &mut App) {
if app.state.servers.is_empty() {
app.message = "Add a server first: `reeve server add caddy`".into();
return;
}
if app.state.php_versions.is_empty() {
app.message = "Install a PHP version first: `reeve php install 8.3`".into();
return;
}
app.park_modal = Some(ParkModal {
dir: format!("{}/", app.config.sites_root.trim_end_matches('/')),
server_idx: app.sel_server.min(app.state.servers.len() - 1),
php_idx: app.sel_php.min(app.state.php_versions.len() - 1),
ssl: false,
sel_park: 0,
field: 1,
error: None,
});
}
fn handle_park_key(app: &mut App, code: KeyCode) {
let park_count = app.state.parks.len();
let php_len = app.state.php_versions.len();
let srv_len = app.state.servers.len();
let m = app.park_modal.as_mut().unwrap();
let cyc = |i: usize, len: usize, fwd: bool| {
if len == 0 {
0
} else if fwd {
(i + 1) % len
} else {
(i + len - 1) % len
}
};
match code {
KeyCode::Esc => app.park_modal = None,
KeyCode::Enter => submit_park(app),
KeyCode::Tab => m.field = (m.field + 1) % PARK_FIELDS,
KeyCode::BackTab => m.field = (m.field + PARK_FIELDS - 1) % PARK_FIELDS,
KeyCode::Up if m.field == 0 => m.sel_park = m.sel_park.saturating_sub(1),
KeyCode::Down if m.field == 0 => {
m.sel_park = (m.sel_park + 1).min(park_count.saturating_sub(1))
}
KeyCode::Delete | KeyCode::Backspace if m.field == 0 => {
if let Some(p) = app.state.parks.get(m.sel_park) {
let root = p.root.clone();
let r = ops::remove_park(&root).map(|_| format!("unparked {root}"));
app.act("unpark", r);
}
}
KeyCode::Up => m.field = (m.field + PARK_FIELDS - 1) % PARK_FIELDS,
KeyCode::Down => m.field = (m.field + 1) % PARK_FIELDS,
KeyCode::Backspace if m.field == 1 => {
m.dir.pop();
}
KeyCode::Char(c) if m.field == 1 => m.dir.push(c),
KeyCode::Left if m.field == 2 => m.server_idx = cyc(m.server_idx, srv_len, false),
KeyCode::Right | KeyCode::Char(' ') if m.field == 2 => {
m.server_idx = cyc(m.server_idx, srv_len, true)
}
KeyCode::Left if m.field == 3 => m.php_idx = cyc(m.php_idx, php_len, false),
KeyCode::Right | KeyCode::Char(' ') if m.field == 3 => {
m.php_idx = cyc(m.php_idx, php_len, true)
}
KeyCode::Char(' ') | KeyCode::Left | KeyCode::Right if m.field == 4 => m.ssl = !m.ssl,
_ => {}
}
}
fn submit_park(app: &mut App) {
let m = app.park_modal.as_ref().unwrap();
let dir = expand_tilde_tui(m.dir.trim());
let server = app.state.servers.get(m.server_idx).map(|s| s.name.clone());
let php = app
.state
.php_versions
.get(m.php_idx)
.map(|p| p.version.clone());
let ssl = m.ssl;
let tld = app
.config
.local_tlds
.first()
.cloned()
.unwrap_or_else(|| "test".into());
let (Some(server), Some(php)) = (server, php) else {
if let Some(m) = app.park_modal.as_mut() {
m.error = Some("Pick a server and a PHP version".into());
}
return;
};
match ops::add_park(&dir, &server, &php, &tld, ssl) {
Ok(n) => {
app.park_modal = None;
app.message = format!("✓ parked {dir} → *.{tld} ({n} site(s)) — press 'a' to apply");
app.refresh();
}
Err(e) => {
if let Some(m) = app.park_modal.as_mut() {
m.error = Some(e.to_string());
}
}
}
}
fn expand_tilde_tui(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest).display().to_string();
}
}
path.to_string()
}
fn unique_server_name(state: &State, backend: Backend) -> String {
let base = backend.to_string();
if !state.servers.iter().any(|s| s.name == base) {
return base;
}
(2..)
.map(|n| format!("{base}{n}"))
.find(|cand| !state.servers.iter().any(|s| &s.name == cand))
.unwrap_or(base)
}
fn handle_server_wizard_key(app: &mut App, code: KeyCode) {
let w = app.server_wizard.as_mut().unwrap();
match code {
KeyCode::Esc => app.server_wizard = None,
KeyCode::Enter => submit_server_wizard(app),
KeyCode::Tab | KeyCode::Down => w.field = (w.field + 1) % SERVER_FIELDS,
KeyCode::BackTab | KeyCode::Up => w.field = (w.field + SERVER_FIELDS - 1) % SERVER_FIELDS,
KeyCode::Left if w.field == 0 => {
w.backend_idx = (w.backend_idx + BACKENDS.len() - 1) % BACKENDS.len()
}
KeyCode::Right | KeyCode::Char(' ') if w.field == 0 => {
w.backend_idx = (w.backend_idx + 1) % BACKENDS.len()
}
KeyCode::Left | KeyCode::Right | KeyCode::Char(' ') if w.field == 3 => {
w.default_site = !w.default_site
}
KeyCode::Left if w.field == 4 => {
let n = crate::state::Framework::all().len();
w.preset_idx = (w.preset_idx + n - 1) % n;
}
KeyCode::Right | KeyCode::Char(' ') if w.field == 4 => {
w.preset_idx = (w.preset_idx + 1) % crate::state::Framework::all().len();
}
KeyCode::Backspace => match w.field {
1 => {
w.http.pop();
}
2 => {
w.https.pop();
}
5 => {
w.default_root.pop();
}
_ => {}
},
KeyCode::Char(c) if w.field == 5 => w.default_root.push(c),
KeyCode::Char(c) if c.is_ascii_digit() => match w.field {
1 => w.http.push(c),
2 => w.https.push(c),
_ => {}
},
_ => {}
}
}
fn submit_server_wizard(app: &mut App) {
let w = app.server_wizard.as_ref().unwrap();
let editing = w.editing.clone();
let backend = BACKENDS[w.backend_idx];
let default_site = w.default_site;
let default_preset = crate::state::Framework::all()[w.preset_idx];
let default_root = {
let r = w.default_root.trim();
(!r.is_empty()).then(|| r.to_string())
};
let http: Result<u16, _> = w.http.parse();
let https: Result<u16, _> = w.https.parse();
let set_err = |app: &mut App, msg: String| {
if let Some(w) = app.server_wizard.as_mut() {
w.error = Some(msg);
}
};
let (Ok(http), Ok(https)) = (http, https) else {
set_err(app, "Ports must be numbers (1-65535)".into());
return;
};
if http == 0 || https == 0 {
set_err(app, "Ports must be non-zero".into());
return;
}
let result = (|| -> anyhow::Result<String> {
let mut state = load_state()?;
match &editing {
Some(name) => {
for s in state.servers.iter().filter(|s| &s.name != name) {
if [s.http_port, s.https_port]
.iter()
.any(|p| *p == http || *p == https)
{
anyhow::bail!("port {http}/{https} conflicts with server '{}'", s.name);
}
}
let srv = state
.servers
.iter_mut()
.find(|s| &s.name == name)
.ok_or_else(|| anyhow::anyhow!("server '{name}' not found"))?;
srv.backend = backend;
srv.http_port = http;
srv.https_port = https;
srv.default_site = default_site;
srv.default_preset = default_preset;
srv.default_root = default_root;
crate::state::save_state(&state)?;
Ok(format!(
"updated '{name}' ({backend} :{http}/:{https}) — press 'r' to apply"
))
}
None => {
let name = unique_server_name(&state, backend);
state.add_server(crate::state::Server {
name: name.clone(),
backend,
http_port: http,
https_port: https,
enabled: false,
default_site,
default_preset,
default_root,
settings: Default::default(),
})?;
crate::state::save_state(&state)?;
Ok(format!(
"added {backend} server '{name}' (:{http}/:{https}) — press enter to start"
))
}
}
})();
match result {
Ok(msg) => {
let creating = editing.is_none();
app.server_wizard = None;
app.message = format!("✓ {msg}");
app.refresh();
if creating && !app.state.servers.is_empty() {
app.focus = Panel::Servers;
app.sel_server = app.state.servers.len() - 1;
}
}
Err(e) => set_err(app, e.to_string()),
}
}
fn handle_server_confirm_key(app: &mut App, code: KeyCode) {
match code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
if let Some(name) = app.confirm_remove_server.take() {
let r = remove_server(&name)
.map(|n| format!("removed server '{name}' (and {n} vhost(s))"));
app.act("remove server", r);
}
}
_ => app.confirm_remove_server = None,
}
}
fn remove_server(name: &str) -> Result<usize> {
let mut state = load_state()?;
let Some(server) = state.servers.iter().find(|s| s.name == name).cloned() else {
anyhow::bail!("server '{name}' not found");
};
daemon::uninstall(&crate::backends::server_service_id(&server)).ok();
state.servers.retain(|s| s.name != name);
let before = state.vhosts.len();
state.vhosts.retain(|v| v.server != name);
let removed = before - state.vhosts.len();
crate::state::save_state(&state)?;
Ok(removed)
}
fn validate_all(app: &mut App) {
let result = (|| -> anyhow::Result<String> {
let brew = Brew::detect()?;
let state = load_state()?;
if state.servers.is_empty() {
return Ok("no servers to validate".into());
}
let mut failures = Vec::new();
for s in &state.servers {
if let Err(e) = crate::backends::backend_for(s.backend).validate(s, &brew) {
let first = e
.to_string()
.lines()
.next()
.unwrap_or("invalid")
.to_string();
failures.push(format!("{}: {first}", s.name));
}
}
if failures.is_empty() {
Ok(format!(
"all {} server config(s) valid",
state.servers.len()
))
} else {
anyhow::bail!("{}", failures.join("; "))
}
})();
app.act("validate", result);
}
fn open_ext_modal(app: &mut App) {
let Some(ver) = app.selected_php_version() else {
app.message = "No PHP version selected".into();
return;
};
match Brew::detect().and_then(|brew| php::extensions::list(&brew, &ver)) {
Ok(mut loaded) => {
loaded.sort_by_key(|s| s.to_lowercase());
app.ext_modal = Some(ExtModal {
version: ver,
loaded,
input: String::new(),
sel: 0,
error: None,
});
}
Err(e) => app.message = format!("✗ extensions: {e}"),
}
}
fn handle_ext_key(app: &mut App, code: KeyCode) {
let m = app.ext_modal.as_mut().unwrap();
m.error = None;
let queue_remove = |app: &mut App| {
let m = app.ext_modal.as_ref().unwrap();
if let Some(name) = m.loaded.get(m.sel).cloned() {
let ver = m.version.clone();
app.ext_modal = None;
app.pending_ext = Some(PendingExt {
version: ver,
action: ExtAction::Remove(name),
});
}
};
match code {
KeyCode::Esc => app.ext_modal = None,
KeyCode::Up => m.sel = m.sel.saturating_sub(1),
KeyCode::Down if !m.loaded.is_empty() => {
m.sel = (m.sel + 1).min(m.loaded.len() - 1);
}
KeyCode::Delete => queue_remove(app),
KeyCode::Enter => {
let name = m.input.trim().to_string();
if name.is_empty() {
queue_remove(app);
} else if m.loaded.iter().any(|x| x.eq_ignore_ascii_case(&name)) {
m.error = Some(format!("{name} is already loaded"));
} else {
let ver = m.version.clone();
app.ext_modal = None;
app.pending_ext = Some(PendingExt {
version: ver,
action: ExtAction::Add(name),
});
}
}
KeyCode::Backspace => {
m.input.pop();
}
KeyCode::Char(c) if c.is_ascii_alphanumeric() || c == '_' || c == '-' => m.input.push(c),
_ => {}
}
}
fn open_config_modal(app: &mut App) {
let cfg = &app.config;
let backend_idx = BACKENDS
.iter()
.position(|b| b.as_str() == cfg.default_backend)
.unwrap_or(0);
let tld = cfg.local_tlds.join(" ");
app.config_modal = Some(ConfigModal {
tld: tld.clone(),
sites_root: cfg.sites_root.clone(),
backend_idx,
field: 0,
error: None,
orig_tld: tld,
});
}
fn handle_config_key(app: &mut App, code: KeyCode) {
let m = app.config_modal.as_mut().unwrap();
match code {
KeyCode::Esc => app.config_modal = None,
KeyCode::Enter => submit_config(app),
KeyCode::Tab | KeyCode::Down => m.field = (m.field + 1) % CONFIG_FIELDS,
KeyCode::BackTab | KeyCode::Up => m.field = (m.field + CONFIG_FIELDS - 1) % CONFIG_FIELDS,
KeyCode::Left if m.field == 2 => {
m.backend_idx = (m.backend_idx + BACKENDS.len() - 1) % BACKENDS.len()
}
KeyCode::Right if m.field == 2 => m.backend_idx = (m.backend_idx + 1) % BACKENDS.len(),
KeyCode::Backspace => match m.field {
0 => {
m.tld.pop();
}
1 => {
m.sites_root.pop();
}
_ => {}
},
KeyCode::Char(c) => match m.field {
0 if c.is_ascii_alphanumeric() || c == '-' || c == ' ' || c == ',' => {
m.tld.push(c.to_ascii_lowercase())
}
1 => m.sites_root.push(c),
2 if c == ' ' => m.backend_idx = (m.backend_idx + 1) % BACKENDS.len(),
_ => {}
},
_ => {}
}
}
fn submit_config(app: &mut App) {
let m = app.config_modal.as_ref().unwrap();
let raw = m.tld.clone();
let sites_root = m.sites_root.trim().to_string();
let backend = BACKENDS[m.backend_idx];
let orig_tld = m.orig_tld.clone();
let set_err = |app: &mut App, msg: String| {
if let Some(m) = app.config_modal.as_mut() {
m.error = Some(msg);
}
};
let mut tlds = Vec::new();
for tok in raw.split([' ', ',']).filter(|t| !t.is_empty()) {
let t = tok.trim_matches('.').to_lowercase();
if t.is_empty() {
continue;
}
if !t.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
set_err(
app,
format!("'{tok}' is not a valid TLD (letters/digits/hyphens)"),
);
return;
}
if !tlds.contains(&t) {
tlds.push(t);
}
}
if tlds.is_empty() {
set_err(
app,
"Enter at least one TLD (e.g. test lan localhost)".into(),
);
return;
}
if sites_root.is_empty() {
set_err(app, "Sites root is required".into());
return;
}
let changed = tlds.join(" ") != orig_tld;
let result = (|| -> anyhow::Result<()> {
let mut cfg = load_config()?;
cfg.local_tlds = tlds.clone();
cfg.sites_root = sites_root.clone();
cfg.default_backend = backend.as_str().to_string();
save_config(&cfg)
})();
match result {
Ok(()) => {
app.config_modal = None;
let list = tlds
.iter()
.map(|t| format!(".{t}"))
.collect::<Vec<_>>()
.join(" ");
app.message = if changed {
format!("✓ config saved — TLDs: {list}; press D to set up DNS")
} else {
"✓ config saved".into()
};
app.refresh();
}
Err(e) => set_err(app, e.to_string()),
}
}
fn open_settings(app: &mut App) {
let Some(s) = app.state.servers.get(app.sel_server).cloned() else {
return;
};
let defs = settings_defs(s.backend);
if defs.is_empty() {
app.message = format!("{} has no tunable settings yet", s.backend);
return;
}
let values = defs
.iter()
.map(|d| s.setting(d.key, d.default).to_string())
.collect();
app.settings_modal = Some(SettingsModal {
server_name: s.name,
backend: s.backend,
values,
field: 0,
error: None,
});
}
fn handle_settings_key(app: &mut App, code: KeyCode) {
let n = app
.settings_modal
.as_ref()
.map(|m| m.values.len())
.unwrap_or(0)
.max(1);
let m = app.settings_modal.as_mut().unwrap();
match code {
KeyCode::Esc => app.settings_modal = None,
KeyCode::Enter => submit_settings(app),
KeyCode::Tab | KeyCode::Down => m.field = (m.field + 1) % n,
KeyCode::BackTab | KeyCode::Up => m.field = (m.field + n - 1) % n,
KeyCode::Backspace => {
if let Some(v) = m.values.get_mut(m.field) {
v.pop();
}
}
KeyCode::Char(c) => {
if let Some(v) = m.values.get_mut(m.field) {
v.push(c);
}
}
_ => {}
}
}
fn submit_settings(app: &mut App) {
let m = app.settings_modal.as_ref().unwrap();
let name = m.server_name.clone();
let defs = settings_defs(m.backend);
let values = m.values.clone();
let result = (|| -> anyhow::Result<()> {
let mut state = load_state()?;
let srv = state
.servers
.iter_mut()
.find(|s| s.name == name)
.ok_or_else(|| anyhow::anyhow!("server '{name}' not found"))?;
for (def, val) in defs.iter().zip(values.iter()) {
let val = val.trim();
if val.is_empty() || val == def.default {
srv.settings.remove(def.key);
} else {
srv.settings.insert(def.key.to_string(), val.to_string());
}
}
crate::state::save_state(&state)
})();
match result {
Ok(()) => {
app.settings_modal = None;
app.message = format!("✓ saved settings for '{name}' — press 'r' to apply");
app.refresh();
}
Err(e) => {
if let Some(m) = app.settings_modal.as_mut() {
m.error = Some(e.to_string());
}
}
}
}
fn open_php_settings(app: &mut App) {
let Some(p) = app.state.php_versions.get(app.sel_php).cloned() else {
return;
};
let values = php::php_settings_defs()
.iter()
.map(|d| p.setting(d.key, d.default).to_string())
.collect();
app.php_settings = Some(PhpSettingsModal {
version: p.version,
values,
field: 0,
error: None,
});
}
fn handle_php_settings_key(app: &mut App, code: KeyCode) {
let n = php::php_settings_defs().len().max(1);
let m = app.php_settings.as_mut().unwrap();
match code {
KeyCode::Esc => app.php_settings = None,
KeyCode::Enter => submit_php_settings(app),
KeyCode::Tab | KeyCode::Down => m.field = (m.field + 1) % n,
KeyCode::BackTab | KeyCode::Up => m.field = (m.field + n - 1) % n,
KeyCode::Backspace => {
if let Some(v) = m.values.get_mut(m.field) {
v.pop();
}
}
KeyCode::Char(c) => {
if let Some(v) = m.values.get_mut(m.field) {
v.push(c);
}
}
_ => {}
}
}
fn submit_php_settings(app: &mut App) {
let m = app.php_settings.as_ref().unwrap();
let version = m.version.clone();
let values = m.values.clone();
let defs = php::php_settings_defs();
let result = (|| -> Result<()> {
let mut state = load_state()?;
let php_rec = state
.php_versions
.iter_mut()
.find(|p| p.version == version)
.ok_or_else(|| anyhow::anyhow!("PHP {version} not found"))?;
for (def, val) in defs.iter().zip(values.iter()) {
let val = val.trim();
if val.is_empty() || val == def.default {
php_rec.settings.remove(def.key);
} else {
php_rec
.settings
.insert(def.key.to_string(), val.to_string());
}
}
let record = php_rec.clone();
crate::state::save_state(&state)?;
let brew = Brew::detect()?;
php::ensure_fpm_running(&brew, &record)
})();
match result {
Ok(()) => {
app.php_settings = None;
app.message = format!("✓ saved PHP {version} settings (FPM restarted)");
app.refresh();
}
Err(e) => {
if let Some(m) = app.php_settings.as_mut() {
m.error = Some(e.to_string());
}
}
}
}
fn toggle_xdebug(app: &mut App) {
let Some(p) = app.state.php_versions.get(app.sel_php).cloned() else {
return;
};
let next = p.xdebug.next();
let needs_install = !next.is_off()
&& Brew::detect()
.and_then(|b| php::extensions::is_loaded(&b, &p.version, "xdebug"))
.map(|loaded| !loaded)
.unwrap_or(true);
if needs_install {
app.pending_xdebug = Some((p.version.clone(), next));
app.message = format!("installing Xdebug for PHP {}…", p.version);
} else {
let r = ops::set_xdebug(&p.version, next)
.map(|_| format!("PHP {} Xdebug {}", p.version, next.as_str()));
app.act("xdebug", r);
}
}
fn restart_selected_fpm(app: &mut App) {
if let Some(ver) = app.selected_php_version() {
let r = ops::restart_fpm(&ver).map(|_| format!("restarted PHP {ver} FPM"));
app.act("php restart", r);
}
}
fn fmt_started(label: &str, out: ops::Started) -> String {
let pre = out.handoff.map(|n| format!("{n}; ")).unwrap_or_default();
format!("{pre}started {label} — {}", out.status.as_str())
}
fn start_selected_server(app: &mut App) {
let Some(name) = app.selected_server_name() else {
return;
};
let installed = app
.state
.get_server(&name)
.map(|s| crate::backends::backend_for(s.backend).formula())
.and_then(|formula| Brew::detect().ok().map(|brew| brew.is_installed(formula)))
.unwrap_or(false);
if installed {
let r = ops::start_server(&name).map(|o| fmt_started(&format!("'{name}'"), o));
app.act("start", r);
} else {
app.pending_server = Some(name);
app.message = "installing web server backend…".into();
}
}
fn start_selected_service(app: &mut App) {
let Some(kind) = app.selected_service() else {
return;
};
let installed = Brew::detect()
.map(|b| crate::services::is_installed(&b, kind))
.unwrap_or(false);
if installed {
let r = ops::start_service(kind).map(|o| fmt_started(&format!("'{kind}'"), o));
app.act("start", r);
} else {
app.pending_service = Some(kind);
app.message = format!("installing {kind}…");
}
}
fn handle_service_picker_key(app: &mut App, code: KeyCode) {
let kinds = crate::state::ServiceKind::all();
let m = app.service_picker.as_mut().unwrap();
match code {
KeyCode::Esc => app.service_picker = None,
KeyCode::Up | KeyCode::Char('k') => m.sel = (m.sel + kinds.len() - 1) % kinds.len(),
KeyCode::Down | KeyCode::Char('j') => m.sel = (m.sel + 1) % kinds.len(),
KeyCode::Enter => {
let kind = kinds[m.sel];
app.service_picker = None;
if let Err(e) = ops::add_service(kind) {
app.message = format!("✗ add service: {e}");
return;
}
let installed = Brew::detect()
.map(|b| crate::services::is_installed(&b, kind))
.unwrap_or(false);
if installed {
let r = ops::start_service(kind).map(|o| fmt_started(&format!("'{kind}'"), o));
app.act("start", r);
} else {
app.pending_service = Some(kind);
app.message = format!("installing {kind}…");
}
}
_ => {}
}
}
fn handle_php_install_key(app: &mut App, code: KeyCode) {
let m = app.php_install.as_mut().unwrap();
match code {
KeyCode::Esc => app.php_install = None,
KeyCode::Enter => {
let ver = m.version.trim().to_string();
if ver.is_empty() {
m.error = Some("Enter a version, e.g. 8.3".into());
} else if app.state.php_versions.iter().any(|p| p.version == ver) {
m.error = Some(format!("PHP {ver} is already managed"));
} else {
app.php_install = None;
app.pending_install = Some(ver);
}
}
KeyCode::Backspace => {
m.version.pop();
}
KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => m.version.push(c),
_ => {}
}
}
fn handle_php_confirm_key(app: &mut App, code: KeyCode) {
match code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
if let Some(ver) = app.confirm_remove_php.take() {
let r = ops::remove_php(&ver).map(|_| format!("removed PHP {ver}"));
app.act("remove php", r);
}
}
_ => app.confirm_remove_php = None,
}
}
fn handle_confirm_key(app: &mut App, code: KeyCode) {
match code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
if let Some(name) = app.confirm_remove.take() {
let r = remove_vhost(&name).map(|_| format!("removed vhost '{name}'"));
app.act("remove", r);
}
}
_ => app.confirm_remove = None,
}
}
fn remove_vhost(name: &str) -> Result<()> {
let mut state = load_state()?;
let before = state.vhosts.len();
state.vhosts.retain(|v| v.server_name != name);
if state.vhosts.len() == before {
anyhow::bail!("vhost '{name}' not found");
}
crate::state::save_state(&state)?;
apply_all()?;
Ok(())
}
fn open_wizard_for_snapshot(app: &mut App) {
open_wizard(app);
if let Some(w) = app.wizard.as_mut() {
w.field = FIELD_DOCROOT;
}
}
fn open_wizard(app: &mut App) {
if app.state.php_versions.is_empty() {
app.message = "Install a PHP version first: `reeve php install 8.3`".into();
return;
}
if app.state.servers.is_empty() {
app.message = "Add a server first: `reeve server add caddy`".into();
return;
}
let sites_root = format!("{}/", app.config.sites_root.trim_end_matches('/'));
app.wizard = Some(VhostWizard {
server_name: String::new(),
docroot: sites_root,
php_idx: app.sel_php.min(app.state.php_versions.len() - 1),
server_idx: app.sel_server.min(app.state.servers.len() - 1),
ssl: false,
field: 0,
error: None,
editing: None,
dropdown_open: true,
path_sel: None,
preset: Framework::Generic,
proxy: String::new(),
});
}
fn open_edit_wizard(app: &mut App) {
let Some(v) = app.state.vhosts.get(app.sel_vhost).cloned() else {
return;
};
let php_idx = app
.state
.php_versions
.iter()
.position(|p| p.version == v.php_version)
.unwrap_or(0);
let server_idx = app
.state
.servers
.iter()
.position(|s| s.name == v.server)
.unwrap_or(0);
app.wizard = Some(VhostWizard {
server_name: v.server_name.clone(),
docroot: v.docroot,
php_idx,
server_idx,
ssl: v.ssl,
field: 0,
error: None,
editing: Some(v.server_name),
dropdown_open: true,
path_sel: None,
preset: v.preset,
proxy: v.proxy_target.unwrap_or_default(),
});
}
const FIELD_DOCROOT: usize = 1;
fn handle_wizard_key(app: &mut App, code: KeyCode) {
let php_len = app.state.php_versions.len();
let srv_len = app.state.servers.len();
let w = app.wizard.as_mut().unwrap();
if w.dropdown_active() {
let count = w.path_suggestions().len();
match code {
KeyCode::Esc => {
w.dropdown_open = false;
w.path_sel = None;
}
KeyCode::Down if count > 0 => {
w.path_sel = Some(match w.path_sel {
None => 0,
Some(i) => (i + 1).min(count - 1),
});
}
KeyCode::Up => match w.path_sel {
Some(i) if i > 0 => w.path_sel = Some(i - 1),
_ => w.path_sel = None,
},
KeyCode::Enter | KeyCode::Right => commit_highlighted_path(w),
KeyCode::Tab => tab_complete_path(w),
KeyCode::BackTab => w.prev_field(),
KeyCode::Backspace => {
w.docroot.pop();
w.path_sel = None;
}
KeyCode::Char(c) => {
w.docroot.push(c);
w.path_sel = None;
}
_ => {}
}
return;
}
match code {
KeyCode::Esc => app.wizard = None,
KeyCode::Enter => submit_wizard(app),
KeyCode::Tab | KeyCode::Down => w.next_field(),
KeyCode::BackTab | KeyCode::Up => w.prev_field(),
KeyCode::Left => wizard_adjust(w, php_len, srv_len, false),
KeyCode::Right => wizard_adjust(w, php_len, srv_len, true),
KeyCode::Backspace => match w.field {
0 => {
w.server_name.pop();
}
FIELD_DOCROOT => {
w.docroot.pop();
w.dropdown_open = true;
}
FIELD_PROXY => {
w.proxy.pop();
}
_ => {}
},
KeyCode::Char(' ') => match w.field {
0 => w.server_name.push(' '),
2 | 3 | 5 => wizard_adjust(w, php_len, srv_len, true),
4 => w.ssl = !w.ssl,
_ => {}
},
KeyCode::Char(c) => match w.field {
0 => w.server_name.push(c),
FIELD_DOCROOT => {
w.docroot.push(c);
w.dropdown_open = true;
}
FIELD_PROXY => w.proxy.push(c),
_ => {}
},
_ => {}
}
}
fn commit_highlighted_path(w: &mut VhostWizard) {
let sugg = w.path_suggestions();
let entry = w
.path_sel
.and_then(|i| sugg.get(i))
.or_else(|| sugg.iter().find(|e| e.is_dir));
if let Some(e) = entry {
w.docroot = pathpick::commit_entry(&w.docroot, e);
w.path_sel = None;
}
}
fn tab_complete_path(w: &mut VhostWizard) {
let all = pathpick::read_dir_filtered(&w.docroot, usize::MAX);
if all.is_empty() {
w.next_field();
return;
}
if all.len() == 1 {
w.docroot = pathpick::commit_entry(&w.docroot, &all[0]);
return;
}
let names: Vec<&str> = all.iter().map(|e| e.name.as_str()).collect();
let lcp = pathpick::longest_common_prefix(&names);
let (dir, prefix) = pathpick::split_path(&w.docroot);
if lcp.chars().count() > prefix.chars().count() {
w.docroot = format!("{dir}{lcp}");
} else {
w.next_field();
}
}
fn wizard_adjust(w: &mut VhostWizard, php_len: usize, srv_len: usize, forward: bool) {
let step = |i: usize, len: usize| {
if len == 0 {
0
} else if forward {
(i + 1) % len
} else {
(i + len - 1) % len
}
};
match w.field {
2 => w.php_idx = step(w.php_idx, php_len),
3 => w.server_idx = step(w.server_idx, srv_len),
4 => w.ssl = !w.ssl,
5 => {
let all = Framework::all();
let cur = all.iter().position(|f| *f == w.preset).unwrap_or(0);
w.preset = all[step(cur, all.len())];
}
_ => {}
}
}
fn submit_wizard(app: &mut App) {
let w = app.wizard.as_ref().unwrap();
let name = w.server_name.trim().to_string();
let php = app
.state
.php_versions
.get(w.php_idx)
.map(|p| p.version.clone());
let server = app.state.servers.get(w.server_idx).map(|s| s.name.clone());
let ssl = w.ssl;
let preset = w.preset;
let proxy = w.proxy.trim().to_string();
let is_proxy = !proxy.is_empty();
let is_editing = w.editing.is_some();
let raw_root = w.docroot.trim();
let sites_root = app.config.sites_root.trim_end_matches('/');
let docroot = if raw_root.is_empty() {
format!("{sites_root}/{name}")
} else if !is_editing && raw_root.trim_end_matches('/') == sites_root {
format!("{sites_root}/{name}")
} else {
raw_root.trim_end_matches('/').to_string()
};
let set_err = |app: &mut App, msg: String| {
if let Some(w) = app.wizard.as_mut() {
w.error = Some(msg);
}
};
if name.is_empty() {
set_err(app, "Hostname is required".into());
return;
}
let Some(server) = server else {
set_err(app, "Pick a server".into());
return;
};
let php = if is_proxy {
String::new()
} else {
match php {
Some(p) => p,
None => {
set_err(app, "Pick a PHP version".into());
return;
}
}
};
let proxy_target = if is_proxy { Some(proxy.clone()) } else { None };
let editing = app.wizard.as_ref().and_then(|w| w.editing.clone());
let result = (|| -> anyhow::Result<()> {
let mut state = load_state()?;
if let Some(orig) = &editing {
state.vhosts.retain(|v| &v.server_name != orig);
}
state.add_vhost(crate::state::Vhost {
server_name: name.clone(),
server: server.clone(),
docroot: docroot.clone(),
php_version: php.clone(),
ssl,
preset,
proxy_target,
})?;
crate::state::save_state(&state)
})();
match result {
Ok(()) => {
app.wizard = None;
let verb = if editing.is_some() {
"updated"
} else {
"created"
};
let how = if is_proxy {
format!("→ proxy {proxy}")
} else {
format!("PHP {php}")
};
let tail = match apply_all() {
Ok(n) => format!("— applied to {n} server(s)"),
Err(e) => format!("— saved, but apply failed: {e}"),
};
app.message = format!("✓ {verb} vhost '{name}' on '{server}' ({how}) {tail}");
app.refresh();
}
Err(e) => set_err(app, e.to_string()),
}
}
fn apply_all() -> Result<usize> {
let state = load_state()?;
let mut n = 0;
for server in state.servers.iter().filter(|s| s.enabled) {
ops::restart_server(&server.name)?;
n += 1;
}
Ok(n)
}
fn run_install_suspended(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
app: &mut App,
version: &str,
) -> Result<()> {
restore_terminal(terminal)?;
println!("\n── Installing PHP {version} (this can take a few minutes) ──\n");
let result = ops::install_php(version);
match &result {
Ok(()) => println!("\n✓ PHP {version} installed. Press any key to return…"),
Err(e) => println!("\n✗ install failed: {e}\nPress any key to return…"),
}
let _ = enable_raw_mode();
loop {
if event::poll(Duration::from_millis(500))? {
if let Event::Key(k) = event::read()? {
if k.kind == KeyEventKind::Press {
break;
}
}
}
}
let _ = disable_raw_mode();
*terminal = setup_terminal()?;
app.message = match result {
Ok(()) => format!("✓ installed PHP {version}"),
Err(e) => format!("✗ install PHP {version}: {e}"),
};
app.refresh();
Ok(())
}
fn run_ext_suspended(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
app: &mut App,
pe: PendingExt,
) -> Result<()> {
restore_terminal(terminal)?;
let (verb, name) = match &pe.action {
ExtAction::Add(n) => ("Installing", n.clone()),
ExtAction::Remove(n) => ("Removing", n.clone()),
};
println!("\n── {verb} {name} for PHP {} ──\n", pe.version);
let result = run_ext_op(&pe);
match &result {
Ok(msg) => println!("\n✓ {msg}. Press any key to return…"),
Err(e) => println!("\n✗ {e}\nPress any key to return…"),
}
let _ = enable_raw_mode();
loop {
if event::poll(Duration::from_millis(500))? {
if let Event::Key(k) = event::read()? {
if k.kind == KeyEventKind::Press {
break;
}
}
}
}
let _ = disable_raw_mode();
*terminal = setup_terminal()?;
app.message = match result {
Ok(msg) => format!("✓ {msg}"),
Err(e) => format!("✗ {e}"),
};
app.refresh();
Ok(())
}
fn run_xdebug_suspended(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
app: &mut App,
version: &str,
mode: XdebugMode,
) -> Result<()> {
restore_terminal(terminal)?;
println!(
"\n── Enabling Xdebug ({}) for PHP {version} ──\n",
mode.as_str()
);
let result = ops::set_xdebug(version, mode);
match &result {
Ok(()) => println!(
"\n✓ Xdebug {} for PHP {version}. Press any key to return…",
mode.as_str()
),
Err(e) => println!("\n✗ {e}\nPress any key to return…"),
}
let _ = enable_raw_mode();
loop {
if event::poll(Duration::from_millis(500))? {
if let Event::Key(k) = event::read()? {
if k.kind == KeyEventKind::Press {
break;
}
}
}
}
let _ = disable_raw_mode();
*terminal = setup_terminal()?;
app.message = match result {
Ok(()) => format!("✓ PHP {version} Xdebug {}", mode.as_str()),
Err(e) => format!("✗ xdebug: {e}"),
};
app.refresh();
Ok(())
}
fn run_server_start_suspended(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
app: &mut App,
name: &str,
) -> Result<()> {
restore_terminal(terminal)?;
println!("\n── Installing backend + starting '{name}' ──\n");
let result = ops::start_server(name);
match &result {
Ok(o) => {
if let Some(note) = &o.handoff {
println!(" ↪ {note}");
}
println!(
"\n✓ '{name}' — {}. Press any key to return…",
o.status.as_str()
);
}
Err(e) => println!("\n✗ {e}\nPress any key to return…"),
}
let _ = enable_raw_mode();
loop {
if event::poll(Duration::from_millis(500))? {
if let Event::Key(k) = event::read()? {
if k.kind == KeyEventKind::Press {
break;
}
}
}
}
let _ = disable_raw_mode();
*terminal = setup_terminal()?;
app.message = match result {
Ok(o) => format!("✓ {}", fmt_started(&format!("'{name}'"), o)),
Err(e) => format!("✗ {name}: {e}"),
};
app.refresh();
Ok(())
}
fn run_service_start_suspended(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
app: &mut App,
kind: crate::state::ServiceKind,
) -> Result<()> {
restore_terminal(terminal)?;
println!("\n── Installing + starting {kind} ──\n");
let result = ops::start_service(kind);
match &result {
Ok(o) => {
if let Some(note) = &o.handoff {
println!(" ↪ {note}");
}
println!(
"\n✓ {kind} — {}. Press any key to return…",
o.status.as_str()
);
}
Err(e) => println!("\n✗ {e}\nPress any key to return…"),
}
let _ = enable_raw_mode();
loop {
if event::poll(Duration::from_millis(500))? {
if let Event::Key(k) = event::read()? {
if k.kind == KeyEventKind::Press {
break;
}
}
}
}
let _ = disable_raw_mode();
*terminal = setup_terminal()?;
app.message = match result {
Ok(o) => format!("✓ {}", fmt_started(&format!("'{kind}'"), o)),
Err(e) => format!("✗ {kind}: {e}"),
};
app.refresh();
Ok(())
}
fn run_ext_op(pe: &PendingExt) -> Result<String> {
let brew = Brew::detect()?;
match &pe.action {
ExtAction::Add(name) => {
php::extensions::add(&brew, &pe.version, name)?;
ops::restart_fpm(&pe.version)?;
Ok(format!(
"{name} installed for PHP {} (FPM restarted)",
pe.version
))
}
ExtAction::Remove(name) => {
php::extensions::remove(&brew, &pe.version, name)?;
ops::restart_fpm(&pe.version)?;
Ok(format!(
"{name} removed from PHP {} (FPM restarted)",
pe.version
))
}
}
}
fn emit_hyperlinks(terminal: &mut Terminal<CrosstermBackend<Stdout>>, app: &App) -> Result<()> {
if app.modal_open() {
app.links.borrow_mut().clear();
return Ok(());
}
let links = app.links.borrow();
if links.is_empty() {
return Ok(());
}
let mut out = String::new();
out.push_str("\x1b7"); for link in links.iter() {
out.push_str(&format!("\x1b[{};{}H", link.y + 1, link.x + 1));
let mut sgr = String::from("0;36;4"); if link.reversed {
sgr.push_str(";7");
}
if link.bold {
sgr.push_str(";1");
}
out.push_str(&format!("\x1b[{sgr}m"));
out.push_str(&format!("\x1b]8;;{}\x1b\\", link.url));
out.push_str(&link.text);
out.push_str("\x1b]8;;\x1b\\\x1b[0m");
}
out.push_str("\x1b8"); let backend = terminal.backend_mut();
backend.write_all(out.as_bytes())?;
backend.flush()?;
Ok(())
}
fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
Ok(Terminal::new(CrosstermBackend::new(stdout))?)
}
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}