use clap::Parser;
use crossterm::{
cursor::SetCursorStyle,
event::{
poll as event_poll, read as event_read, Event as CrosstermEvent, KeyEvent, KeyEventKind,
KeyboardEnhancementFlags, MouseEvent, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
#[cfg(target_os = "linux")]
use fresh::services::gpm::{gpm_to_crossterm, GpmClient};
use fresh::services::tracing_setup;
use fresh::{
app::script_control::ScriptControlMode, app::Editor, config, config::DirectoryContext,
services::release_checker, services::signal_handler,
};
use ratatui::Terminal;
use std::{
io::{self, stdout},
path::PathBuf,
time::Duration,
};
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
#[derive(Parser, Debug)]
#[command(name = "fresh")]
#[command(about = "A terminal text editor with multi-cursor support", long_about = None)]
#[command(version)]
struct Args {
#[arg(value_name = "FILE")]
file: Option<PathBuf>,
#[arg(long)]
no_plugins: bool,
#[arg(long, value_name = "PATH")]
config: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
log_file: Option<PathBuf>,
#[arg(long, value_name = "LOG_FILE")]
event_log: Option<PathBuf>,
#[arg(long)]
script_mode: bool,
#[arg(long, default_value = "80")]
script_width: u16,
#[arg(long, default_value = "24")]
script_height: u16,
#[arg(long)]
script_schema: bool,
#[arg(long)]
no_session: bool,
}
#[derive(Debug)]
struct FileLocation {
path: PathBuf,
line: Option<usize>,
column: Option<usize>,
}
fn parse_file_location(input: &str) -> FileLocation {
use std::path::{Component, Path};
let full_path = PathBuf::from(input);
if full_path.is_file() {
return FileLocation {
path: full_path,
line: None,
column: None,
};
}
let has_prefix = Path::new(input)
.components()
.next()
.map(|c| matches!(c, Component::Prefix(_)))
.unwrap_or(false);
let search_start = if has_prefix {
input.find(':').map(|i| i + 1).unwrap_or(0)
} else {
0
};
let suffix = &input[search_start..];
let parts: Vec<&str> = suffix.rsplitn(3, ':').collect();
match parts.as_slice() {
[maybe_col, maybe_line, rest] => {
if let (Ok(line), Ok(col)) = (maybe_line.parse::<usize>(), maybe_col.parse::<usize>()) {
let path_str = if has_prefix {
format!("{}{}", &input[..search_start], rest)
} else {
rest.to_string()
};
return FileLocation {
path: PathBuf::from(path_str),
line: Some(line),
column: Some(col),
};
}
}
[maybe_line, rest] => {
if let Ok(line) = maybe_line.parse::<usize>() {
let path_str = if has_prefix {
format!("{}{}", &input[..search_start], rest)
} else {
rest.to_string()
};
return FileLocation {
path: PathBuf::from(path_str),
line: Some(line),
column: None,
};
}
}
_ => {}
}
FileLocation {
path: full_path,
line: None,
column: None,
}
}
fn main() -> io::Result<()> {
let args = Args::parse();
if args.script_schema {
println!("{}", fresh::app::script_control::get_command_schema());
return Ok(());
}
if args.script_mode {
tracing_subscriber::registry()
.with(fmt::layer().with_writer(io::stderr))
.with(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into()))
.init();
return run_script_control_mode(&args);
}
let log_file = args
.log_file
.unwrap_or_else(|| std::env::temp_dir().join("fresh.log"));
let mut warning_log_handle = tracing_setup::init_global(&log_file);
tracing::info!("Editor starting");
signal_handler::install_signal_handlers();
tracing::info!("Signal handlers installed");
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic| {
let _ = stdout().execute(SetCursorStyle::DefaultUserShape);
let _ = stdout().execute(PopKeyboardEnhancementFlags);
let _ = disable_raw_mode();
let _ = stdout().execute(LeaveAlternateScreen);
original_hook(panic);
}));
let config = if let Some(config_path) = &args.config {
match config::Config::load_from_file(config_path) {
Ok(cfg) => cfg,
Err(e) => {
eprintln!(
"Error: Failed to load config from {}: {}",
config_path.display(),
e
);
return Err(io::Error::new(io::ErrorKind::InvalidData, e.to_string()));
}
}
} else {
config::Config::load_or_default()
};
enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
let keyboard_flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS;
let _ = stdout().execute(PushKeyboardEnhancementFlags(keyboard_flags));
tracing::info!("Enabled keyboard enhancement flags: {:?}", keyboard_flags);
#[cfg(target_os = "linux")]
let gpm_client = match GpmClient::connect() {
Ok(client) => client,
Err(e) => {
tracing::warn!("Failed to connect to GPM: {}", e);
None
}
};
#[cfg(not(target_os = "linux"))]
let gpm_client: Option<()> = None;
if gpm_client.is_none() {
let _ = crossterm::execute!(stdout(), crossterm::event::EnableMouseCapture);
tracing::info!("Enabled crossterm mouse capture");
} else {
tracing::info!("Using GPM for mouse capture, skipping crossterm mouse protocol");
}
let _ = stdout().execute(SetCursorStyle::BlinkingBlock);
tracing::info!("Enabled blinking block cursor");
let backend = ratatui::backend::CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let size = terminal.size()?;
tracing::info!("Terminal size: {}x{}", size.width, size.height);
let file_location = args
.file
.as_ref()
.map(|p| parse_file_location(p.to_string_lossy().as_ref()));
let (working_dir, file_to_open, show_file_explorer) = if let Some(ref loc) = file_location {
if loc.path.is_dir() {
(Some(loc.path.clone()), None, true)
} else {
(None, Some(loc.path.clone()), false)
}
} else {
(None, None, false)
};
let dir_context = DirectoryContext::from_system()?;
let mut current_working_dir = working_dir;
let mut is_first_run = true;
let mut restore_session_on_restart = false;
let mut last_update_result = None;
let result = loop {
let mut editor = if args.no_plugins {
Editor::with_plugins_disabled(
config.clone(),
size.width,
size.height,
current_working_dir.clone(),
dir_context.clone(),
)?
} else {
Editor::with_working_dir(
config.clone(),
size.width,
size.height,
current_working_dir.clone(),
dir_context.clone(),
)?
};
#[cfg(target_os = "linux")]
if gpm_client.is_some() {
editor.set_gpm_active(true);
}
if is_first_run {
if let Some(log_path) = &args.event_log {
tracing::trace!("Event logging enabled: {}", log_path.display());
editor.enable_event_streaming(log_path)?;
}
}
if is_first_run {
if let Some(handle) = warning_log_handle.take() {
editor.set_warning_log(handle.receiver, handle.path);
}
}
let session_enabled = !args.no_session && file_to_open.is_none();
if (is_first_run && session_enabled) || restore_session_on_restart {
match editor.try_restore_session() {
Ok(true) => {
tracing::info!("Session restored successfully");
}
Ok(false) => {
tracing::debug!("No previous session found");
}
Err(e) => {
tracing::warn!("Failed to restore session: {}", e);
}
}
restore_session_on_restart = false;
}
if is_first_run {
if let Some(path) = &file_to_open {
editor.open_file(path)?;
if let Some(ref loc) = file_location {
if let Some(line) = loc.line {
editor.goto_line_col(line, loc.column);
}
}
}
}
if (is_first_run && show_file_explorer) || !is_first_run {
editor.show_file_explorer();
}
if is_first_run && editor.has_recovery_files().unwrap_or(false) {
tracing::info!("Recovery files found from previous session, recovering...");
match editor.recover_all_buffers() {
Ok(count) if count > 0 => {
tracing::info!("Recovered {} buffer(s)", count);
}
Ok(_) => {
tracing::info!("No buffers to recover");
}
Err(e) => {
tracing::warn!("Failed to recover buffers: {}", e);
}
}
}
if let Err(e) = editor.start_recovery_session() {
tracing::warn!("Failed to start recovery session: {}", e);
}
if !is_first_run {
editor.set_status_message(format!(
"Switched to project: {}",
current_working_dir
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| ".".to_string())
));
}
#[cfg(target_os = "linux")]
let loop_result = run_event_loop(&mut editor, &mut terminal, session_enabled, &gpm_client);
#[cfg(not(target_os = "linux"))]
let loop_result = run_event_loop(&mut editor, &mut terminal, session_enabled);
if let Err(e) = editor.end_recovery_session() {
tracing::warn!("Failed to end recovery session: {}", e);
}
last_update_result = editor.get_update_result().cloned();
if let Some(new_dir) = editor.take_restart_dir() {
tracing::info!(
"Restarting editor with new working directory: {}",
new_dir.display()
);
current_working_dir = Some(new_dir);
is_first_run = false;
restore_session_on_restart = true; drop(editor);
terminal.clear()?;
continue;
}
break loop_result;
};
let _ = crossterm::execute!(stdout(), crossterm::event::DisableMouseCapture);
let _ = stdout().execute(SetCursorStyle::DefaultUserShape);
let _ = stdout().execute(PopKeyboardEnhancementFlags);
disable_raw_mode()?;
stdout().execute(LeaveAlternateScreen)?;
if let Some(update_result) = last_update_result {
if update_result.update_available {
eprintln!();
eprintln!(
"A new version of fresh is available: {} -> {}",
release_checker::CURRENT_VERSION,
update_result.latest_version
);
if let Some(cmd) = update_result.install_method.update_command() {
eprintln!("Update with: {}", cmd);
} else {
eprintln!(
"Download from: https://github.com/sinelaw/fresh/releases/tag/v{}",
update_result.latest_version
);
}
eprintln!();
}
}
result
}
fn run_script_control_mode(args: &Args) -> io::Result<()> {
let file_location = args
.file
.as_ref()
.map(|p| parse_file_location(p.to_string_lossy().as_ref()));
let dir_context = DirectoryContext::from_system()?;
let mut control = if let Some(ref loc) = file_location {
if loc.path.is_dir() {
ScriptControlMode::with_working_dir(
args.script_width,
args.script_height,
loc.path.clone(),
dir_context,
)?
} else {
let mut ctrl =
ScriptControlMode::new(args.script_width, args.script_height, dir_context)?;
ctrl.open_file(&loc.path)?;
if let Some(line) = loc.line {
ctrl.goto_line_col(line, loc.column);
}
ctrl
}
} else {
ScriptControlMode::new(args.script_width, args.script_height, dir_context)?
};
control.run()
}
#[cfg(target_os = "linux")]
fn run_event_loop(
editor: &mut Editor,
terminal: &mut Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>,
session_enabled: bool,
gpm_client: &Option<GpmClient>,
) -> io::Result<()> {
use std::time::Instant;
const FRAME_DURATION: Duration = Duration::from_millis(16); let mut last_render = Instant::now();
let mut needs_render = true;
let mut pending_event: Option<CrosstermEvent> = None;
loop {
if editor.process_async_messages() {
needs_render = true;
}
if editor.check_mouse_hover_timer() {
needs_render = true;
}
if editor.check_warning_log() {
needs_render = true;
}
if let Err(e) = editor.auto_save_dirty_buffers() {
tracing::debug!("Auto-save error: {}", e);
}
if editor.should_quit() {
if session_enabled {
if let Err(e) = editor.save_session() {
tracing::warn!("Failed to save session: {}", e);
} else {
tracing::debug!("Session saved successfully");
}
}
break;
}
if needs_render && last_render.elapsed() >= FRAME_DURATION {
terminal.draw(|frame| editor.render(frame))?;
last_render = Instant::now();
needs_render = false;
}
let event = if let Some(e) = pending_event.take() {
Some(e)
} else {
let timeout = if needs_render {
FRAME_DURATION.saturating_sub(last_render.elapsed())
} else {
Duration::from_millis(50)
};
poll_with_gpm(gpm_client.as_ref(), timeout)?
};
let Some(event) = event else { continue };
let (event, next) = coalesce_mouse_moves(event)?;
pending_event = next;
match event {
CrosstermEvent::Key(key_event) => {
if key_event.kind == KeyEventKind::Press {
handle_key_event(editor, key_event)?;
needs_render = true;
}
}
CrosstermEvent::Mouse(mouse_event) => {
if handle_mouse_event(editor, mouse_event)? {
needs_render = true;
}
}
CrosstermEvent::Resize(w, h) => {
editor.resize(w, h);
needs_render = true;
}
_ => {}
}
}
Ok(())
}
#[cfg(not(target_os = "linux"))]
fn run_event_loop(
editor: &mut Editor,
terminal: &mut Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>,
session_enabled: bool,
) -> io::Result<()> {
use std::time::Instant;
const FRAME_DURATION: Duration = Duration::from_millis(16); let mut last_render = Instant::now();
let mut needs_render = true;
let mut pending_event: Option<CrosstermEvent> = None;
loop {
if editor.process_async_messages() {
needs_render = true;
}
if editor.check_mouse_hover_timer() {
needs_render = true;
}
if editor.check_warning_log() {
needs_render = true;
}
if let Err(e) = editor.auto_save_dirty_buffers() {
tracing::debug!("Auto-save error: {}", e);
}
if editor.should_quit() {
if session_enabled {
if let Err(e) = editor.save_session() {
tracing::warn!("Failed to save session: {}", e);
} else {
tracing::debug!("Session saved successfully");
}
}
break;
}
if needs_render && last_render.elapsed() >= FRAME_DURATION {
terminal.draw(|frame| editor.render(frame))?;
last_render = Instant::now();
needs_render = false;
}
let event = if let Some(e) = pending_event.take() {
Some(e)
} else {
let timeout = if needs_render {
FRAME_DURATION.saturating_sub(last_render.elapsed())
} else {
Duration::from_millis(50)
};
if event_poll(timeout)? {
Some(event_read()?)
} else {
None
}
};
let Some(event) = event else { continue };
let (event, next) = coalesce_mouse_moves(event)?;
pending_event = next;
match event {
CrosstermEvent::Key(key_event) => {
if key_event.kind == KeyEventKind::Press {
handle_key_event(editor, key_event)?;
needs_render = true;
}
}
CrosstermEvent::Mouse(mouse_event) => {
if handle_mouse_event(editor, mouse_event)? {
needs_render = true;
}
}
CrosstermEvent::Resize(w, h) => {
editor.resize(w, h);
needs_render = true;
}
_ => {}
}
}
Ok(())
}
#[cfg(target_os = "linux")]
fn poll_with_gpm(
gpm_client: Option<&GpmClient>,
timeout: Duration,
) -> io::Result<Option<CrosstermEvent>> {
use nix::poll::{poll, PollFd, PollFlags, PollTimeout};
use std::os::unix::io::{AsRawFd, BorrowedFd};
let Some(gpm) = gpm_client else {
return if event_poll(timeout)? {
Ok(Some(event_read()?))
} else {
Ok(None)
};
};
let stdin_fd = std::io::stdin().as_raw_fd();
let gpm_fd = gpm.fd();
tracing::trace!("GPM poll: stdin_fd={}, gpm_fd={}", stdin_fd, gpm_fd);
let stdin_borrowed = unsafe { BorrowedFd::borrow_raw(stdin_fd) };
let gpm_borrowed = unsafe { BorrowedFd::borrow_raw(gpm_fd) };
let mut poll_fds = [
PollFd::new(stdin_borrowed, PollFlags::POLLIN),
PollFd::new(gpm_borrowed, PollFlags::POLLIN),
];
let timeout_ms = timeout.as_millis().min(u16::MAX as u128) as u16;
let poll_timeout = PollTimeout::from(timeout_ms);
let ready = poll(&mut poll_fds, poll_timeout)?;
if ready == 0 {
return Ok(None);
}
let stdin_revents = poll_fds[0].revents();
let gpm_revents = poll_fds[1].revents();
tracing::trace!(
"GPM poll: ready={}, stdin_revents={:?}, gpm_revents={:?}",
ready,
stdin_revents,
gpm_revents
);
if gpm_revents.map_or(false, |r| r.contains(PollFlags::POLLIN)) {
tracing::trace!("GPM poll: GPM fd has data, reading event...");
match gpm.read_event() {
Ok(Some(gpm_event)) => {
tracing::trace!(
"GPM event received: x={}, y={}, buttons={}, type=0x{:x}",
gpm_event.x,
gpm_event.y,
gpm_event.buttons.0,
gpm_event.event_type
);
if let Some(mouse_event) = gpm_to_crossterm(&gpm_event) {
tracing::trace!("GPM event converted to crossterm: {:?}", mouse_event);
return Ok(Some(CrosstermEvent::Mouse(mouse_event)));
} else {
tracing::debug!("GPM event could not be converted to crossterm event");
}
}
Ok(None) => {
tracing::trace!("GPM poll: read_event returned None");
}
Err(e) => {
tracing::warn!("GPM poll: read_event error: {}", e);
}
}
}
if stdin_revents.map_or(false, |r| r.contains(PollFlags::POLLIN)) {
if event_poll(Duration::ZERO)? {
return Ok(Some(event_read()?));
}
}
Ok(None)
}
fn handle_key_event(editor: &mut Editor, key_event: KeyEvent) -> io::Result<()> {
tracing::trace!(
"Key event received: code={:?}, modifiers={:?}, kind={:?}, state={:?}",
key_event.code,
key_event.modifiers,
key_event.kind,
key_event.state
);
let key_code = format!("{:?}", key_event.code);
let modifiers = format!("{:?}", key_event.modifiers);
editor.log_keystroke(&key_code, &modifiers);
editor.handle_key(key_event.code, key_event.modifiers)?;
Ok(())
}
fn handle_mouse_event(editor: &mut Editor, mouse_event: MouseEvent) -> io::Result<bool> {
tracing::debug!(
"Mouse event received: kind={:?}, column={}, row={}, modifiers={:?}",
mouse_event.kind,
mouse_event.column,
mouse_event.row,
mouse_event.modifiers
);
editor.handle_mouse(mouse_event)
}
fn coalesce_mouse_moves(
event: CrosstermEvent,
) -> io::Result<(CrosstermEvent, Option<CrosstermEvent>)> {
use crossterm::event::MouseEventKind;
if !matches!(&event, CrosstermEvent::Mouse(m) if m.kind == MouseEventKind::Moved) {
return Ok((event, None));
}
let mut latest = event;
while event_poll(Duration::ZERO)? {
let next = event_read()?;
if matches!(&next, CrosstermEvent::Mouse(m) if m.kind == MouseEventKind::Moved) {
latest = next; } else {
return Ok((latest, Some(next))); }
}
Ok((latest, None))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_file_location_simple_path() {
let loc = parse_file_location("foo.txt");
assert_eq!(loc.path, PathBuf::from("foo.txt"));
assert_eq!(loc.line, None);
assert_eq!(loc.column, None);
}
#[test]
fn test_parse_file_location_with_line() {
let loc = parse_file_location("foo.txt:42");
assert_eq!(loc.path, PathBuf::from("foo.txt"));
assert_eq!(loc.line, Some(42));
assert_eq!(loc.column, None);
}
#[test]
fn test_parse_file_location_with_line_and_col() {
let loc = parse_file_location("foo.txt:42:10");
assert_eq!(loc.path, PathBuf::from("foo.txt"));
assert_eq!(loc.line, Some(42));
assert_eq!(loc.column, Some(10));
}
#[test]
fn test_parse_file_location_absolute_path() {
let loc = parse_file_location("/home/user/foo.txt:100:5");
assert_eq!(loc.path, PathBuf::from("/home/user/foo.txt"));
assert_eq!(loc.line, Some(100));
assert_eq!(loc.column, Some(5));
}
#[test]
fn test_parse_file_location_no_numbers_after_colon() {
let loc = parse_file_location("foo:bar");
assert_eq!(loc.path, PathBuf::from("foo:bar"));
assert_eq!(loc.line, None);
assert_eq!(loc.column, None);
}
#[test]
fn test_parse_file_location_mixed_suffix() {
let loc = parse_file_location("foo:10:bar");
assert_eq!(loc.path, PathBuf::from("foo:10:bar"));
assert_eq!(loc.line, None);
assert_eq!(loc.column, None);
}
#[test]
fn test_parse_file_location_line_only_not_col() {
let loc = parse_file_location("foo:bar:10");
assert_eq!(loc.path, PathBuf::from("foo:bar:10"));
assert_eq!(loc.line, None);
assert_eq!(loc.column, None);
}
}
#[cfg(all(test, not(windows)))]
mod proptests {
use super::*;
use proptest::prelude::*;
fn unix_path_strategy() -> impl Strategy<Value = String> {
prop::collection::vec("[a-zA-Z0-9._-]+", 1..5).prop_map(|components| components.join("/"))
}
proptest! {
#[test]
fn roundtrip_line_col(
path in unix_path_strategy(),
line in 1usize..10000,
col in 1usize..1000
) {
let input = format!("{}:{}:{}", path, line, col);
let loc = parse_file_location(&input);
prop_assert_eq!(loc.path, PathBuf::from(&path));
prop_assert_eq!(loc.line, Some(line));
prop_assert_eq!(loc.column, Some(col));
}
#[test]
fn roundtrip_line_only(
path in unix_path_strategy(),
line in 1usize..10000
) {
let input = format!("{}:{}", path, line);
let loc = parse_file_location(&input);
prop_assert_eq!(loc.path, PathBuf::from(&path));
prop_assert_eq!(loc.line, Some(line));
prop_assert_eq!(loc.column, None);
}
#[test]
fn path_without_numbers_unchanged(
path in unix_path_strategy()
) {
let loc = parse_file_location(&path);
prop_assert_eq!(loc.path, PathBuf::from(&path));
prop_assert_eq!(loc.line, None);
prop_assert_eq!(loc.column, None);
}
#[test]
fn parsed_values_match_input(
path in unix_path_strategy(),
line in 0usize..10000,
col in 0usize..1000
) {
let input = format!("{}:{}:{}", path, line, col);
let loc = parse_file_location(&input);
prop_assert_eq!(loc.line, Some(line));
prop_assert_eq!(loc.column, Some(col));
}
}
}