use anyhow::{bail, Context, Result};
use clap::Parser;
use crossbeam_channel::unbounded;
use log::{error, warn, LevelFilter};
use projectable::{
app::{component::Drawable, App, TerminalEvent},
config::{self, Config, GlobList, Merge},
external_event,
logger::EVENT_LOGGER,
marks::{self, Marks},
};
use std::{
cell::RefCell,
env, fs,
io::{self, Stdout},
panic,
path::PathBuf,
process::Command,
rc::Rc,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::Duration,
};
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
use scopeguard::{defer, defer_on_success};
use tui::{backend::CrosstermBackend, Terminal};
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
dir: Option<PathBuf>,
#[arg(long, help = "Debug mode")]
debug: bool,
#[arg(short, long, help = "Print config location", conflicts_with_all = ["marks_file", "write_config", "make_config"])]
config: bool,
#[arg(long, help = "Print marks file location", conflicts_with_all = ["config", "write_config", "make_config"])]
marks_file: bool,
#[arg(long, help = "Create a default config", conflicts_with_all = ["marks_file", "config", "make_config"])]
write_config: bool,
#[arg(long, help = "Create a config file", conflicts_with_all = ["marks_file", "write_config", "config"])]
make_config: bool,
}
fn main() -> Result<()> {
let args = Args::parse();
if args.config {
println!(
"{}",
config::get_config_home()
.context("could not find config home")?
.display()
);
return Ok(());
} else if args.marks_file {
println!(
"{}",
marks::get_marks_file()
.context("could not find config home")?
.display()
);
return Ok(());
} else if args.write_config {
let config_file = config::get_config_home()
.context("could not find config home")?
.join("config.toml");
fs::create_dir_all(
config_file
.parent()
.expect("config file should have parent"),
)
.context("error making config file")?;
#[cfg(not(target_os = "windows"))]
fs::write(&config_file, include_str!("./config_defaults/unix.toml"))?;
#[cfg(target_os = "windows")]
fs::write(&config_file, include_str!("./config_defaults/windows.toml"))?;
println!("Wrote to config file at {}!", config_file.display());
return Ok(());
} else if args.make_config {
let config_file = config::get_config_home()
.context("could not find config home")?
.join("config.toml");
if config_file.exists() {
bail!("config file already exists at {}", config_file.display());
}
fs::create_dir_all(
config_file
.parent()
.expect("config file should have parent"),
)
.context("error making config file")?;
fs::File::create(&config_file)?;
println!("Wrote to config file at {}!", config_file.display());
return Ok(());
}
setup()?;
defer_on_success! {
shut_down();
}
let config = Rc::new(get_config().context("error gettting project root")?);
log::set_logger(&EVENT_LOGGER)
.map(|()| {
#[cfg(debug_assertions)]
log::set_max_level(LevelFilter::Debug);
#[cfg(not(debug_assertions))]
if !args.debug {
log::set_max_level(LevelFilter::Info);
} else {
log::set_max_level(LevelFilter::Trace);
}
})
.expect("should not have problem initializing logger");
let conflicts = config.check_conflicts();
for conflict in conflicts {
warn!("{conflict}");
}
let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))
.context("error initializing stdout terminal")?;
let root = find_project_root(&config.project_roots)
.context("error getting possible project roots")?
.unwrap_or(env::current_dir().context("error reading current directory")?);
let dir = args.dir.map_or(
env::current_dir().context("error gettiing current directory")?,
|dir| root.join(dir),
);
let marks = Rc::new(RefCell::new(
Marks::from_marks_file(&root).context("error getting marks file")?,
));
let mut app = App::new(root, dir, Rc::clone(&config), Rc::clone(&marks))
.context("failed to create app")?;
run_app(&mut terminal, &mut app, Rc::clone(&config), marks)
.context("error during app runtime")?;
Ok(())
}
fn get_config() -> Result<Config> {
let mut config = config::get_config_home()
.map(|path| -> Result<Option<Config>> {
if !path.join("config.toml").exists() {
return Ok(None);
}
let contents = fs::read_to_string(path.join("config.toml"))?;
Ok(Some(toml::from_str::<Config>(&contents)?))
})
.unwrap_or(Ok(Some(Config::default())))?
.unwrap_or(Config::default());
if let Some(local_config) = find_local_config()? {
let contents = fs::read_to_string(local_config)?;
let local_config = toml::from_str(&contents)?;
config.merge(local_config);
}
Ok(config)
}
fn find_project_root(globs: &GlobList) -> Result<Option<PathBuf>> {
let start = env::current_dir()?;
Ok(start
.ancestors()
.take_while(|path| *path != dirs_next::home_dir().unwrap_or_default())
.find_map(|path| {
fs::read_dir(path)
.ok()?
.filter_map(|entry| entry.ok())
.any(|entry| globs.is_match(entry.path()))
.then(|| path.to_path_buf())
}))
}
fn find_local_config() -> Result<Option<PathBuf>> {
let start = env::current_dir()?;
Ok(start.ancestors().find_map(|path| {
let new_path = path.join(".projectable.toml");
if new_path.exists() {
Some(new_path)
} else {
None
}
}))
}
fn run_app(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
app: &mut App,
config: Rc<Config>,
marks: Rc<RefCell<Marks>>,
) -> Result<()> {
let (event_send, event_recv) = unbounded();
let stop = Arc::new(AtomicBool::new(false));
let mut input_handle = external_event::crossterm_watch(event_send.clone(), Arc::clone(&stop));
let (_watcher, mut change_buffer) = external_event::fs_watch(
app.path(),
event_send.clone(),
config.filetree.refresh_time,
Arc::clone(&stop),
)
.context("error starting filesystem refresh watcher")?;
let thread_stop = Arc::new(AtomicBool::new(false));
let mut first_run = true;
loop {
if first_run {
first_run = false;
} else {
match event_recv.recv().context("error receiving event") {
Ok(event) => {
if let Err(err) = app.handle_event(&event) {
error!("{err:#}");
}
}
Err(err) => bail!(err),
}
}
match app.update() {
Ok(Some(event)) => match event {
TerminalEvent::OpenFile(path) => {
execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)
.context("error leaving screen")?;
disable_raw_mode().context("error disabling raw mode")?;
defer! {
execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture).expect("error setting up screen");
enable_raw_mode().expect("error enabling raw mode");
io::stdout().execute(EnterAlternateScreen).expect("error entering alternate screen");
terminal.clear().expect("error clearing terminal");
}
stop.store(true, Ordering::Release);
input_handle.join().expect("error joining thread");
Command::new(&config.editor_cmd)
.arg(path)
.status()
.context("error in editor")?;
stop.store(false, Ordering::Release);
change_buffer.flush(&event_send);
input_handle =
external_event::crossterm_watch(event_send.clone(), Arc::clone(&stop));
}
TerminalEvent::RunCommandThreaded(expr) => {
thread_stop.store(false, Ordering::Release);
external_event::run_cmd(
expr,
event_send.clone(),
Duration::from_millis(300),
thread_stop.clone(),
)
.context("error starting command")?
}
TerminalEvent::RunCommand(expr) => {
execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)
.context("error leaving current screen")?;
disable_raw_mode().context("error disablling raw mode")?;
defer! {
execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture).expect("error setting up screen");
enable_raw_mode().expect("error enabling raw mode");
io::stdout().execute(EnterAlternateScreen).expect("error entering alternate screen");
terminal.clear().expect("error clearing terminal");
}
stop.store(true, Ordering::Release);
input_handle.join().expect("error joining thread");
expr.start()
.context("error starting command")?
.wait()
.context("error waiting for command completion")?;
stop.store(false, Ordering::Release);
change_buffer.flush(&event_send);
input_handle =
external_event::crossterm_watch(event_send.clone(), Arc::clone(&stop));
}
TerminalEvent::StopAllCommands => thread_stop.store(true, Ordering::Release),
},
Err(err) => {
error!("{err:#}");
}
Ok(None) => {}
}
terminal
.draw(|f| app.draw(f, f.size()).unwrap())
.context("error rendering terminal")?;
if app.should_quit() {
marks.borrow_mut().write().context("error writing marks")?;
return Ok(());
}
}
}
fn setup() -> Result<()> {
enable_raw_mode().context("error enabling raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
.context("error setting up screen")?;
panic::set_hook(Box::new(|info| {
shut_down();
let meta = human_panic::metadata!();
let file_path = human_panic::handle_dump(&meta, info);
human_panic::print_msg(file_path, &meta)
.expect("human-panic: printing error message to console failed");
}));
Ok(())
}
fn shut_down() {
let mut stdout = io::stdout();
let leave_screen = execute!(stdout, LeaveAlternateScreen);
if let Err(err) = leave_screen {
eprintln!("could not leave screen:\n{err}");
}
let disable_raw_mode = disable_raw_mode();
if let Err(err) = disable_raw_mode {
eprintln!("could not disable raw mode:\n{err}");
}
let disable_mouse_capture = execute!(stdout, DisableMouseCapture);
if let Err(err) = disable_mouse_capture {
eprintln!("could not disable mouse capture:\n{err}");
}
}