mod app;
mod config;
mod dex;
mod editor;
mod icons;
mod log;
mod markdown;
mod pulse;
mod registry;
mod repos;
#[cfg(test)]
mod test_support;
mod theme;
mod tree;
mod ui;
mod watch;
mod worktree;
use std::sync::mpsc::{channel, Sender};
use std::sync::Arc;
use std::thread;
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event as CtEvent, KeyCode, KeyEvent,
KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use crossterm::execute;
use app::{App, Focus, Mode, Pending, Prompt, TextInput};
use dex::{Dex, Task};
enum Msg {
Tasks {
store: String,
result: Result<Vec<Task>, String>,
},
Ok(String),
Failed(String),
CompleteRejected {
id: String,
result: String,
error: String,
},
StoreLoaded {
dir: String,
result: Result<Vec<Task>, String>,
},
}
const USAGE: &str = "\
dextui — browse and triage dex tasks
USAGE:
dextui [COMMAND]
With no command, runs the TUI against the dex store for the current directory.
COMMANDS:
config Show the config paths and a commented template
config init Write a config template
config edit Open a config in $EDITOR, creating it if needed
icons List the glyph tiers
selftest Print the data pipeline as text and exit (no TUI)
OPTIONS:
-h, --help Show this help
-V, --version Show the version
CONFIG OPTIONS:
-g, --global Act on ~/.config/dextui/config.toml (the default)
-l, --local Act on .dextui.toml at the git root
--project Alias for --local
--force With `config init`, overwrite an existing file
Settings layer defaults < global < project < environment. Inside the app,
`,` opens the global config in $EDITOR and reloads it when you save.
";
#[derive(Debug)]
enum Command {
Run,
Help,
Version,
ShowConfig,
InitConfig { force: bool, scope: config::Scope },
EditConfig { scope: config::Scope },
Icons,
SelfTest,
}
fn parse_args() -> Result<Command, String> {
let args: Vec<String> = std::env::args().skip(1).collect();
parse(&args)
}
fn requires_a_terminal() -> String {
"dextui: this needs a real terminal, and stdout is not one.\n\n\
It draws a full-screen interface, so it cannot render into a pipe, a file,\n\
or a job with no terminal attached.\n\n\
To inspect the data without a terminal, run `dextui selftest`."
.to_string()
}
fn parse(args: &[String]) -> Result<Command, String> {
let mut words: Vec<&str> = Vec::new();
let mut force = false;
let mut scope = config::Scope::Global;
for arg in args {
match arg.as_str() {
"-h" | "--help" => return Ok(Command::Help),
"-V" | "--version" => return Ok(Command::Version),
"--force" => force = true,
"-l" | "--local" | "--project" => scope = config::Scope::Project,
"-g" | "--global" => scope = config::Scope::Global,
other if other.starts_with('-') => {
return Err(format!("unknown option {other:?}"));
}
other => words.push(other),
}
}
match words.as_slice() {
[] => Ok(Command::Run),
["config"] => Ok(Command::ShowConfig),
["config", "init"] => Ok(Command::InitConfig { force, scope }),
["config", "edit"] => Ok(Command::EditConfig { scope }),
["config", other] => Err(format!(
"unknown config command {other:?}; expected `init` or `edit`"
)),
["icons"] => Ok(Command::Icons),
["selftest"] => Ok(Command::SelfTest),
[other, ..] => Err(format!("unknown command {other:?}")),
}
}
fn main() -> std::io::Result<()> {
let command = match parse_args() {
Ok(c) => c,
Err(e) => {
eprintln!("dextui: {e}\n");
eprint!("{USAGE}");
std::process::exit(2);
}
};
match command {
Command::Help => {
print!("{USAGE}");
return Ok(());
}
Command::Version => {
println!("dextui {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
Command::ShowConfig => {
let mark = |p: Option<std::path::PathBuf>| match p {
Some(p) => {
let state = if p.exists() { "present" } else { "not present" };
format!("{} ({state})", p.display())
}
None => "(could not resolve)".to_string(),
};
println!("# global {}", mark(config::path()));
println!("# project {}\n", mark(config::project_path()));
print!("{}", config::EXAMPLE);
return Ok(());
}
Command::InitConfig { force, scope } => match config::init(scope, force) {
Ok(p) => {
println!("wrote {}", p.display());
return Ok(());
}
Err(e) => {
eprintln!("dextui: {e}");
std::process::exit(1);
}
},
Command::EditConfig { scope } => {
let path = match config::path_for_editing(scope) {
Ok(p) => p,
Err(e) => {
eprintln!("dextui: {e}");
std::process::exit(1);
}
};
let current = std::fs::read_to_string(&path).unwrap_or_default();
match editor::edit("config", ¤t) {
Ok(Some(text)) => {
if let Err(e) = std::fs::write(&path, format!("{text}\n")) {
eprintln!("dextui: {}: {e}", path.display());
std::process::exit(1);
}
let (_, problem) = config::load();
match problem {
Some(p) => {
eprintln!("dextui: saved {}, but: {p}", path.display());
std::process::exit(1);
}
None => println!("saved {}", path.display()),
}
}
Ok(None) => println!("{} unchanged", path.display()),
Err(e) => {
eprintln!("dextui: {e}");
std::process::exit(1);
}
}
return Ok(());
}
Command::Icons => {
println!("Set with icons = \"...\" in the config, or DEXTUI_ICONS\n");
for i in icons::ALL {
println!(
" {:<9} {} {}{}{}{}",
icons::name(i.tier),
icons::about(i.tier),
i.pending,
i.active,
i.done,
i.blocked,
);
}
return Ok(());
}
Command::Run | Command::SelfTest => {}
}
log::init();
if matches!(command, Command::Run)
&& !std::io::IsTerminal::is_terminal(&std::io::stdout())
{
eprintln!("{}", requires_a_terminal());
std::process::exit(1);
}
let mut dex = Arc::new(Dex::real());
let store_dir = match dex.store_dir() {
Ok(d) => d,
Err(e) => {
eprintln!("{}", dex::requires_dex(&e));
std::process::exit(1);
}
};
let tasks = match dex.list() {
Ok(t) => t,
Err(e) => {
eprintln!("{}", dex::requires_dex(&e));
std::process::exit(1);
}
};
let (cfg, config_problem) = config::load();
let (registry, registry_problem) = registry::Registry::load();
match ®istry_problem {
Some(p) => log::line("registry", &format!("load failed: {p}")),
None => log::line("registry", &format!("loaded {} repo(s)", registry.repos.len())),
}
let mut repos: Vec<repos::Repo> = Vec::new();
let mut repo_problems: Vec<String> = Vec::new();
for repo_path in ®istry.repos {
if !std::path::Path::new(repo_path).is_dir() {
repo_problems.push(format!("{repo_path} no longer exists"));
continue;
}
match worktree::list(repo_path) {
Ok(worktrees) => repos.push(repos::Repo {
name: repo_name(repo_path),
path: repo_path.clone(),
worktrees,
open: true,
registered: true,
is_global: false,
}),
Err(e) => repo_problems.push(format!("{repo_path}: {}", flatten(&e))),
}
}
let mut here_path = None;
if let Some(here) = current_repo(&store_dir, &mut repo_problems) {
here_path = Some(here.path.clone());
if !repos.iter().any(|r| r.path == here.path) {
repos.push(here);
}
}
repos.sort_by(|a, b| a.path.cmp(&b.path));
let mut app = App::new(tasks, store_dir.clone(), cfg);
app.registry = registry;
app.repos = repos;
app.here_path = here_path;
app.status = [
config_problem.map(|c| format!("config: {c}")),
registry_problem.map(|r| format!("repos: {r}")),
]
.into_iter()
.flatten()
.chain(repo_problems)
.collect::<Vec<_>>()
.join("; ");
if matches!(command, Command::SelfTest) {
println!("store {store_dir}");
print!("{}", ui::selftest(&app));
return Ok(());
}
let (tx, rx) = channel::<Msg>();
app.selected_worktree = app
.repos
.iter()
.flat_map(|r| r.worktrees.iter())
.find(|w| repos::store_dir(&w.path) == store_dir)
.map(|w| w.path.clone());
app.select_current_store_row();
let all_store_dirs = app.sidebar_stores();
let handles: Vec<_> = all_store_dirs
.iter()
.filter(|dir| *dir != &store_dir && std::path::Path::new(dir).is_dir())
.map(|dir| {
let dir = dir.clone();
thread::spawn(move || {
let start = std::time::Instant::now();
let result = Dex::for_store(&dir).and_then(|d| d.list());
log_list_outcome(&dir, &result, start.elapsed());
(dir, result)
})
})
.collect();
for h in handles {
if let Ok((dir, Ok(store_tasks))) = h.join() {
app.store_tasks.insert(dir, store_tasks);
}
}
app.store_tasks.insert(store_dir.clone(), app.tasks.clone());
let (worktree_tx, worktree_rx) = channel::<String>();
let mut store_watchers = watch::spawn_many(&all_store_dirs, worktree_tx.clone());
let mut watched: std::collections::HashSet<String> =
all_store_dirs.iter().cloned().collect();
{
let tx = tx.clone();
thread::spawn(move || {
while let Ok(dir) = worktree_rx.recv() {
let start = std::time::Instant::now();
let result = Dex::for_store(&dir).and_then(|d| d.list());
log_list_outcome(&dir, &result, start.elapsed());
if tx.send(Msg::StoreLoaded { dir, result }).is_err() {
return;
}
}
});
}
let mut glyphs = cfg.icons;
let mut terminal = ratatui::init();
let _ = execute!(std::io::stdout(), EnableMouseCapture);
let epoch = std::time::Instant::now();
let mut dirty = true;
while !app.should_quit {
if app.pulse_tick(epoch.elapsed(), glyphs.spin.len()) {
dirty = true;
}
if std::mem::take(&mut app.force_redraw) {
terminal.clear()?;
dirty = true;
}
if dirty {
terminal.draw(|f| ui::draw(f, &mut app, &glyphs))?;
dirty = false;
}
if event::poll(pulse::poll_timeout(app.is_animating(), epoch.elapsed()))? {
loop {
match event::read()? {
CtEvent::Key(key) if key.kind == KeyEventKind::Press => {
handle_key(&mut app, key, &dex, &tx);
dirty = true;
}
CtEvent::Mouse(m) => {
handle_mouse(&mut app, m);
dirty = true;
}
_ => dirty = true,
}
if app.should_quit
|| app.pending_editor.is_some()
|| app.pending_config_edit
|| !event::poll(std::time::Duration::ZERO)?
{
break;
}
}
}
while let Ok(msg) = rx.try_recv() {
let visible = !matches!(&msg, Msg::StoreLoaded { dir, .. } if *dir != app.store_dir);
handle_msg(&mut app, msg, &dex, &tx);
dirty = dirty || visible;
}
if let Some(id) = app.pending_editor.take() {
run_editor(&mut terminal, &mut app, &id, &dex, &tx)?;
dirty = true;
}
if std::mem::take(&mut app.pending_config_edit) {
edit_config(&mut terminal, &mut app, &mut glyphs)?;
dirty = true;
}
if std::mem::take(&mut app.repos_changed) {
watch_new_stores(&app, &mut store_watchers, &mut watched, &worktree_tx, &tx);
}
if let Some(path) = app.pending_store.take() {
switch_store(&mut app, &mut dex, &tx, &path);
dirty = true;
}
}
let _ = execute!(std::io::stdout(), DisableMouseCapture);
ratatui::restore();
Ok(())
}
fn handle_mouse(app: &mut App, m: MouseEvent) {
if matches!(app.mode, Mode::Help) {
match m.kind {
MouseEventKind::ScrollDown => app.scroll_help(1),
MouseEventKind::ScrollUp => app.scroll_help(-1),
_ => {}
}
return;
}
match m.kind {
MouseEventKind::Down(MouseButton::Right) if m.row == 0 => {
app.click_header(m.column, true);
}
MouseEventKind::Down(MouseButton::Left) if m.row == 0 => {
app.click_header(m.column, false);
}
MouseEventKind::Down(MouseButton::Left) => {
if let Some(d) = app.divider_at(m.column) {
app.dragging = Some(d);
} else if app.in_body(m.row) {
match app.pane_at(m.column) {
Focus::Tree => {
app.focus = Focus::Tree;
app.click_tree(m.column, m.row);
}
Focus::Detail => app.focus = Focus::Detail,
Focus::Repos => {
app.focus = Focus::Repos;
app.select_repo_at_row(m.row);
follow_repo_cursor(app);
}
}
}
}
MouseEventKind::Drag(MouseButton::Left) if app.dragging.is_some() => {
match app.dragging {
Some(app::Divider::Repos) => app.set_repos_width(m.column, app.terminal_width),
Some(app::Divider::Split) => app.set_split(m.column, app.terminal_width),
None => {}
}
}
MouseEventKind::Up(_) => app.dragging = None,
MouseEventKind::ScrollDown => {
match app.pane_at(m.column) {
Focus::Tree => app.scroll_tree(1),
Focus::Detail => app.scroll_detail(1, 0),
Focus::Repos => {
app.scroll_repos(1);
follow_repo_cursor(app);
}
}
}
MouseEventKind::ScrollUp => {
match app.pane_at(m.column) {
Focus::Tree => app.scroll_tree(-1),
Focus::Detail => app.scroll_detail(-1, 0),
Focus::Repos => {
app.scroll_repos(-1);
follow_repo_cursor(app);
}
}
}
MouseEventKind::ScrollLeft => app.scroll_detail(0, -4),
MouseEventKind::ScrollRight => app.scroll_detail(0, 4),
_ => {}
}
}
fn handle_msg(app: &mut App, msg: Msg, dex: &Arc<Dex>, tx: &Sender<Msg>) {
match msg {
Msg::Tasks { store, .. } if store != app.store_dir => {
log::line(
"store",
&format!("dropped a task list from {store}; now on {}", app.store_dir),
);
}
Msg::Tasks { result: Ok(tasks), .. } => app.apply_tasks(tasks),
Msg::Tasks { result: Err(e), .. } => {
app.status = format!("refresh failed: {}", flatten(&e))
}
Msg::Ok(message) => {
app.status = message;
refresh(dex, tx, app);
}
Msg::Failed(e) => app.mode = Mode::Error(flatten(&e)),
Msg::CompleteRejected { id, result, error } => {
app.mode = Mode::ForceComplete {
id,
result,
message: flatten(&error),
};
}
Msg::StoreLoaded { dir, result } => {
let Ok(store_tasks) = result else { return };
if dir == app.store_dir {
if app.is_modal() {
app.store_tasks.insert(dir, store_tasks);
app.pending_refresh = true;
return;
}
app.apply_tasks(store_tasks.clone());
}
app.store_tasks.insert(dir, store_tasks);
}
}
}
fn edit_config(
terminal: &mut ratatui::DefaultTerminal,
app: &mut App,
glyphs: &mut icons::Icons,
) -> std::io::Result<()> {
let path = match config::path_for_editing(config::Scope::Global) {
Ok(p) => p,
Err(e) => {
app.mode = Mode::Error(e);
return Ok(());
}
};
let current = std::fs::read_to_string(&path).unwrap_or_default();
let _ = execute!(std::io::stdout(), DisableMouseCapture);
ratatui::restore();
let outcome = editor::edit("config", ¤t);
*terminal = ratatui::init();
let _ = execute!(std::io::stdout(), EnableMouseCapture);
terminal.clear()?;
match outcome {
Ok(Some(text)) => {
if let Err(e) = std::fs::write(&path, format!("{text}\n")) {
app.mode = Mode::Error(format!("{}: {e}", path.display()));
return Ok(());
}
let (cfg, problem) = config::load();
*glyphs = cfg.icons;
app.apply_config(cfg);
app.status = match problem {
Some(p) => format!("config reloaded, but: {p}"),
None => "config reloaded".into(),
};
}
Ok(None) => app.status = "config unchanged".into(),
Err(e) => app.mode = Mode::Error(flatten(&e.to_string())),
}
Ok(())
}
fn run_editor(
terminal: &mut ratatui::DefaultTerminal,
app: &mut App,
id: &str,
dex: &Arc<Dex>,
tx: &Sender<Msg>,
) -> std::io::Result<()> {
let current = app
.by_id
.get(id)
.and_then(|t| t.description.clone())
.unwrap_or_default();
let _ = execute!(std::io::stdout(), DisableMouseCapture);
let _ = execute!(std::io::stdout(), DisableMouseCapture);
ratatui::restore();
let outcome = editor::edit(id, ¤t);
*terminal = ratatui::init();
let _ = execute!(std::io::stdout(), EnableMouseCapture);
terminal.clear()?;
match outcome {
Ok(Some(new_text)) => {
let id = id.to_string();
act(dex, tx, "description updated".into(), move |d| {
d.edit(&id, None, Some(&new_text))
});
}
Ok(None) => app.status = "description unchanged".into(),
Err(e) => app.mode = Mode::Error(flatten(&e.to_string())),
}
Ok(())
}
fn flatten(s: &str) -> String {
s.replace(['\n', '\r'], " ").trim().to_string()
}
fn switch_store(app: &mut App, dex: &mut Arc<Dex>, tx: &Sender<Msg>, worktree_path: &str) {
let dir = app.store_for_path(worktree_path);
if dir == app.store_dir {
return;
}
log::line("store", &format!("switching from {} to {dir}", app.store_dir));
let new_dex = match Dex::for_store(&dir) {
Ok(d) => d,
Err(e) => {
app.status = format!("could not switch store: {e}");
return;
}
};
*dex = Arc::new(new_dex);
match app.store_tasks.get(&dir) {
Some(cached) => app.load_store(cached.clone(), dir.clone()),
None => {
app.load_store(Vec::new(), dir.clone());
let dex = Arc::clone(dex);
let tx = tx.clone();
let store = dir.clone();
thread::spawn(move || {
let start = std::time::Instant::now();
let result = dex.list();
log_list_outcome(&store, &result, start.elapsed());
let _ = tx.send(Msg::StoreLoaded { dir: store, result });
});
}
}
}
fn watch_new_stores(
app: &App,
watchers: &mut Vec<watch::StoreWatcher>,
watched: &mut std::collections::HashSet<String>,
worktree_tx: &Sender<String>,
tx: &Sender<Msg>,
) {
for dir in app.sidebar_stores() {
if !watched.insert(dir.clone()) {
continue;
}
watchers.extend(watch::spawn_many(
std::slice::from_ref(&dir),
worktree_tx.clone(),
));
if app.store_tasks.contains_key(&dir) || !std::path::Path::new(&dir).is_dir() {
continue;
}
let tx = tx.clone();
thread::spawn(move || {
let start = std::time::Instant::now();
let result = Dex::for_store(&dir).and_then(|d| d.list());
log_list_outcome(&dir, &result, start.elapsed());
let _ = tx.send(Msg::StoreLoaded { dir, result });
});
}
}
fn save_repo_at(app: &mut App, typed: &str) -> String {
let typed = typed.trim();
if typed.is_empty() {
return String::new();
}
let expanded = match typed.strip_prefix("~") {
Some(rest) => match std::env::var_os("HOME") {
Some(home) => format!("{}{rest}", home.to_string_lossy()),
None => return "cannot expand ~: HOME is not set".to_string(),
},
None => typed.to_string(),
};
if !std::path::Path::new(&expanded).is_dir() {
return format!("{expanded} is not a directory");
}
match worktree::list(&expanded) {
Ok(worktrees) => register_repo(app, worktrees),
Err(e) => format!("{expanded} is not a git repo: {}", flatten(&e)),
}
}
fn register_current_repo(app: &mut App) -> String {
let cwd = match std::env::current_dir() {
Ok(c) => c,
Err(e) => return format!("could not resolve the current directory: {e}"),
};
let worktrees = match worktree::list(&cwd.to_string_lossy()) {
Ok(w) => w,
Err(e) => return format!("could not list worktrees: {}", flatten(&e)),
};
register_repo(app, worktrees)
}
fn register_repo(app: &mut App, worktrees: Vec<worktree::Worktree>) -> String {
let Some(path) = worktrees.first().map(|w| w.path.clone()) else {
return "no worktrees found".to_string();
};
match app.register_repo_path(&path) {
Ok(true) => {
log::line("registry", &format!("saved: added {path}"));
match app.repos.iter_mut().find(|r| r.path == path) {
Some(row) => row.registered = true,
None => {
app.repos.push(repos::Repo {
name: repo_name(&path),
path: path.clone(),
worktrees,
open: true,
registered: true,
is_global: false,
});
app.repos.sort_by(|a, b| a.path.cmp(&b.path));
}
}
app.select_current_store_row();
app.repos_changed = true;
format!("saved {path}")
}
Ok(false) => format!("{path} is already saved"),
Err(e) => {
let e = flatten(&e);
log::line("registry", &format!("save failed: {e}"));
format!("could not save: {e}")
}
}
}
fn current_repo(store_dir: &str, problems: &mut Vec<String>) -> Option<repos::Repo> {
let Some(root) = store_dir.strip_suffix("/.dex") else {
return Some(repos::Repo {
name: "global".into(),
path: store_dir.to_string(),
worktrees: Vec::new(),
open: true,
registered: false,
is_global: true,
});
};
match worktree::list(root) {
Ok(worktrees) => {
let path = worktrees.first().map_or(root, |w| w.path.as_str()).to_string();
Some(repos::Repo {
name: repo_name(&path),
path,
worktrees,
open: true,
registered: false,
is_global: false,
})
}
Err(e) => {
problems.push(format!("{root}: {}", flatten(&e)));
None
}
}
}
fn repo_name(repo_path: &str) -> String {
std::path::Path::new(repo_path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| repo_path.to_string())
}
fn select_worktree_under_cursor(app: &mut App) {
follow_repo_cursor(app);
if app.selected_worktree_path().is_some() {
app.focus = Focus::Tree;
}
}
fn move_repo_cursor(app: &mut App, delta: isize) {
app.move_repo_row(delta);
follow_repo_cursor(app);
}
fn follow_repo_cursor(app: &mut App) {
if let Some(path) = app.selected_worktree_path() {
app.select_worktree(&path);
app.pending_store = Some(path);
}
}
fn log_list_outcome(store: &str, result: &Result<Vec<Task>, String>, elapsed: std::time::Duration) {
let ms = elapsed.as_millis();
match result {
Ok(tasks) => log::line("dex", &format!("list {store} - {} tasks {ms}ms", tasks.len())),
Err(e) => log::line("dex", &format!("list {store} failed after {ms}ms: {}", flatten(e))),
}
}
fn refresh(dex: &Arc<Dex>, tx: &Sender<Msg>, app: &App) {
let dex = Arc::clone(dex);
let tx = tx.clone();
let store = app.store_dir.clone();
let logged = store.clone();
thread::spawn(move || {
let start = std::time::Instant::now();
let result = dex.list();
log_list_outcome(&logged, &result, start.elapsed());
let _ = tx.send(Msg::Tasks { store, result });
});
}
fn act<F>(dex: &Arc<Dex>, tx: &Sender<Msg>, success: String, f: F)
where
F: FnOnce(&Dex) -> Result<(), String> + Send + 'static,
{
let dex = Arc::clone(dex);
let tx = tx.clone();
thread::spawn(move || {
let msg = match f(&dex) {
Ok(()) => Msg::Ok(success),
Err(e) => Msg::Failed(e),
};
let _ = tx.send(msg);
});
}
fn close_modal(app: &mut App, dex: &Arc<Dex>, tx: &Sender<Msg>) {
app.mode = Mode::Normal;
if app.pending_refresh {
app.pending_refresh = false;
refresh(dex, tx, app);
}
}
fn handle_key(app: &mut App, key: KeyEvent, dex: &Arc<Dex>, tx: &Sender<Msg>) {
match app.mode.clone() {
Mode::Normal => handle_normal(app, key, dex, tx),
Mode::Search => handle_search(app, key),
Mode::Prompt(p) => handle_prompt(app, key, p, dex, tx),
Mode::Confirm { id, .. } => {
if matches!(key.code, KeyCode::Enter | KeyCode::Char('y')) {
if let Some(path) = id.strip_prefix("repo:") {
app.status = match app.unregister_repo_path(path) {
Ok(true) => {
log::line("registry", &format!("saved: removed {path}"));
format!("forgot {path}")
}
Ok(false) => format!("{path} was not saved"),
Err(e) => {
let e = flatten(&e);
log::line("registry", &format!("save failed: {e}"));
format!("could not forget {path}: {e}")
}
};
} else {
let name = app.by_id.get(&id).map(|t| t.name.clone()).unwrap_or_default();
act(dex, tx, format!("deleted {name}"), move |d| d.delete(&id));
}
}
close_modal(app, dex, tx);
}
Mode::ForceComplete { id, result, .. } => {
if matches!(key.code, KeyCode::Enter | KeyCode::Char('y')) {
act(dex, tx, "completed".to_string(), move |d| {
d.complete(&id, &result, true)
});
}
close_modal(app, dex, tx);
}
Mode::Help => handle_help(app, key, dex, tx),
Mode::Error(_) => close_modal(app, dex, tx),
}
}
fn handle_help(app: &mut App, key: KeyEvent, dex: &Arc<Dex>, tx: &Sender<Msg>) {
let page = app.help_viewport_height.max(1) as i32;
match key.code {
KeyCode::Down | KeyCode::Char('j') => app.scroll_help(1),
KeyCode::Up | KeyCode::Char('k') => app.scroll_help(-1),
KeyCode::PageDown => app.scroll_help(page),
KeyCode::PageUp => app.scroll_help(-page),
KeyCode::Home | KeyCode::Char('g') => app.scroll_help(i32::MIN),
KeyCode::End | KeyCode::Char('G') => app.scroll_help(i32::MAX),
_ => close_modal(app, dex, tx),
}
}
fn handle_normal(app: &mut App, key: KeyEvent, dex: &Arc<Dex>, tx: &Sender<Msg>) {
let selected = app.selected_task().cloned();
app.status.clear();
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.should_quit = true
}
KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.force_redraw = true;
app.status = "redrew the screen".into();
}
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
KeyCode::Tab => app.cycle_focus(true),
KeyCode::BackTab => app.cycle_focus(false),
KeyCode::Char('w') => app.toggle_wrap(),
KeyCode::Char('b') => app.toggle_repos(),
KeyCode::Char('o') => app.cycle_sort(),
KeyCode::Char('O') => app.toggle_sort_direction(),
KeyCode::Down | KeyCode::Char('j') => match app.focus {
Focus::Tree => app.move_selection(1),
Focus::Detail => app.scroll_detail(1, 0),
Focus::Repos => move_repo_cursor(app, 1),
},
KeyCode::Up | KeyCode::Char('k') => match app.focus {
Focus::Tree => app.move_selection(-1),
Focus::Detail => app.scroll_detail(-1, 0),
Focus::Repos => move_repo_cursor(app, -1),
},
KeyCode::PageDown => match app.focus {
Focus::Tree => app.move_selection(10),
Focus::Detail => app.scroll_detail(10, 0),
Focus::Repos => move_repo_cursor(app, 10),
},
KeyCode::PageUp => match app.focus {
Focus::Tree => app.move_selection(-10),
Focus::Detail => app.scroll_detail(-10, 0),
Focus::Repos => move_repo_cursor(app, -10),
},
KeyCode::Enter => match app.focus {
Focus::Repos => select_worktree_under_cursor(app),
_ => app.show_detail(),
},
KeyCode::Char('1') => app.show_repos(),
KeyCode::Char('2') => app.show_tree(),
KeyCode::Char('3') => app.show_detail(),
KeyCode::Right | KeyCode::Char('l') => match app.focus {
Focus::Tree => {
if !app.expand_selected() && app.single_pane() {
app.show_detail();
}
}
Focus::Detail => app.scroll_detail(0, 4),
Focus::Repos => select_worktree_under_cursor(app),
},
KeyCode::Left | KeyCode::Char('h') => match app.focus {
Focus::Tree => app.collapse_selected(),
Focus::Detail => {
let can_scroll = !app.wrap && app.detail_scroll.1 > 0;
if !can_scroll && app.single_pane() {
app.show_tree();
} else {
app.scroll_detail(0, -4);
}
}
Focus::Repos => {}
},
KeyCode::Char('g') => match app.focus {
Focus::Tree => app.select_first(),
Focus::Detail => app.detail_to_top(),
Focus::Repos => {
app.select_first_repo_row();
follow_repo_cursor(app);
}
},
KeyCode::Char('G') => match app.focus {
Focus::Tree => app.select_last(),
Focus::Detail => app.detail_to_bottom(),
Focus::Repos => {
app.select_last_repo_row();
follow_repo_cursor(app);
}
},
KeyCode::Char('z') => app.toggle_zoom(),
KeyCode::Char('-') => app.collapse_all(),
KeyCode::Char('+') | KeyCode::Char('=') => app.expand_all(),
KeyCode::Char('/') => app.mode = Mode::Search,
KeyCode::Char('f') => {
app.filter = app.filter.next();
app.rebuild();
}
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
refresh(dex, tx, app)
}
KeyCode::Char('A') => {
app.mode = Mode::Prompt(Prompt {
title: "Save a repo".into(),
label: "Path".into(),
input: app::TextInput::default(),
pending: Pending::SaveRepo,
});
}
KeyCode::Char('?') => app.open_help(),
KeyCode::Char(',') => app.pending_config_edit = true,
KeyCode::Char('n') => {
app.mode = Mode::Prompt(Prompt {
title: "New task".into(),
label: "Name".into(),
input: TextInput::default(),
pending: Pending::CreateName { parent: None },
})
}
KeyCode::Char('s') => {
if let Some(t) = selected {
let id = t.id.clone();
act(dex, tx, format!("started {}", t.name), move |d| d.start(&id));
}
}
KeyCode::Char('a') if app.focus == Focus::Repos => {
app.status = register_current_repo(app);
}
KeyCode::Char('D') if app.focus == Focus::Repos => {
if let Some(r) = app.selected_repo() {
app.mode = Mode::Confirm {
id: format!("repo:{}", r.path),
message: format!(
"\"{}\" will be unregistered. Its worktrees are not touched.",
r.name
),
};
}
}
KeyCode::Char('a') => {
if let Some(t) = selected {
app.mode = Mode::Prompt(Prompt {
title: format!("New subtask of: {}", t.name),
label: "Name".into(),
input: TextInput::default(),
pending: Pending::CreateName {
parent: Some(t.id.clone()),
},
});
}
}
KeyCode::Char('c') => {
if let Some(t) = selected {
app.mode = Mode::Prompt(Prompt {
title: format!("Complete: {}", t.name),
label: "Result".into(),
input: TextInput::default(),
pending: Pending::Complete { id: t.id.clone() },
});
}
}
KeyCode::Char('r') => {
if let Some(t) = selected {
app.mode = Mode::Prompt(Prompt {
title: format!("Rename: {}", t.name),
label: "Name".into(),
input: TextInput::new(&t.name),
pending: Pending::EditName { id: t.id.clone() },
});
}
}
KeyCode::Char('e') => {
if let Some(t) = selected {
app.pending_editor = Some(t.id.clone());
}
}
KeyCode::Char('d') => {
if let Some(t) = selected {
let kids = app
.tasks
.iter()
.filter(|x| x.parent_id.as_deref() == Some(t.id.as_str()))
.count();
let message = if kids > 0 {
format!("\"{}\" and its {kids} subtask(s) will be deleted.", t.name)
} else {
format!("\"{}\" will be deleted.", t.name)
};
app.mode = Mode::Confirm {
id: t.id.clone(),
message,
};
}
}
_ => {}
}
}
fn handle_search(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Enter => app.mode = Mode::Normal,
KeyCode::Backspace => {
app.query.backspace();
app.rebuild();
}
KeyCode::Left => app.query.left(),
KeyCode::Right => app.query.right(),
KeyCode::Char(c) => {
app.query.insert(c);
app.rebuild();
}
_ => {}
}
}
fn handle_prompt(app: &mut App, key: KeyEvent, mut p: Prompt, dex: &Arc<Dex>, tx: &Sender<Msg>) {
match key.code {
KeyCode::Esc => close_modal(app, dex, tx),
KeyCode::Enter => submit(app, p, dex, tx),
KeyCode::Backspace => {
p.input.backspace();
app.mode = Mode::Prompt(p);
}
KeyCode::Left => {
p.input.left();
app.mode = Mode::Prompt(p);
}
KeyCode::Right => {
p.input.right();
app.mode = Mode::Prompt(p);
}
KeyCode::Char(c) => {
p.input.insert(c);
app.mode = Mode::Prompt(p);
}
_ => {}
}
}
fn submit(app: &mut App, p: Prompt, dex: &Arc<Dex>, tx: &Sender<Msg>) {
let value = p.input.value.clone();
match p.pending {
Pending::SaveRepo => {
app.status = save_repo_at(app, &value);
}
Pending::CreateName { parent } => {
if value.trim().is_empty() {
close_modal(app, dex, tx);
return;
}
app.mode = Mode::Prompt(Prompt {
title: p.title,
label: "Description (may be left empty)".into(),
input: TextInput::default(),
pending: Pending::CreateDescription {
parent,
name: value,
},
});
}
Pending::CreateDescription { parent, name } => {
let shown = name.clone();
act(dex, tx, format!("created {shown}"), move |d| {
d.create(&name, &value, parent.as_deref())
});
close_modal(app, dex, tx);
}
Pending::EditName { id } => {
if value.trim().is_empty() {
close_modal(app, dex, tx);
return;
}
let shown = value.clone();
act(dex, tx, format!("renamed to {shown}"), move |d| {
d.edit(&id, Some(&value), None)
});
close_modal(app, dex, tx);
}
Pending::Complete { id } => {
let dex2 = Arc::clone(dex);
let tx2 = tx.clone();
let result = value;
thread::spawn(move || {
let msg = match dex2.complete(&id, &result, false) {
Ok(()) => Msg::Ok("completed".to_string()),
Err(e) if e.to_lowercase().contains("subtask") => Msg::CompleteRejected {
id,
result,
error: e,
},
Err(e) => Msg::Failed(e),
};
let _ = tx2.send(msg);
});
close_modal(app, dex, tx);
app.status = "completing…".into();
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn the_no_terminal_message_offers_a_way_forward() {
let m = super::requires_a_terminal();
assert!(m.contains("real terminal"), "does not say what is wrong: {m}");
assert!(m.contains("dextui selftest"), "offers no alternative: {m}");
assert!(
matches!(super::parse(&["selftest".to_string()]), Ok(super::Command::SelfTest)),
"the message names a command that is not accepted"
);
}
use super::*;
fn parsed(args: &[&str]) -> Result<Command, String> {
parse(&args.iter().map(|s| s.to_string()).collect::<Vec<_>>())
}
fn help_key(key: KeyCode) -> (Option<u16>, u16) {
let mut app = App::new(vec![], "demo".into(), crate::config::Config::default());
app.open_help();
app.help_content_height = 30;
app.help_viewport_height = 10;
app.help_scroll = 5;
let (tx, _rx) = std::sync::mpsc::channel();
let dex = Arc::new(Dex::real());
handle_key(&mut app, KeyEvent::from(key), &dex, &tx);
let still_open = matches!(app.mode, Mode::Help).then_some(app.help_scroll);
(still_open, app.help_scroll)
}
#[test]
fn the_help_scrolls_on_movement_keys_and_dismisses_on_everything_else() {
for (key, want) in [
(KeyCode::Char('j'), 6),
(KeyCode::Down, 6),
(KeyCode::Char('k'), 4),
(KeyCode::Up, 4),
(KeyCode::PageDown, 15),
(KeyCode::PageUp, 0),
(KeyCode::Char('g'), 0),
(KeyCode::Home, 0),
(KeyCode::Char('G'), 20),
(KeyCode::End, 20),
] {
let (open, scroll) = help_key(key);
assert_eq!(open, Some(want), "{key:?} should have scrolled to {want}");
assert_eq!(scroll, want);
}
for key in [
KeyCode::Esc,
KeyCode::Enter,
KeyCode::Char('q'),
KeyCode::Char('?'),
KeyCode::Char(' '),
KeyCode::Char('x'),
] {
assert_eq!(help_key(key).0, None, "{key:?} should have dismissed the help");
}
}
#[test]
fn saving_a_repo_by_path_rejects_what_is_not_a_git_repo() {
let mut app = App::new(vec![], "demo".into(), crate::config::Config::default());
assert!(save_repo_at(&mut app, " ").is_empty(), "blank is a no-op, not an error");
let missing = save_repo_at(&mut app, "/nonexistent-path-for-tests");
assert!(missing.contains("not a directory"), "{missing}");
let not_a_repo = save_repo_at(&mut app, "/tmp");
assert!(not_a_repo.contains("not a git repo"), "{not_a_repo}");
assert!(app.repos.is_empty(), "a rejected path must not leave a row");
}
#[test]
fn saving_a_repo_by_path_expands_a_leading_tilde() {
let mut app = App::new(vec![], "demo".into(), crate::config::Config::default());
let home = std::env::var("HOME").expect("HOME is set in tests");
let msg = save_repo_at(&mut app, "~/definitely-not-here-xyz");
assert!(msg.starts_with(&home), "the tilde was not expanded: {msg}");
}
#[test]
fn a_store_outside_any_repo_becomes_the_global_row() {
let mut problems = Vec::new();
let r = current_repo("/home/u/.config/dex/local", &mut problems).unwrap();
assert!(r.is_global);
assert!(!r.registered, "nothing registers the global store");
assert_eq!(r.name, "global");
assert!(r.worktrees.is_empty());
assert_eq!(r.store(None), "/home/u/.config/dex/local");
assert!(problems.is_empty());
}
#[test]
fn a_dex_directory_is_treated_as_a_repo_not_the_global_store() {
let mut problems = Vec::new();
let r = current_repo("/nonexistent-repo-for-tests/.dex", &mut problems);
assert!(r.is_none());
assert_eq!(problems.len(), 1, "a git failure has to be reported: {problems:?}");
assert!(
problems[0].starts_with("/nonexistent-repo-for-tests"),
"the problem names the repo, not its store: {problems:?}"
);
}
#[test]
fn ctrl_l_forces_a_redraw_and_is_not_swallowed_by_the_plain_l() {
let (tx, _rx) = std::sync::mpsc::channel();
let dex = Arc::new(Dex::real());
let mut app = App::new(vec![], "demo".into(), crate::config::Config::default());
app.focus = Focus::Tree;
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL),
&dex,
&tx,
);
assert!(app.force_redraw, "ctrl-l was swallowed by the plain `l`");
let mut app = App::new(vec![], "demo".into(), crate::config::Config::default());
app.focus = Focus::Tree;
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE),
&dex,
&tx,
);
assert!(!app.force_redraw, "the plain `l` should not force a redraw");
}
#[test]
fn reopening_the_help_starts_at_the_top() {
let mut app = App::new(vec![], "demo".into(), crate::config::Config::default());
app.help_content_height = 30;
app.help_viewport_height = 10;
app.open_help();
app.scroll_help(i32::MAX);
assert_eq!(app.help_scroll, 20, "the test never scrolled anywhere");
app.mode = Mode::Normal;
app.open_help();
assert_eq!(app.help_scroll, 0);
}
#[test]
fn no_arguments_runs_the_tui() {
assert!(matches!(parsed(&[]), Ok(Command::Run)));
}
#[test]
fn an_unknown_command_or_option_is_an_error_not_a_silent_launch() {
assert!(parsed(&["--nonsense"]).unwrap_err().contains("--nonsense"));
assert!(parsed(&["wibble"]).unwrap_err().contains("wibble"));
assert!(parsed(&["config", "wibble"]).unwrap_err().contains("wibble"));
}
#[test]
fn help_and_version_have_short_forms() {
assert!(matches!(parsed(&["-h"]), Ok(Command::Help)));
assert!(matches!(parsed(&["--help"]), Ok(Command::Help)));
assert!(matches!(parsed(&["-V"]), Ok(Command::Version)));
assert!(matches!(parsed(&["--version"]), Ok(Command::Version)));
}
#[test]
fn help_wins_even_after_a_command() {
assert!(matches!(parsed(&["config", "init", "--help"]), Ok(Command::Help)));
}
#[test]
fn config_subcommands_parse() {
assert!(matches!(parsed(&["config"]), Ok(Command::ShowConfig)));
assert!(matches!(parsed(&["config", "init"]), Ok(Command::InitConfig { .. })));
assert!(matches!(parsed(&["config", "edit"]), Ok(Command::EditConfig { .. })));
}
#[test]
fn local_and_project_select_the_project_file() {
for flag in ["-l", "--local", "--project"] {
assert!(
matches!(
parsed(&["config", "edit", flag]),
Ok(Command::EditConfig { scope: config::Scope::Project })
),
"{flag} did not select the project scope"
);
}
}
#[test]
fn global_is_the_default_and_can_be_stated_explicitly() {
assert!(matches!(
parsed(&["config", "edit"]),
Ok(Command::EditConfig { scope: config::Scope::Global })
));
for flag in ["-g", "--global"] {
assert!(matches!(
parsed(&["config", "edit", flag]),
Ok(Command::EditConfig { scope: config::Scope::Global })
));
}
}
#[test]
fn options_may_appear_before_the_command() {
assert!(matches!(
parsed(&["--local", "--force", "config", "init"]),
Ok(Command::InitConfig { force: true, scope: config::Scope::Project })
));
}
#[test]
fn init_does_not_overwrite_unless_asked() {
assert!(matches!(
parsed(&["config", "init"]),
Ok(Command::InitConfig { force: false, .. })
));
}
#[test]
fn every_command_in_the_usage_text_is_actually_accepted() {
for line in USAGE.lines() {
let line = line.trim_end();
let Some(rest) = line.strip_prefix(" ") else {
continue;
};
if rest.starts_with(' ') || rest.is_empty() {
continue;
}
let words: Vec<&str> = rest
.split_whitespace()
.take_while(|w| !w.starts_with('-') && w.chars().all(|c| c.is_ascii_lowercase()))
.collect();
if words.is_empty() || words[0] == "dextui" {
continue;
}
assert!(
parse(&words.iter().map(|w| w.to_string()).collect::<Vec<_>>()).is_ok(),
"usage advertises {words:?} but the parser rejects it"
);
}
}
fn a_task(id: &str) -> Task {
Task {
id: id.to_string(),
name: id.to_string(),
..Default::default()
}
}
fn apply(app: &mut App, msg: Msg) {
let (tx, _rx) = channel::<Msg>();
handle_msg(app, msg, &Arc::new(Dex::real()), &tx);
}
#[test]
fn a_task_list_from_the_store_we_just_left_is_dropped() {
let mut app = App::new(vec![a_task("new")], "/x/two/.dex".into(), config::Config::default());
apply(
&mut app,
Msg::Tasks {
store: "/x/one/.dex".into(),
result: Ok(vec![a_task("old")]),
},
);
let ids: Vec<&str> = app.tasks.iter().map(|t| t.id.as_str()).collect();
assert_eq!(ids, vec!["new"], "the old store's tasks were painted");
assert_eq!(app.store_label, "two", "and the header still says the new store");
}
#[test]
fn a_task_list_from_the_current_store_is_applied() {
let mut app = App::new(vec![], "/x/one/.dex".into(), config::Config::default());
apply(
&mut app,
Msg::Tasks {
store: "/x/one/.dex".into(),
result: Ok(vec![a_task("fresh")]),
},
);
assert_eq!(app.tasks.len(), 1);
assert_eq!(app.tasks[0].id, "fresh");
}
#[test]
fn a_failure_from_a_store_we_just_left_is_not_reported() {
let mut app = App::new(vec![], "/x/two/.dex".into(), config::Config::default());
apply(
&mut app,
Msg::Tasks {
store: "/x/one/.dex".into(),
result: Err("boom".into()),
},
);
assert!(app.status.is_empty(), "status: {}", app.status);
}
fn worktrees_of(name: &str) -> Vec<crate::worktree::Worktree> {
vec![
crate::worktree::Worktree {
path: format!("/x/{name}"),
branch: "main".into(),
is_main: true,
is_locked: false,
is_detached: false,
},
crate::worktree::Worktree {
path: format!("/x/{name}-feat"),
branch: "feat".into(),
is_main: false,
is_locked: false,
is_detached: false,
},
]
}
#[test]
fn registering_a_repo_adds_its_sidebar_row_immediately() {
crate::test_support::with_isolated_registry("main-register-row", || {
let mut app = App::new(vec![], "t".into(), config::Config::default());
assert!(app.repos.is_empty());
let status = register_repo(&mut app, worktrees_of("one"));
assert!(status.contains("saved"), "status: {status}");
assert_eq!(app.repos.len(), 1, "the sidebar row was not added");
assert_eq!(app.repos[0].path, "/x/one");
assert_eq!(app.repos[0].name, "one");
assert_eq!(
app.repos[0].worktrees.len(),
2,
"the row must carry every worktree, not just the main one"
);
assert_eq!(app.repo_rows().len(), 4);
});
}
#[test]
fn saving_carries_the_sidebar_cursor_along_with_the_row_it_moves() {
crate::test_support::with_isolated_registry("main-register-cursor", || {
let mut app = App::new(vec![], "/x/two/.dex".into(), config::Config::default());
app.here_path = Some("/x/two".into());
app.here_store = std::env::temp_dir().to_string_lossy().into_owned();
app.repos = ["one", "two"]
.iter()
.map(|n| repos::Repo {
name: (*n).to_string(),
path: format!("/x/{n}"),
worktrees: worktrees_of(n),
open: true,
registered: *n == "one",
is_global: false,
})
.collect();
app.select_current_store_row();
assert_eq!(
app.repo_rows()[app.selected_repo_row],
repos::Row::Repo { index: 1 },
"the cursor should start on the repo we are in"
);
register_repo(&mut app, worktrees_of("two"));
let rows = app.repo_rows();
assert_eq!(
rows[app.selected_repo_row],
repos::Row::Repo { index: 1 },
"the cursor stayed at its old index instead of following: {rows:?}"
);
assert!(
!rows.contains(&repos::Row::Heading("here")),
"the saved repo should have left `here`: {rows:?}"
);
});
}
#[test]
fn registering_keeps_the_rows_sorted_and_never_duplicates_one() {
crate::test_support::with_isolated_registry("main-register-order", || {
let mut app = App::new(vec![], "t".into(), config::Config::default());
register_repo(&mut app, worktrees_of("two"));
register_repo(&mut app, worktrees_of("one"));
let paths: Vec<&str> = app.repos.iter().map(|r| r.path.as_str()).collect();
assert_eq!(paths, vec!["/x/one", "/x/two"], "rows are not in registry order");
let status = register_repo(&mut app, worktrees_of("one"));
assert!(status.contains("already saved"), "status: {status}");
assert_eq!(app.repos.len(), 2, "a duplicate row was added");
});
}
fn repo_app() -> App {
let mut app = App::new(vec![], "t".into(), config::Config::default());
app.repos = vec![crate::repos::Repo {
name: "one".into(),
path: "/x/one".into(),
worktrees: vec![crate::worktree::Worktree {
path: "/x/one".into(),
branch: "main".into(),
is_main: true,
is_locked: false,
is_detached: false,
}],
open: true,
registered: true,
is_global: false,
}];
app.focus = Focus::Repos;
app
}
#[test]
fn select_worktree_under_cursor_queues_the_switch_and_returns_focus_to_the_tree() {
let mut app = repo_app();
app.selected_repo_row = 1;
select_worktree_under_cursor(&mut app);
assert_eq!(app.pending_store.as_deref(), Some("/x/one"));
assert_eq!(app.selected_worktree.as_deref(), Some("/x/one"));
assert_eq!(app.focus, Focus::Tree);
}
#[test]
fn select_worktree_under_cursor_on_an_empty_sidebar_does_nothing() {
let mut app = App::new(vec![], "t".into(), config::Config::default());
app.focus = Focus::Repos;
select_worktree_under_cursor(&mut app);
assert_eq!(app.pending_store, None);
assert_eq!(app.focus, Focus::Repos, "must not steal focus with nothing to select");
}
#[test]
fn a_repo_confirm_id_is_distinguishable_from_a_task_id() {
let repo_id = format!("repo:{}", "/x/one");
assert!(repo_id.strip_prefix("repo:").is_some());
let task_id = "b4d5gfpl";
assert!(task_id.strip_prefix("repo:").is_none());
}
#[test]
fn the_3_key_only_sets_focus() {
let mut app = App::new(vec![], "t".into(), config::Config::default());
assert_eq!(app.zoom, None);
app.focus = Focus::Repos;
assert_eq!(app.focus, Focus::Repos);
assert_eq!(app.zoom, None, "must not reach for zoom -- that used to be sticky");
}
}