use anyhow::{Context, Result as AnyhowResult};
use clap::Parser;
use crossterm::event::{
poll as event_poll, read as event_read, Event as CrosstermEvent, KeyEvent, KeyEventKind,
MouseEvent,
};
use fresh::input::key_translator::KeyTranslator;
#[cfg(target_os = "linux")]
use fresh::services::gpm::{gpm_to_crossterm, GpmClient};
use fresh::services::terminal_modes::{self, KeyboardConfig, TerminalModes};
use fresh::services::tracing_setup;
use fresh::{
app::Editor, client, config, config_io::DirectoryContext, server::SocketPaths,
services::release_checker, services::remote, services::signal_handler,
services::tracing_setup::TracingHandles, workspace,
};
use ratatui::Terminal;
use std::{
io::{self, stdout},
path::{Path, PathBuf},
time::Duration,
};
#[derive(Parser, Debug)]
#[command(name = "fresh")]
#[command(version, propagate_version = true)]
#[command(after_help = concat!(
"Commands (use --cmd):\n",
" config show Print effective configuration\n",
" config paths Show directories used by Fresh\n",
" grammar list List all available grammars (with source info)\n",
" init Initialize a new plugin/theme/language\n",
"\n",
"Session commands:\n",
" session list List active sessions\n",
" session attach [NAME] Attach to a session (NAME or current dir)\n",
" session new NAME Start a new named session\n",
" session kill [NAME] Terminate a session\n",
" session open-file NAME FILES [--wait] Open files in session (--wait blocks until done)\n",
"\n",
"File location syntax:\n",
" file.txt:10 Open at line 10\n",
" file.txt:10:5 Open at line 10, column 5\n",
" file.txt:10-20 Select lines 10 to 20\n",
" file.txt:10:5-20:1 Select from line 10 col 5 to line 20 col 1\n",
" file.txt:10@\"msg\" Open at line 10 with markdown popup message\n",
" file.txt:10-20@\"msg\" Select range with markdown popup message\n",
" Tip: use single quotes to avoid shell expansion, e.g. 'file.txt:10@\"msg\"'\n",
"\n",
"Examples:\n",
" fresh file.txt Open a file\n",
" fresh 'file.txt:10-20@\"Check this code\"' Open with range selected and popup\n",
" fresh -a Attach to session (current dir)\n",
" fresh -a mysession Attach to named session\n",
" fresh --cmd session new proj Start session named 'proj'\n",
" fresh --cmd session open-file . main.rs Open file in current dir session\n",
" fresh --cmd session open-file proj a.rs Open file in 'proj' session\n",
"\n",
"Guided walkthrough with --wait:\n",
" The --wait flag blocks the CLI process until the user dismisses the popup\n",
" (if @\"message\" was given) or closes the buffer (if no message). This lets\n",
" a script or tool open files sequentially, waiting for the user to finish\n",
" with each one before moving on.\n",
"\n",
" Use NAME '.' to target the session for the current working directory.\n",
" A session is started automatically if one isn't already running. When a\n",
" new session is started, the client attaches interactively (--wait is ignored).\n",
"\n",
" To show a file with an annotation, combine range selection with @\"msg\":\n",
" fresh --cmd session open-file . 'src/main.rs:10-25@\"msg\"' --wait\n",
"\n",
" The message supports markdown. Use real newlines (not \\n literals) in\n",
" the shell string for multi-line messages. For example with $'...':\n",
" fresh --cmd session open-file . \\\n",
" $'src/main.rs:10-25@\"**Title**\\nBody text here\"' --wait\n",
"\n",
" To walk through multiple locations, run commands sequentially — each\n",
" one blocks until the user presses Escape (popup) or closes the buffer:\n",
" fresh --cmd session open-file . 'a.rs:1-10@\"Step 1\"' --wait\n",
" fresh --cmd session open-file . 'b.rs:5-20@\"Step 2\"' --wait\n",
" fresh --cmd session open-file . 'c.rs:30@\"Step 3\"' --wait\n",
"\n",
" Use as git's editor:\n",
" git config core.editor 'fresh --cmd session open-file . --wait'\n",
"\n",
"Documentation: https://getfresh.dev/docs"
))]
struct Cli {
#[arg(long, num_args = 1.., value_name = "COMMAND", allow_hyphen_values = true)]
cmd: Vec<String>,
#[arg(value_name = "FILES")]
files: Vec<String>,
#[arg(short = 'a', long, value_name = "NAME", num_args = 0..=1, default_missing_value = "")]
attach: Option<String>,
#[arg(long)]
stdin: bool,
#[arg(long)]
no_plugins: bool,
#[arg(long)]
no_init: bool,
#[arg(long)]
safe: 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, alias = "no-session", conflicts_with = "restore")]
no_restore: bool,
#[arg(long)]
restore: bool,
#[arg(long)]
no_upgrade_check: bool,
#[arg(long, value_name = "LOCALE")]
locale: Option<String>,
#[arg(long, hide = true)]
server: bool,
#[arg(long, hide = true, value_name = "NAME")]
session_name: Option<String>,
#[arg(long, hide = true, value_name = "URL")]
ssh_url: Option<String>,
#[arg(long, hide = true)]
dump_config: bool,
#[arg(long, hide = true)]
show_paths: bool,
#[arg(long, hide = true, value_name = "PLUGIN_PATH")]
check_plugin: Option<PathBuf>,
#[arg(long, hide = true, value_name = "TYPE")]
init: Option<Option<String>>,
#[cfg(feature = "gui")]
#[arg(long)]
gui: bool,
}
#[derive(Debug)]
#[allow(dead_code)]
struct Args {
files: Vec<String>,
stdin: bool,
no_plugins: bool,
no_init: bool,
safe: bool,
config: Option<PathBuf>,
log_file: Option<PathBuf>,
event_log: Option<PathBuf>,
no_session: bool,
force_restore: bool,
no_upgrade_check: bool,
dump_config: bool,
show_paths: bool,
list_grammars: bool,
locale: Option<String>,
check_plugin: Option<PathBuf>,
init: Option<Option<String>>,
server: bool,
ssh_url: Option<String>,
attach: bool,
list_sessions: bool,
session_name: Option<String>,
kill: Option<Option<String>>,
open_files_in_session: Option<(Option<String>, Vec<String>, bool)>,
#[cfg(feature = "gui")]
gui: bool,
}
impl From<Cli> for Args {
fn from(cli: Cli) -> Self {
let list_grammars = if !cli.cmd.is_empty() {
let cmd_args: Vec<&str> = cli.cmd.iter().map(|s| s.as_str()).collect();
matches!(
cmd_args.as_slice(),
["grammar", "list"] | ["grammars", "list"] | ["grammar", "ls"] | ["grammars"]
)
} else {
false
};
let (
list_sessions,
kill,
attach,
session_name,
dump_config,
show_paths,
init,
files,
open_files_in_session,
) = if !cli.cmd.is_empty() {
let cmd_args: Vec<&str> = cli.cmd.iter().map(|s| s.as_str()).collect();
match cmd_args.as_slice() {
["session", "list", ..]
| ["s", "list", ..]
| ["session", "ls", ..]
| ["s", "ls", ..] => (true, None, false, None, false, false, None, cli.files, None),
["session", "open-file", name, files @ ..]
| ["s", "open-file", name, files @ ..] => {
let session = if *name == "." {
None
} else {
Some((*name).to_string())
};
let wait = files.contains(&"--wait");
let file_list: Vec<String> = files
.iter()
.filter(|s| **s != "--wait")
.map(|s| (*s).to_string())
.collect();
(
false,
None,
false,
None,
false,
false,
None,
vec![],
Some((session, file_list, wait)),
)
}
["session", "attach", name, ..]
| ["s", "attach", name, ..]
| ["session", "a", name, ..]
| ["s", "a", name, ..] => (
false,
None,
true,
Some((*name).to_string()),
false,
false,
None,
cli.files,
None,
),
["session", "attach"] | ["s", "attach"] | ["session", "a"] | ["s", "a"] => {
(false, None, true, None, false, false, None, cli.files, None)
}
["session", "new", name, rest @ ..]
| ["s", "new", name, rest @ ..]
| ["session", "n", name, rest @ ..]
| ["s", "n", name, rest @ ..] => {
let files: Vec<String> = rest.iter().map(|s| (*s).to_string()).collect();
(
false,
None,
true,
Some((*name).to_string()),
false,
false,
None,
files,
None,
)
}
["session", "kill", "--all"]
| ["s", "kill", "--all"]
| ["session", "k", "--all"]
| ["s", "k", "--all"] => (
false,
Some(Some("--all".to_string())),
false,
None,
false,
false,
None,
cli.files,
None,
),
["session", "kill", name, ..]
| ["s", "kill", name, ..]
| ["session", "k", name, ..]
| ["s", "k", name, ..] => (
false,
Some(Some((*name).to_string())),
false,
None,
false,
false,
None,
cli.files,
None,
),
["session", "kill"] | ["s", "kill"] | ["session", "k"] | ["s", "k"] => (
false,
Some(None),
false,
None,
false,
false,
None,
cli.files,
None,
),
["session", "info", name, ..] | ["s", "info", name, ..] => {
let _ = name;
(true, None, false, None, false, false, None, cli.files, None)
}
["session", "info"] | ["s", "info"] => {
(true, None, false, None, false, false, None, cli.files, None)
}
["config", "show"] | ["config", "dump"] => {
(false, None, false, None, true, false, None, cli.files, None)
}
["config", "paths"] => {
(false, None, false, None, false, true, None, cli.files, None)
}
["init", pkg_type, ..] => (
false,
None,
false,
None,
false,
false,
Some(Some((*pkg_type).to_string())),
cli.files,
None,
),
["init"] => (
false,
None,
false,
None,
false,
false,
Some(None),
cli.files,
None,
),
["grammar", "list"] | ["grammars", "list"] | ["grammar", "ls"] | ["grammars"] => (
false, None, false, None, false, false, None, cli.files, None,
),
_ => {
eprintln!("Unknown command: {}", cli.cmd.join(" "));
eprintln!("Available commands: session (list|attach|new|kill|info|open-file), config (show|paths), grammar (list), init");
std::process::exit(1);
}
}
} else {
let attach = cli.attach.is_some();
let session_name = if attach {
let name = cli.attach.unwrap();
if name.is_empty() || name == "." {
cli.session_name
} else {
Some(name)
}
} else {
cli.session_name
};
(
false,
None,
attach,
session_name,
cli.dump_config,
cli.show_paths,
cli.init,
cli.files,
None,
)
};
let safe = cli.safe;
let no_plugins = cli.no_plugins || safe;
let no_init = cli.no_init || safe;
Args {
files,
stdin: cli.stdin,
no_plugins,
no_init,
safe,
config: cli.config,
log_file: cli.log_file,
event_log: cli.event_log,
no_session: cli.no_restore,
force_restore: cli.restore,
no_upgrade_check: cli.no_upgrade_check,
dump_config,
show_paths,
list_grammars,
locale: cli.locale,
check_plugin: cli.check_plugin,
init,
server: cli.server,
ssh_url: cli.ssh_url,
attach,
list_sessions,
session_name,
kill,
open_files_in_session,
#[cfg(feature = "gui")]
gui: cli.gui,
}
}
}
#[derive(Debug)]
struct FileLocation {
path: PathBuf,
line: Option<usize>,
column: Option<usize>,
end_line: Option<usize>,
end_column: Option<usize>,
message: Option<String>,
}
#[derive(Debug, Clone)]
struct RemoteLocation {
user: String,
host: String,
port: Option<u16>,
path: String,
line: Option<usize>,
column: Option<usize>,
}
#[derive(Debug)]
enum ParsedLocation {
Local(FileLocation),
Remote(RemoteLocation),
}
struct IterationOutcome {
loop_result: AnyhowResult<()>,
update_result: Option<release_checker::ReleaseCheckResult>,
restart_dir: Option<PathBuf>,
}
struct SetupState {
config: config::Config,
tracing_handles: Option<TracingHandles>,
terminal: Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>,
terminal_size: (u16, u16),
file_locations: Vec<FileLocation>,
show_file_explorer: bool,
dir_context: DirectoryContext,
current_working_dir: Option<PathBuf>,
stdin_stream: Option<StdinStreamState>,
authority: fresh::services::authority::Authority,
_remote_session: Option<RemoteSession>,
key_translator: KeyTranslator,
#[cfg(target_os = "linux")]
gpm_client: Option<GpmClient>,
#[cfg(not(target_os = "linux"))]
gpm_client: Option<()>,
terminal_modes: TerminalModes,
}
#[cfg(unix)]
pub struct StdinStreamState {
pub temp_path: PathBuf,
pub thread_handle: Option<std::thread::JoinHandle<anyhow::Result<()>>>,
}
#[cfg(unix)]
fn start_stdin_streaming() -> AnyhowResult<StdinStreamState> {
use std::fs::File;
use std::os::unix::io::{AsRawFd, FromRawFd};
let stdin_fd = io::stdin().as_raw_fd();
let pipe_fd = unsafe { libc::dup(stdin_fd) };
if pipe_fd == -1 {
anyhow::bail!("Failed to dup stdin: {}", io::Error::last_os_error());
}
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join(format!("fresh-stdin-{}.tmp", std::process::id()));
File::create(&temp_path)?;
reopen_stdin_from_tty()?;
tracing::info!("Reopened stdin from /dev/tty for terminal input");
let temp_path_clone = temp_path.clone();
let thread_handle = std::thread::spawn(move || {
use std::io::{Read, Write};
let mut pipe_file = unsafe { File::from_raw_fd(pipe_fd) };
let mut temp_file = std::fs::OpenOptions::new()
.append(true)
.open(&temp_path_clone)?;
const CHUNK_SIZE: usize = 64 * 1024;
let mut buffer = vec![0u8; CHUNK_SIZE];
loop {
let bytes_read = pipe_file.read(&mut buffer)?;
if bytes_read == 0 {
break; }
temp_file.write_all(&buffer[..bytes_read])?;
temp_file.flush()?;
}
tracing::info!("Stdin streaming complete");
Ok(())
});
Ok(StdinStreamState {
temp_path,
thread_handle: Some(thread_handle),
})
}
#[cfg(windows)]
pub struct StdinStreamState {
pub temp_path: PathBuf,
pub thread_handle: Option<std::thread::JoinHandle<anyhow::Result<()>>>,
}
#[cfg(windows)]
fn start_stdin_streaming() -> AnyhowResult<StdinStreamState> {
use std::fs::File;
use std::io::{Read, Write};
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use windows_sys::Win32::Foundation::{
DuplicateHandle, DUPLICATE_SAME_ACCESS, HANDLE, INVALID_HANDLE_VALUE,
};
use windows_sys::Win32::System::Console::GetStdHandle;
use windows_sys::Win32::System::Console::STD_INPUT_HANDLE;
use windows_sys::Win32::System::Threading::GetCurrentProcess;
let stdin_handle = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
if stdin_handle == INVALID_HANDLE_VALUE || stdin_handle.is_null() {
anyhow::bail!("Failed to get stdin handle");
}
let mut duplicated_handle: HANDLE = std::ptr::null_mut();
let current_process = unsafe { GetCurrentProcess() };
let success = unsafe {
DuplicateHandle(
current_process,
stdin_handle,
current_process,
&mut duplicated_handle,
0,
0, DUPLICATE_SAME_ACCESS,
)
};
if success == 0 {
anyhow::bail!(
"Failed to duplicate stdin handle: {}",
io::Error::last_os_error()
);
}
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join(format!("fresh-stdin-{}.txt", std::process::id()));
let temp_path_clone = temp_path.clone();
let handle_as_usize = duplicated_handle as usize;
let thread_handle = std::thread::spawn(move || -> AnyhowResult<()> {
let raw_handle = handle_as_usize as *mut std::ffi::c_void;
let owned_handle = unsafe { OwnedHandle::from_raw_handle(raw_handle) };
let mut pipe_reader = unsafe { File::from_raw_handle(owned_handle.as_raw_handle()) };
std::mem::forget(owned_handle);
let mut temp_file = File::create(&temp_path_clone)?;
let mut buffer = [0u8; 8192];
loop {
match pipe_reader.read(&mut buffer) {
Ok(0) => break, Ok(n) => {
temp_file.write_all(&buffer[..n])?;
}
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => break,
Err(e) => return Err(e.into()),
}
}
temp_file.flush()?;
Ok(())
});
Ok(StdinStreamState {
temp_path,
thread_handle: Some(thread_handle),
})
}
fn stdin_has_data() -> bool {
use std::io::IsTerminal;
!io::stdin().is_terminal()
}
#[cfg(unix)]
fn reopen_stdin_from_tty() -> AnyhowResult<()> {
use std::fs::File;
use std::os::unix::io::AsRawFd;
let tty = File::open("/dev/tty")?;
let result = unsafe { libc::dup2(tty.as_raw_fd(), libc::STDIN_FILENO) };
if result == -1 {
anyhow::bail!(io::Error::last_os_error());
}
Ok(())
}
#[cfg(windows)]
fn reopen_stdin_from_tty() -> AnyhowResult<()> {
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
use windows_sys::Win32::Storage::FileSystem::{
CreateFileW, FILE_GENERIC_READ, FILE_SHARE_READ, OPEN_EXISTING,
};
use windows_sys::Win32::System::Console::{SetStdHandle, STD_INPUT_HANDLE};
let conin: Vec<u16> = "CONIN$\0".encode_utf16().collect();
let conin_handle = unsafe {
CreateFileW(
conin.as_ptr(),
FILE_GENERIC_READ,
FILE_SHARE_READ,
std::ptr::null(),
OPEN_EXISTING,
0,
std::ptr::null_mut(),
)
};
if conin_handle == INVALID_HANDLE_VALUE {
anyhow::bail!("Failed to open CONIN$: {}", io::Error::last_os_error());
}
let success = unsafe { SetStdHandle(STD_INPUT_HANDLE, conin_handle) };
if success == 0 {
anyhow::bail!(
"Failed to set stdin to CONIN$: {}",
io::Error::last_os_error()
);
}
Ok(())
}
fn handle_first_run_setup(
editor: &mut Editor,
args: &Args,
file_locations: &[FileLocation],
show_file_explorer: bool,
stdin_stream: &mut Option<StdinStreamState>,
workspace_enabled: bool,
) -> AnyhowResult<()> {
if let Some(log_path) = &args.event_log {
tracing::trace!("Event logging enabled: {}", log_path.display());
editor.enable_event_streaming(log_path)?;
}
let restore_full_session = workspace_enabled
&& (args.force_restore || editor.config().editor.restore_previous_session);
if restore_full_session {
match editor.try_restore_workspace() {
Ok(true) => {
tracing::info!("Workspace restored successfully");
}
Ok(false) => {
tracing::debug!("No previous workspace found");
}
Err(e) => {
tracing::warn!("Failed to restore workspace: {}", e);
}
}
} else {
if !workspace_enabled {
tracing::info!("Skipping workspace restore: --no-restore was specified");
} else {
tracing::info!(
"Skipping workspace restore: editor.restore_previous_session is disabled"
);
}
match editor.try_restore_hot_exit_buffers() {
Ok(n) if n > 0 => {
tracing::info!(
"Restored {} hot-exit buffer(s) despite skipping session restore",
n
);
}
Ok(_) => {}
Err(e) => {
tracing::warn!("Failed to restore hot-exit buffers: {}", e);
}
}
}
if let Some(mut stream_state) = stdin_stream.take() {
tracing::info!("Opening stdin buffer from: {:?}", stream_state.temp_path);
editor.open_stdin_buffer(&stream_state.temp_path, stream_state.thread_handle.take())?;
}
let mut has_cli_files = false;
for loc in file_locations {
if loc.path.is_dir() {
continue;
}
tracing::info!("[SYNTAX DEBUG] Queueing CLI file for open: {:?}", loc.path);
editor.queue_file_open(
loc.path.clone(),
loc.line,
loc.column,
loc.end_line,
loc.end_column,
loc.message.clone(),
None,
);
has_cli_files = true;
}
if has_cli_files {
editor.schedule_hot_exit_recovery();
}
if show_file_explorer {
editor.show_file_explorer();
}
if 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);
}
}
}
Ok(())
}
fn build_file_requests(
files: &[String],
working_dir: &std::path::Path,
) -> Vec<fresh::server::protocol::FileRequest> {
use fresh::server::protocol::FileRequest;
let mut requests = Vec::new();
for f in files {
let loc = parse_file_location(f);
let abs_path = if loc.path.is_relative() {
working_dir.join(&loc.path)
} else {
loc.path.clone()
};
let canonical_path = abs_path.canonicalize().unwrap_or(abs_path);
if canonical_path.is_dir() {
continue;
}
requests.push(FileRequest {
path: canonical_path.to_string_lossy().to_string(),
line: loc.line,
column: loc.column,
end_line: loc.end_line,
end_column: loc.end_column,
message: loc.message,
});
}
requests
}
fn parse_file_location(input: &str) -> FileLocation {
use std::path::{Component, Path};
let empty = FileLocation {
path: PathBuf::from(input),
line: None,
column: None,
end_line: None,
end_column: None,
message: None,
};
let full_path = PathBuf::from(input);
if full_path.is_file() {
return FileLocation {
path: full_path,
..empty
};
}
let (input_no_msg, message) = extract_message_suffix(input);
let has_prefix = Path::new(input_no_msg)
.components()
.next()
.map(|c| matches!(c, Component::Prefix(_)))
.unwrap_or(false);
let search_start = if has_prefix {
input_no_msg.find(':').map(|i| i + 1).unwrap_or(0)
} else {
0
};
let suffix = &input_no_msg[search_start..];
if let Some(first_colon) = suffix.find(':') {
let location_part = &suffix[first_colon + 1..];
if location_part.contains('-') {
let path_part = &suffix[..first_colon];
let path_str = if has_prefix {
format!("{}{}", &input_no_msg[..search_start], path_part)
} else {
path_part.to_string()
};
if let Some(result) =
parse_range(location_part, PathBuf::from(path_str), message.clone())
{
return result;
}
}
}
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_no_msg[..search_start], rest)
} else {
rest.to_string()
};
return FileLocation {
path: PathBuf::from(path_str),
line: Some(line),
column: Some(col),
message,
..empty
};
}
}
[maybe_line, rest] => {
if let Ok(line) = maybe_line.parse::<usize>() {
let path_str = if has_prefix {
format!("{}{}", &input_no_msg[..search_start], rest)
} else {
rest.to_string()
};
return FileLocation {
path: PathBuf::from(path_str),
line: Some(line),
message,
..empty
};
}
}
_ => {}
}
FileLocation {
path: PathBuf::from(input_no_msg),
message,
..empty
}
}
fn extract_message_suffix(input: &str) -> (&str, Option<String>) {
if let Some(at_pos) = input.rfind("@\"") {
if input.ends_with('"') && input.len() > at_pos + 2 {
let msg = &input[at_pos + 2..input.len() - 1];
let msg = msg.replace("\\\"", "\"");
return (&input[..at_pos], Some(msg));
}
}
(input, None)
}
fn parse_range(location: &str, path: PathBuf, message: Option<String>) -> Option<FileLocation> {
let parts: Vec<&str> = location.splitn(2, '-').collect();
if parts.len() != 2 {
return None;
}
let start_part = parts[0];
let end_part = parts[1];
let (start_line, start_col) = parse_line_col(start_part)?;
let (end_line, end_col) = parse_line_col(end_part)?;
Some(FileLocation {
path,
line: Some(start_line),
column: start_col,
end_line: Some(end_line),
end_column: end_col,
message,
})
}
fn parse_line_col(s: &str) -> Option<(usize, Option<usize>)> {
if let Some((line_str, col_str)) = s.split_once(':') {
let line = line_str.parse::<usize>().ok()?;
let col = col_str.parse::<usize>().ok()?;
Some((line, Some(col)))
} else {
let line = s.parse::<usize>().ok()?;
Some((line, None))
}
}
fn parse_path_with_line_col(path_and_rest: &str) -> (String, Option<usize>, Option<usize>) {
let parts: Vec<&str> = path_and_rest.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>()) {
(rest.to_string(), Some(line), Some(col))
} else {
(path_and_rest.to_string(), None, None)
}
}
[maybe_line, rest] => {
if let Ok(line) = maybe_line.parse::<usize>() {
(rest.to_string(), Some(line), None)
} else {
(path_and_rest.to_string(), None, None)
}
}
_ => (path_and_rest.to_string(), None, None),
}
}
fn default_ssh_user() -> Option<String> {
std::env::var("USER")
.ok()
.or_else(|| std::env::var("USERNAME").ok())
.filter(|u| !u.is_empty())
}
fn parse_ssh_url_rest(rest: &str) -> Option<RemoteLocation> {
let (authority, path_and_rest) = rest.split_once('/')?;
if path_and_rest.is_empty() {
return None;
}
let (user, host_and_port) = match authority.split_once('@') {
Some((u, rest)) if !u.is_empty() && !u.contains(' ') => (u.to_string(), rest),
Some(_) => return None, None => (default_ssh_user()?, authority),
};
let (host, port) = match host_and_port.rsplit_once(':') {
Some((h, p)) => {
let parsed_port = p.parse::<u16>().ok()?;
(h, Some(parsed_port))
}
None => (host_and_port, None),
};
if host.is_empty() || host.contains(' ') {
return None;
}
let (path_tail, line, column) = parse_path_with_line_col(path_and_rest);
let path = format!("/{}", path_tail);
Some(RemoteLocation {
user,
host: host.to_string(),
port,
path,
line,
column,
})
}
fn remote_location_to_ssh_url(remote: &RemoteLocation) -> String {
let path = remote.path.trim_start_matches('/');
match remote.port {
Some(port) => format!("ssh://{}@{}:{}/{}", remote.user, remote.host, port, path),
None => format!("ssh://{}@{}/{}", remote.user, remote.host, path),
}
}
fn extract_ssh_url_from_files(files: &[String]) -> AnyhowResult<Option<String>> {
let parsed: Vec<ParsedLocation> = files
.iter()
.filter(|f| *f != "-")
.map(|f| parse_location(f))
.collect();
let remotes: Vec<&RemoteLocation> = parsed
.iter()
.filter_map(|loc| match loc {
ParsedLocation::Remote(r) => Some(r),
ParsedLocation::Local(_) => None,
})
.collect();
if remotes.is_empty() {
return Ok(None);
}
let first = remotes[0];
for r in &remotes[1..] {
if r.user != first.user || r.host != first.host || r.port != first.port {
anyhow::bail!(
"Cannot open files from multiple remote hosts. First: {}@{}, found: {}@{}",
first.user,
first.host,
r.user,
r.host
);
}
}
if parsed
.iter()
.any(|loc| matches!(loc, ParsedLocation::Local(_)))
{
anyhow::bail!(
"Cannot mix local and remote files. Use either local paths or remote paths (ssh:// or user@host:path)."
);
}
Ok(Some(remote_location_to_ssh_url(first)))
}
fn parse_ssh_url_arg(url: &str) -> AnyhowResult<RemoteLocation> {
let rest = url
.strip_prefix("ssh://")
.ok_or_else(|| anyhow::anyhow!("--ssh-url expects an ssh:// URL, got {:?}", url))?;
parse_ssh_url_rest(rest)
.ok_or_else(|| anyhow::anyhow!("--ssh-url is not a valid ssh:// URL: {:?}", url))
}
fn parse_location(input: &str) -> ParsedLocation {
if let Some(rest) = input.strip_prefix("ssh://") {
return match parse_ssh_url_rest(rest) {
Some(loc) => ParsedLocation::Remote(loc),
None => ParsedLocation::Local(parse_file_location(input)),
};
}
if let Some(at_pos) = input.find('@') {
let user = &input[..at_pos];
let after_at = &input[at_pos + 1..];
if let Some(colon_pos) = after_at.find(':') {
let host = &after_at[..colon_pos];
let path_and_rest = &after_at[colon_pos + 1..];
if !user.is_empty()
&& !host.is_empty()
&& !user.contains(' ')
&& !host.contains(' ')
&& !path_and_rest.is_empty()
{
let (path, line, column) = parse_path_with_line_col(path_and_rest);
return ParsedLocation::Remote(RemoteLocation {
user: user.to_string(),
host: host.to_string(),
port: None,
path,
line,
column,
});
}
}
}
ParsedLocation::Local(parse_file_location(input))
}
struct RemoteSession {
_connection: remote::SshConnection,
_runtime: tokio::runtime::Runtime,
_reconnect_handle: tokio::task::JoinHandle<()>,
}
struct StartupAuthority {
authority: fresh::services::authority::Authority,
remote_session: Option<RemoteSession>,
}
fn create_startup_authority(
remote_info: &Option<RemoteLocation>,
) -> AnyhowResult<StartupAuthority> {
if let Some(remote) = remote_info {
connect_remote(remote)
} else {
Ok(StartupAuthority {
authority: fresh::services::authority::Authority::local(),
remote_session: None,
})
}
}
fn connect_remote(remote: &RemoteLocation) -> AnyhowResult<StartupAuthority> {
let rt = tokio::runtime::Runtime::new()
.context("Failed to create Tokio runtime for remote connection")?;
let connection_params = remote::ConnectionParams {
user: remote.user.clone(),
host: remote.host.clone(),
port: remote.port,
identity_file: None,
};
match remote.port {
Some(port) => eprintln!(
"Connecting via SSH to {}@{}:{}...",
remote.user, remote.host, port
),
None => eprintln!("Connecting via SSH to {}@{}...", remote.user, remote.host),
}
let connection = rt
.block_on(remote::SshConnection::connect(connection_params))
.context(format!(
"Failed to connect to remote host {}@{}",
remote.user, remote.host
))?;
let connection_string = connection.connection_string();
let channel = connection.channel();
let reconnect_params = connection.params().clone();
tracing::info!("Connected to remote host: {}", connection_string);
let filesystem = std::sync::Arc::new(remote::RemoteFileSystem::new(
channel.clone(),
connection_string,
));
let process_spawner = std::sync::Arc::new(remote::RemoteProcessSpawner::new(channel.clone()));
let reconnect_handle = {
let _guard = rt.enter();
remote::spawn_reconnect_task(channel, reconnect_params)
};
Ok(StartupAuthority {
authority: fresh::services::authority::Authority::ssh(filesystem, process_spawner),
remote_session: Some(RemoteSession {
_connection: connection,
_runtime: rt,
_reconnect_handle: reconnect_handle,
}),
})
}
fn initialize_app(args: &Args) -> AnyhowResult<SetupState> {
let log_file = args
.log_file
.clone()
.unwrap_or_else(fresh::services::log_dirs::main_log_path);
let tracing_handles = tracing_setup::init_global(&log_file);
fresh::services::log_dirs::cleanup_stale_logs();
tracing::info!(
"Editor starting (v{} {})",
env!("CARGO_PKG_VERSION"),
env!("FRESH_GIT_HASH")
);
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| {
terminal_modes::emergency_cleanup();
original_hook(panic);
}));
let stdin_requested = args.stdin || args.files.iter().any(|f| f == "-");
let stdin_stream = if stdin_requested {
if stdin_has_data() {
tracing::info!("Starting background stdin streaming");
match start_stdin_streaming() {
Ok(stream_state) => {
tracing::info!(
"Stdin streaming started, temp file: {:?}",
stream_state.temp_path
);
Some(stream_state)
}
Err(e) => {
eprintln!("Error: Failed to start stdin streaming: {}", e);
return Err(e);
}
}
} else {
eprintln!("Error: --stdin or \"-\" specified but stdin is a terminal (no piped data)");
anyhow::bail!(io::Error::new(
io::ErrorKind::InvalidInput,
"No data piped to stdin",
));
}
} else {
None
};
let parsed_locations: Vec<ParsedLocation> = args
.files
.iter()
.filter(|f| *f != "-")
.map(|f| parse_location(f))
.collect();
let remote_locations: Vec<&RemoteLocation> = parsed_locations
.iter()
.filter_map(|loc| match loc {
ParsedLocation::Remote(r) => Some(r),
ParsedLocation::Local(_) => None,
})
.collect();
let remote_info: Option<RemoteLocation> = if !remote_locations.is_empty() {
let first = remote_locations[0];
for r in &remote_locations[1..] {
if r.user != first.user || r.host != first.host {
anyhow::bail!(
"Cannot open files from multiple remote hosts. \
First: {}@{}, found: {}@{}",
first.user,
first.host,
r.user,
r.host
);
}
}
let has_local = parsed_locations
.iter()
.any(|loc| matches!(loc, ParsedLocation::Local(_)));
if has_local {
anyhow::bail!(
"Cannot mix local and remote files. Use either local paths or remote paths (user@host:path)."
);
}
Some(first.clone())
} else {
None
};
let file_locations: Vec<FileLocation> = parsed_locations
.into_iter()
.map(|loc| match loc {
ParsedLocation::Local(fl) => fl,
ParsedLocation::Remote(rl) => FileLocation {
path: PathBuf::from(&rl.path),
line: rl.line,
column: rl.column,
end_line: None,
end_column: None,
message: None,
},
})
.collect();
tracing::info!("Building startup authority...");
let StartupAuthority {
authority,
remote_session,
} = create_startup_authority(&remote_info)?;
tracing::info!("Startup authority ready");
let mut working_dir = None;
let mut show_file_explorer = false;
if file_locations.len() == 1 {
if let Some(first_loc) = file_locations.first() {
let is_directory = authority
.filesystem
.is_dir(&first_loc.path)
.unwrap_or(false);
if is_directory {
working_dir = Some(first_loc.path.clone());
show_file_explorer = true;
}
}
}
tracing::info!("Loading config...");
let effective_working_dir = if remote_info.is_some() {
std::env::current_dir().unwrap_or_default()
} else {
working_dir
.as_ref()
.cloned()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
};
let dir_context = fresh::config_io::DirectoryContext::from_system()?;
let mut 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
);
anyhow::bail!(io::Error::new(io::ErrorKind::InvalidData, e.to_string()));
}
}
} else {
config::Config::load_with_layers(&dir_context, &effective_working_dir)
};
tracing::info!("Config loaded");
if args.no_upgrade_check {
config.check_for_updates = false;
}
let locale_override = args.locale.as_deref().or(config.locale.as_option());
fresh::i18n::init_with_config(locale_override);
let keyboard_config = KeyboardConfig {
disambiguate_escape_codes: config.editor.keyboard_disambiguate_escape_codes,
report_event_types: config.editor.keyboard_report_event_types,
report_alternate_keys: config.editor.keyboard_report_alternate_keys,
report_all_keys_as_escape_codes: config.editor.keyboard_report_all_keys_as_escape_codes,
};
let terminal_modes = TerminalModes::enable(Some(&keyboard_config))?;
#[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_some() {
tracing::info!("Using GPM for mouse capture");
}
use crossterm::ExecutableCommand;
#[allow(clippy::let_underscore_must_use)]
let _ = stdout().execute(config.editor.cursor_style.to_crossterm_style());
tracing::info!("Set cursor style to {:?}", config.editor.cursor_style);
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);
tracing::info!("Loading directory context...");
let dir_context = DirectoryContext::from_system()?;
tracing::info!("Directory context loaded");
let current_working_dir = working_dir;
tracing::info!("Loading key translator...");
let key_translator = match KeyTranslator::load_from_config_dir(&dir_context.config_dir) {
Ok(translator) => translator,
Err(e) => {
tracing::warn!("Failed to load key calibration: {}", e);
KeyTranslator::new()
}
};
tracing::info!("Key translator loaded, returning SetupState");
Ok(SetupState {
config,
tracing_handles,
terminal,
terminal_size: (size.width, size.height),
file_locations,
show_file_explorer,
dir_context,
current_working_dir,
stdin_stream,
key_translator,
gpm_client,
terminal_modes,
authority,
_remote_session: remote_session,
})
}
#[cfg_attr(not(target_os = "linux"), allow(unused_variables))]
fn run_editor_iteration(
editor: &mut Editor,
workspace_enabled: bool,
terminal: &mut Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>,
key_translator: &KeyTranslator,
#[cfg(target_os = "linux")] gpm_client: &Option<GpmClient>,
terminal_modes: &mut TerminalModes,
) -> AnyhowResult<IterationOutcome> {
#[cfg(target_os = "linux")]
let loop_result = run_event_loop(
editor,
terminal,
workspace_enabled,
key_translator,
gpm_client,
terminal_modes,
);
#[cfg(not(target_os = "linux"))]
let loop_result = run_event_loop(
editor,
terminal,
workspace_enabled,
key_translator,
terminal_modes,
);
if let Err(e) = editor.end_recovery_session() {
tracing::warn!("Failed to end recovery session: {}", e);
}
let update_result = editor.get_update_result().cloned();
let restart_dir = editor.take_restart_dir();
Ok(IterationOutcome {
loop_result,
update_result,
restart_dir,
})
}
#[cfg(feature = "plugins")]
fn check_plugin_bundle(plugin_path: &std::path::Path) -> AnyhowResult<()> {
use fresh_parser_js::{bundle_module, has_es_module_syntax, transpile_typescript};
eprintln!("Checking plugin: {}", plugin_path.display());
let source = std::fs::read_to_string(plugin_path)
.with_context(|| format!("Failed to read plugin file: {}", plugin_path.display()))?;
eprintln!("Source length: {} bytes", source.len());
if has_es_module_syntax(&source) {
eprintln!("Plugin has ES module syntax, bundling...\n");
match bundle_module(plugin_path) {
Ok(bundled) => {
eprintln!("=== BUNDLED OUTPUT ({} bytes) ===\n", bundled.len());
println!("{}", bundled);
eprintln!("\n=== END BUNDLED OUTPUT ===");
}
Err(e) => {
eprintln!("ERROR bundling plugin: {}", e);
return Err(e);
}
}
} else {
eprintln!("Plugin has no ES module syntax, transpiling directly...\n");
let filename = plugin_path.to_str().unwrap_or("plugin.ts");
match transpile_typescript(&source, filename) {
Ok(transpiled) => {
eprintln!("=== TRANSPILED OUTPUT ({} bytes) ===\n", transpiled.len());
println!("{}", transpiled);
eprintln!("\n=== END TRANSPILED OUTPUT ===");
}
Err(e) => {
eprintln!("ERROR transpiling plugin: {}", e);
return Err(e);
}
}
}
Ok(())
}
fn init_check_command() -> AnyhowResult<()> {
let dir_context = fresh::config_io::DirectoryContext::from_system()
.context("failed to resolve config directory")?;
let report = fresh::init_script::check(&dir_context.config_dir);
let path_display = report.path.display();
if report.ok {
if report.diagnostics.is_empty() {
println!("init.ts: ok ({path_display})");
} else {
for d in &report.diagnostics {
eprintln!(
"{path_display}:{}:{} warning {}",
d.line, d.column, d.message
);
}
}
return Ok(());
}
for d in &report.diagnostics {
let tag = match d.severity {
fresh::init_script::CheckSeverity::Error => "error",
fresh::init_script::CheckSeverity::Warning => "warning",
};
eprintln!(
"{path_display}:{}:{} {tag} {}",
d.line, d.column, d.message
);
}
let errors = report
.diagnostics
.iter()
.filter(|d| d.severity == fresh::init_script::CheckSeverity::Error)
.count();
eprintln!(
"\n{errors} error{}. init.ts will not be evaluated until fixed.",
if errors == 1 { "" } else { "s" }
);
std::process::exit(1);
}
fn init_package_command(package_type: Option<String>) -> AnyhowResult<()> {
use std::io::{BufRead, Write};
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
let mut prompt = |msg: &str| -> String {
print!("{}", msg);
#[allow(clippy::let_underscore_must_use)]
let _ = stdout.flush();
let mut input = String::new();
stdin.lock().read_line(&mut input).unwrap_or_default();
input.trim().to_string()
};
println!("Fresh Package Initializer");
println!("=========================\n");
let pkg_type = match package_type.as_deref() {
Some("plugin") | Some("p") => "plugin",
Some("theme") | Some("t") => "theme",
Some("language") | Some("lang") | Some("l") => "language",
Some(other) => {
eprintln!(
"Unknown package type '{}'. Valid types: plugin, theme, language",
other
);
std::process::exit(1);
}
None => {
println!("Package types:");
println!(" 1. plugin - Extend Fresh with custom commands and functionality");
println!(" 2. theme - Custom color schemes and styling");
println!(" 3. language - Syntax highlighting, LSP, and language configuration\n");
loop {
let choice = prompt("Select type (1/2/3 or plugin/theme/language): ");
match choice.as_str() {
"1" | "plugin" | "p" => break "plugin",
"2" | "theme" | "t" => break "theme",
"3" | "language" | "lang" | "l" => break "language",
"" => {
eprintln!("Please select a package type.");
}
_ => {
eprintln!("Invalid choice. Please enter 1, 2, 3, or the type name.");
}
}
}
}
};
let default_name = format!("my-fresh-{}", pkg_type);
let name = loop {
let input = prompt(&format!("Package name [{}]: ", default_name));
let name = if input.is_empty() {
default_name.clone()
} else {
input
};
if name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& !name.starts_with('-')
&& !name.ends_with('-')
{
break name;
}
eprintln!("Invalid name. Use lowercase letters, numbers, and dashes only.");
};
let description = prompt("Description (optional): ");
let author = prompt("Author (optional): ");
let pkg_dir = PathBuf::from(&name);
if pkg_dir.exists() {
eprintln!("Error: Directory '{}' already exists.", name);
std::process::exit(1);
}
std::fs::create_dir_all(&pkg_dir)?;
match pkg_type {
"plugin" => create_plugin_package(&pkg_dir, &name, &description, &author)?,
"theme" => create_theme_package(&pkg_dir, &name, &description, &author)?,
"language" => create_language_package(&pkg_dir, &name, &description, &author)?,
_ => unreachable!(),
}
println!("\nPackage '{}' created successfully!", name);
println!("\nNext steps:");
println!(" 1. cd {}", name);
match pkg_type {
"plugin" => {
println!(" 2. Edit plugin.ts to add your functionality");
println!(" 3. Test locally: fresh --check-plugin .");
println!(" 4. Validate manifest: ./validate.sh");
}
"theme" => {
println!(" 2. Edit theme.json to customize colors");
println!(" 3. Validate theme: ./validate.sh (requires: pip install jsonschema)");
}
"language" => {
println!(" 2. Edit grammars/syntax.sublime-syntax (YAML format)");
println!(" 3. Update package.json with file extensions and LSP command");
println!(" 4. Test by copying to ~/.config/fresh/grammars/");
println!(" 5. Validate manifest: ./validate.sh");
}
_ => unreachable!(),
}
println!("\nTo publish:");
println!(" 1. Push your package to a public Git repository");
println!(" 2. Submit a PR to: https://github.com/sinelaw/fresh-plugins-registry");
println!(" Add your package to the appropriate registry file:");
match pkg_type {
"plugin" => println!(" - plugins.json"),
"theme" => println!(" - themes.json"),
"language" => println!(" - languages.json"),
_ => unreachable!(),
}
println!("\nDocumentation: https://github.com/sinelaw/fresh-plugins-registry#readme");
Ok(())
}
fn write_validate_script(dir: &Path) -> AnyhowResult<()> {
let validate_sh = r#"#!/bin/bash
# Validate package.json against the official Fresh package schema
#
# Prerequisite: pip install jsonschema
curl -sSL https://raw.githubusercontent.com/sinelaw/fresh/main/scripts/validate-package.sh | bash
"#;
write_script_file(dir, "validate.sh", validate_sh)
}
fn write_theme_validate_script(dir: &Path) -> AnyhowResult<()> {
let validate_sh = r#"#!/bin/bash
# Validate Fresh theme package
#
# Prerequisite: pip install jsonschema
set -e
echo "Validating package.json..."
curl -sSL https://raw.githubusercontent.com/sinelaw/fresh/main/scripts/validate-package.sh | bash
echo "Validating theme.json..."
python3 -c "
import json, jsonschema, urllib.request, sys
with open('theme.json') as f:
data = json.load(f)
schema_url = 'https://raw.githubusercontent.com/sinelaw/fresh/main/crates/fresh-editor/plugins/schemas/theme.schema.json'
try:
with urllib.request.urlopen(schema_url, timeout=5) as resp:
schema = json.load(resp)
jsonschema.validate(data, schema)
print('✓ theme.json is valid')
except urllib.error.URLError:
print('âš Could not fetch schema (URL may not exist yet)')
except jsonschema.ValidationError as e:
print(f'✗ Validation error: {e.message}')
sys.exit(1)
"
"#;
write_script_file(dir, "validate.sh", validate_sh)
}
fn write_script_file(dir: &Path, name: &str, content: &str) -> AnyhowResult<()> {
std::fs::write(dir.join(name), content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(dir.join(name))?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(dir.join(name), perms)?;
}
Ok(())
}
fn write_package_json(
dir: &Path,
name: &str,
description: &str,
author: &str,
pkg_type: &str,
default_description: &str,
fresh_section: &str,
) -> AnyhowResult<()> {
let desc = if description.is_empty() {
default_description
} else {
description
};
let content = format!(
r#"{{
"$schema": "https://raw.githubusercontent.com/sinelaw/fresh/main/crates/fresh-editor/plugins/schemas/package.schema.json",
"name": "{name}",
"version": "0.1.0",
"description": "{desc}",
"type": "{pkg_type}",
"author": "{author}",
"license": "MIT",
"fresh": {fresh_section}
}}
"#
);
std::fs::write(dir.join("package.json"), content)?;
Ok(())
}
fn create_plugin_package(
dir: &Path,
name: &str,
description: &str,
author: &str,
) -> AnyhowResult<()> {
write_package_json(
dir,
name,
description,
author,
"plugin",
"A Fresh plugin",
r#"{
"main": "plugin.ts"
}"#,
)?;
write_validate_script(dir)?;
let plugin_ts = r#"// Fresh Plugin
// Documentation: https://github.com/user/fresh/blob/main/docs/plugins.md
const editor = getEditor();
// Define a command handler and register it
function hello(): void {
editor.setStatus("Hello from your plugin!");
}
registerHandler("hello", hello);
editor.registerCommand("hello", "Say Hello", "hello");
// React to editor events
function onBufferOpened(): void {
const bufferId = editor.getActiveBufferId();
const info = editor.getBufferInfo(bufferId);
if (info) {
editor.debug(`Opened: ${info.path}`);
}
}
registerHandler("on_buffer_opened", onBufferOpened);
editor.on("buffer_opened", "on_buffer_opened");
// Example: Add a keybinding in your Fresh config:
// {
// "keyBindings": {
// "ctrl+alt+h": "command:hello"
// }
// }
"#;
std::fs::write(dir.join("plugin.ts"), plugin_ts)?;
let readme = format!(
r#"# {}
{}
## Installation
Install via Fresh's package manager:
```
:pkg install {}
```
Or install from this repository:
```
:pkg install https://github.com/YOUR_USERNAME/{}
```
## Usage
This plugin adds the following commands:
- `hello` - Say Hello
## License
MIT
"#,
name,
if description.is_empty() {
"A Fresh plugin."
} else {
description
},
name,
name
);
std::fs::write(dir.join("README.md"), readme)?;
Ok(())
}
fn create_theme_package(
dir: &Path,
name: &str,
description: &str,
author: &str,
) -> AnyhowResult<()> {
write_package_json(
dir,
name,
description,
author,
"theme",
"A Fresh theme",
r#"{
"theme": "theme.json"
}"#,
)?;
write_theme_validate_script(dir)?;
let theme_json = r##"{
"name": "My Theme",
"colors": {
"background": "#1e1e2e",
"foreground": "#cdd6f4",
"cursor": "#f5e0dc",
"selection": "#45475a",
"line_numbers": "#6c7086",
"current_line": "#313244",
"status_bar": {
"background": "#181825",
"foreground": "#cdd6f4"
},
"syntax": {
"keyword": "#cba6f7",
"string": "#a6e3a1",
"number": "#fab387",
"comment": "#6c7086",
"function": "#89b4fa",
"type": "#f9e2af",
"variable": "#cdd6f4",
"operator": "#89dceb"
}
}
}
"##;
std::fs::write(dir.join("theme.json"), theme_json)?;
let readme = format!(
r#"# {}
{}
## Installation
Install via Fresh's package manager:
```
:pkg install {}
```
## Activation
After installation, activate the theme:
```
:theme {}
```
Or add to your Fresh config:
```json
{{
"theme": "{}"
}}
```
## Preview
<!-- Add a screenshot of your theme here -->
## License
MIT
"#,
name,
if description.is_empty() {
"A Fresh theme."
} else {
description
},
name,
name,
name
);
std::fs::write(dir.join("README.md"), readme)?;
Ok(())
}
fn create_language_package(
dir: &Path,
name: &str,
description: &str,
author: &str,
) -> AnyhowResult<()> {
std::fs::create_dir_all(dir.join("grammars"))?;
write_package_json(
dir,
name,
description,
author,
"language",
"Language support for Fresh",
r#"{
"grammar": {
"file": "grammars/syntax.sublime-syntax",
"extensions": ["ext"]
},
"language": {
"commentPrefix": "//",
"tabSize": 4,
"autoIndent": true
},
"lsp": {
"command": "language-server",
"args": ["--stdio"],
"autoStart": true
}
}"#,
)?;
write_validate_script(dir)?;
let grammar = r#"%YAML 1.2
---
# Sublime syntax file for your language
# Documentation: https://www.sublimetext.com/docs/syntax.html
name: My Language
scope: source.mylang
file_extensions: [ext]
contexts:
main:
- include: comments
- include: strings
- include: keywords
- include: numbers
comments:
# Line comments
- match: //.*$
scope: comment.line.double-slash
strings:
# Double-quoted strings with escape sequences
- match: '"'
scope: punctuation.definition.string.begin
push:
- meta_scope: string.quoted.double
- match: \\.
scope: constant.character.escape
- match: '"'
scope: punctuation.definition.string.end
pop: true
keywords:
- match: \b(if|else|while|for|return)\b
scope: keyword.control
numbers:
- match: \b[0-9]+(\.[0-9]+)?\b
scope: constant.numeric
"#;
std::fs::write(dir.join("grammars/syntax.sublime-syntax"), grammar)?;
let readme = format!(
r#"# {}
{}
## Features
- Syntax highlighting via Sublime syntax grammar
- Language configuration (comments, indentation)
- LSP integration (if configured)
## Installation
Install via Fresh's package manager:
```
:pkg install {}
```
## Configuration
This language pack provides:
### Grammar
- File extensions: `.ext` (update in package.json)
- Syntax highlighting rules in `grammars/syntax.sublime-syntax`
### Language Settings
- Comment prefix: `//`
- Tab size: 4 spaces
- Auto-indent: enabled
### LSP Server
- Command: `language-server --stdio`
- Auto-start: enabled
Update `package.json` to match your language's requirements.
## Development
1. Edit `grammars/syntax.sublime-syntax` for syntax highlighting
2. Update `package.json` with correct file extensions and LSP command
3. Test by copying to `~/.config/fresh/grammars/` and restarting Fresh
**Tip:** Search GitHub for existing `<language> sublime-syntax` files you can adapt.
If using an existing grammar, check its license and include a copy in `grammars/LICENSE`.
## Grammar Attribution
<!-- If you used an existing grammar, add attribution here: -->
<!-- The syntax grammar is derived from [original](https://github.com/user/repo) -->
<!-- by Original Author, licensed under MIT. See `grammars/LICENSE` for details. -->
## Resources
- [Sublime Text Syntax Documentation](https://www.sublimetext.com/docs/syntax.html)
- [Scope Naming Conventions](https://www.sublimetext.com/docs/scope_naming.html)
## License
MIT
"#,
name,
if description.is_empty() {
"Language support for Fresh."
} else {
description
},
name
);
std::fs::write(dir.join("README.md"), readme)?;
Ok(())
}
fn list_grammars_command() -> AnyhowResult<()> {
let dir_context = fresh::config_io::DirectoryContext::from_system()?;
let config_dir = dir_context.config_dir.clone();
let registry = fresh::primitives::grammar::GrammarRegistry::for_editor(config_dir);
let grammars = registry.available_grammar_info();
if grammars.is_empty() {
println!("No grammars available.");
return Ok(());
}
let max_name_len = grammars.iter().map(|g| g.name.len()).max().unwrap_or(0);
let max_short_len = grammars
.iter()
.map(|g| g.short_name.as_ref().map_or(0, |s| s.len()))
.max()
.unwrap_or(0)
.max("SHORT NAME".len());
println!(
"{:<nw$} {:<sw$} {:<12} EXTENSIONS",
"GRAMMAR",
"SHORT NAME",
"SOURCE",
nw = max_name_len,
sw = max_short_len
);
println!(
"{:<nw$} {:<sw$} {:<12} ----------",
"-------",
"----------",
"------",
nw = max_name_len,
sw = max_short_len
);
for grammar in &grammars {
let extensions = if grammar.file_extensions.is_empty() {
String::new()
} else {
grammar
.file_extensions
.iter()
.map(|e| format!(".{}", e))
.collect::<Vec<_>>()
.join(", ")
};
let short = grammar.short_name.as_deref().unwrap_or("");
println!(
"{:<nw$} {:<sw$} {:<12} {}",
grammar.name,
short,
grammar.source.to_string(),
extensions,
nw = max_name_len,
sw = max_short_len
);
}
println!("\n{} grammars available.", grammars.len());
println!("Use the grammar name or short name in config: languages -> <language> -> grammar");
Ok(())
}
fn list_sessions_command() -> AnyhowResult<()> {
let socket_dir = SocketPaths::socket_directory()?;
if !socket_dir.exists() {
println!("No active sessions.");
return Ok(());
}
let mut sessions = Vec::new();
let mut stale_cleaned = 0;
for entry in std::fs::read_dir(&socket_dir)? {
let entry = entry?;
let path = entry.path();
let filename = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
if let Some(name) = filename.strip_suffix(".ctrl.sock") {
let socket_paths = SocketPaths::for_session_name(name)?;
if socket_paths.cleanup_if_stale() {
stale_cleaned += 1;
continue;
}
if !socket_paths.is_server_alive() {
continue;
}
let display_name = if let Some(decoded_path) = workspace::decode_filename_to_path(name)
{
if decoded_path.components().count() > 2 {
decoded_path.display().to_string()
} else {
name.to_string()
}
} else {
name.to_string()
};
sessions.push((name.to_string(), display_name));
}
}
if stale_cleaned > 0 {
eprintln!("Cleaned up {} stale session(s).", stale_cleaned);
}
if sessions.is_empty() {
println!("No active sessions.");
} else {
println!("Active sessions:");
for (id, display) in &sessions {
if display != id {
println!(" {} (name: {})", display, id);
} else {
println!(" {}", id);
}
}
println!();
if sessions.len() == 1 {
let (id, display) = &sessions[0];
if display != id {
println!("Attach with: fresh -a (from that directory)");
println!(" or: fresh -a {}", id);
} else {
println!("Attach with: fresh -a {}", id);
}
} else {
println!("Attach with: fresh -a [NAME]");
}
}
Ok(())
}
fn kill_session_command(session: Option<&str>, args: &Args) -> AnyhowResult<()> {
use fresh::server::ipc::ClientConnection;
use fresh::server::protocol::ClientControl;
let working_dir = std::env::current_dir()?;
let socket_paths = match session.or(args.session_name.as_deref()) {
Some(name) => SocketPaths::for_session_name(name)?,
None => SocketPaths::for_working_dir(&working_dir)?,
};
if !socket_paths.data.exists() || !socket_paths.control.exists() {
eprintln!("No session found to kill.");
return Ok(());
}
let conn = ClientConnection::connect(&socket_paths)?;
use fresh::server::protocol::{ClientHello, TermSize};
let hello = ClientHello::new(TermSize::new(80, 24));
let hello_json = serde_json::to_string(&ClientControl::Hello(hello))?;
conn.write_control(&hello_json)?;
let _ = conn.read_control()?;
let quit_msg = serde_json::to_string(&ClientControl::Quit)?;
conn.write_control(&quit_msg)?;
conn.set_data_nonblocking(false)?;
let mut buf = [0u8; 1024];
let timeout = std::time::Duration::from_secs(5);
let start = std::time::Instant::now();
while start.elapsed() < timeout {
match conn.read_data(&mut buf) {
Ok(0) => break, Ok(_) => continue, Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(_) => break, }
}
std::thread::sleep(std::time::Duration::from_millis(100));
if socket_paths.data.exists() {
#[allow(clippy::let_underscore_must_use)]
let _ = std::fs::remove_file(&socket_paths.data);
}
if socket_paths.control.exists() {
#[allow(clippy::let_underscore_must_use)]
let _ = std::fs::remove_file(&socket_paths.control);
}
println!("Session terminated.");
Ok(())
}
fn run_server_command(args: &Args) -> AnyhowResult<()> {
use fresh::server::{EditorServer, EditorServerConfig};
use tracing_subscriber::{fmt, EnvFilter};
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.with_ansi(false)
.init();
eprintln!(
"[server] Starting server process for session {:?}",
args.session_name
);
let remote_info = match args.ssh_url.as_deref() {
Some(url) => Some(parse_ssh_url_arg(url)?),
None => None,
};
let StartupAuthority {
authority,
remote_session,
} = create_startup_authority(&remote_info)?;
let working_dir = match &remote_info {
Some(remote) => PathBuf::from(&remote.path),
None => std::env::current_dir()?,
};
let config_dir = std::env::current_dir()?;
eprintln!("[server] Working directory: {:?}", working_dir);
let dir_context = fresh::config_io::DirectoryContext::from_system()?;
eprintln!("[server] Loading editor config...");
let editor_config = if let Some(config_path) = &args.config {
config::Config::load_from_file(config_path)?
} else {
config::Config::load_with_layers(&dir_context, &config_dir)
};
eprintln!("[server] Editor config loaded");
let session_keepalive: Option<Box<dyn std::any::Any + Send>> =
remote_session.map(|rs| Box::new(rs) as Box<dyn std::any::Any + Send>);
let startup_authority = if remote_info.is_some() {
Some(authority)
} else {
None
};
let config = EditorServerConfig {
working_dir: working_dir.clone(),
session_name: args.session_name.clone(),
idle_timeout: Some(std::time::Duration::from_secs(3600)), editor_config,
dir_context,
plugins_enabled: !args.no_plugins,
init_enabled: !args.no_init,
startup_authority,
session_keepalive,
};
eprintln!("[server] Creating EditorServer...");
let mut server = match EditorServer::new(config) {
Ok(s) => {
eprintln!("[server] EditorServer created successfully");
s
}
Err(e) => {
eprintln!("[server] EditorServer::new failed: {:?}", e);
return Err(e.into());
}
};
eprintln!("[server] Server ready at {:?}", server.socket_paths());
tracing::info!("Editor server started at {:?}", server.socket_paths());
eprintln!("[server] Entering main loop...");
server.run()?;
eprintln!("[server] Server shutting down");
Ok(())
}
fn resolve_session(session_name: Option<&str>) -> anyhow::Result<SocketPaths> {
let working_dir = std::env::current_dir()?;
match session_name {
None => Ok(SocketPaths::for_working_dir(&working_dir)?),
Some(name) => {
let path = std::path::Path::new(name);
if path.is_absolute() || name.contains('/') || name.contains('\\') {
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let by_dir = SocketPaths::for_working_dir(&canonical)?;
if by_dir.is_server_alive() {
return Ok(by_dir);
}
}
Ok(SocketPaths::for_session_name(name)?)
}
}
}
fn run_open_files_command(
session_name: Option<&str>,
files: &[String],
wait: bool,
) -> AnyhowResult<()> {
use fresh::server::daemon::is_process_running;
use fresh::server::protocol::{
ClientControl, ClientHello, ServerControl, TermSize, PROTOCOL_VERSION,
};
use fresh::server::spawn_server_detached;
if files.is_empty() {
eprintln!("No files specified.");
return Ok(());
}
let ssh_url = extract_ssh_url_from_files(files)?;
let local_files: Vec<String> = if ssh_url.is_some() {
files
.iter()
.filter_map(|f| match parse_location(f) {
ParsedLocation::Remote(r) => Some(r.path),
ParsedLocation::Local(_) => None,
})
.collect()
} else {
files.to_vec()
};
let working_dir = std::env::current_dir()?;
let file_requests = build_file_requests(&local_files, &working_dir);
if file_requests.is_empty() {
eprintln!("No files to open (only directories were specified).");
return Ok(());
}
let socket_paths = resolve_session(session_name)?;
socket_paths.cleanup_if_stale();
let server_was_started = if !socket_paths.is_server_alive() {
let _pid = spawn_server_detached(session_name, ssh_url.as_deref())?;
loop {
if let Ok(Some(pid)) = socket_paths.read_pid() {
if is_process_running(pid) {
break;
}
}
std::thread::yield_now();
}
true
} else {
false
};
let conn = fresh::server::ipc::ClientConnection::connect(&socket_paths)?;
let hello = ClientHello::new(TermSize::new(80, 24)); let hello_json = serde_json::to_string(&ClientControl::Hello(hello))?;
conn.write_control(&hello_json)?;
let response = conn
.read_control()?
.ok_or_else(|| anyhow::anyhow!("Server closed connection during handshake"))?;
let server_msg: ServerControl = serde_json::from_str(&response)?;
match server_msg {
ServerControl::Hello(server_hello) => {
if server_hello.protocol_version != PROTOCOL_VERSION {
eprintln!(
"Version mismatch: server is v{}",
server_hello.server_version
);
return Ok(());
}
}
ServerControl::VersionMismatch(mismatch) => {
eprintln!("Version mismatch: server is v{}", mismatch.server_version);
return Ok(());
}
ServerControl::Error { message } => {
return Err(anyhow::anyhow!("Server error: {}", message));
}
_ => {
return Err(anyhow::anyhow!("Unexpected server response"));
}
}
let msg = serde_json::to_string(&ClientControl::OpenFiles {
files: file_requests.clone(),
wait,
})?;
conn.write_control(&msg)?;
if server_was_started {
drop(conn);
if std::io::IsTerminal::is_terminal(&std::io::stdin()) {
return run_attach(session_name, &[]);
} else {
eprintln!(
"Started new session and opened {} file(s). Attach with: fresh -a{}",
file_requests.len(),
session_name.map_or(String::new(), |n| format!(" {}", n)),
);
return Ok(());
}
} else if wait {
loop {
match conn.read_control() {
Ok(Some(line)) => {
if let Ok(msg) = serde_json::from_str::<ServerControl>(&line) {
match msg {
ServerControl::WaitComplete => break,
ServerControl::Quit { .. } => break,
_ => {} }
}
}
Ok(None) => break, Err(_) => break, }
}
} else {
eprintln!("Opened {} file(s) in session.", file_requests.len());
}
Ok(())
}
fn run_attach_command(args: &Args) -> AnyhowResult<()> {
run_attach(args.session_name.as_deref(), &args.files)
}
fn run_attach(session_name: Option<&str>, files: &[String]) -> AnyhowResult<()> {
use crossterm::terminal::enable_raw_mode;
use fresh::server::protocol::{
ClientControl, ClientHello, ServerControl, TermSize, PROTOCOL_VERSION,
};
use fresh::server::spawn_server_detached;
use tracing_subscriber::{fmt, EnvFilter};
let log_path = fresh::services::log_dirs::log_dir()
.join(format!("fresh-client-{}.log", std::process::id()));
let log_file = std::fs::File::create(&log_path).ok();
if let Some(file) = log_file {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"));
#[allow(clippy::let_underscore_must_use)]
let _ = fmt()
.with_env_filter(filter)
.with_writer(std::sync::Mutex::new(file))
.with_ansi(false)
.try_init();
}
let working_dir = std::env::current_dir()?;
let ssh_url = extract_ssh_url_from_files(files)?;
let socket_paths = resolve_session(session_name)?;
if socket_paths.cleanup_if_stale() {
eprintln!("Cleaned up stale session.");
}
let server_was_started = if !socket_paths.is_server_alive() {
eprintln!("Starting server...");
let _pid = spawn_server_detached(session_name, ssh_url.as_deref())?;
true
} else {
false
};
let (cols, rows) = crossterm::terminal::size()?;
if server_was_started {
use fresh::server::daemon::is_process_running;
loop {
if let Ok(Some(pid)) = socket_paths.read_pid() {
if is_process_running(pid) {
break; }
}
std::thread::yield_now();
}
}
let conn = fresh::server::ipc::ClientConnection::connect(&socket_paths)?;
if server_was_started {
eprintln!("Server started.");
}
let term_size = TermSize::new(cols, rows);
let hello = ClientHello::new(term_size);
let hello_json = serde_json::to_string(&ClientControl::Hello(hello))?;
conn.write_control(&hello_json)?;
let response = conn
.read_control()?
.ok_or_else(|| anyhow::anyhow!("Server closed connection during handshake"))?;
let server_msg: ServerControl = serde_json::from_str(&response)?;
match server_msg {
ServerControl::Hello(server_hello) => {
if server_hello.protocol_version != PROTOCOL_VERSION {
eprintln!(
"Version mismatch: server is v{}",
server_hello.server_version
);
eprintln!("Please restart the server with the same version as the client.");
return Ok(());
}
tracing::info!(
"Connected to session '{}' (server {})",
server_hello.session_id,
server_hello.server_version
);
}
ServerControl::VersionMismatch(mismatch) => {
eprintln!("Version mismatch: server is v{}", mismatch.server_version);
eprintln!("Please restart the server with the same version as the client.");
return Ok(());
}
ServerControl::Error { message } => {
return Err(anyhow::anyhow!("Server error: {}", message));
}
_ => {
return Err(anyhow::anyhow!("Unexpected server response"));
}
}
if !files.is_empty() {
let local_files: Vec<String> = if ssh_url.is_some() {
files
.iter()
.filter_map(|f| match parse_location(f) {
ParsedLocation::Remote(r) => Some(r.path),
ParsedLocation::Local(_) => None,
})
.collect()
} else {
files.to_vec()
};
let file_requests = build_file_requests(&local_files, &working_dir);
if !file_requests.is_empty() {
let msg = serde_json::to_string(&ClientControl::OpenFiles {
files: file_requests,
wait: false,
})?;
conn.write_control(&msg)?;
}
}
#[cfg(windows)]
let original_console_mode = fresh_winterm::save_console_mode();
enable_raw_mode()?;
let result = client::run_client_relay(conn);
fresh::services::terminal_modes::emergency_cleanup();
#[cfg(windows)]
let _ = fresh_winterm::restore_console_mode(original_console_mode);
match result {
Ok(client::ClientExitReason::ServerQuit) => {
tracing::debug!("Client exit: ServerQuit");
}
Err(e) => {
tracing::debug!("Client error: {}", e);
return Err(e.into());
}
Ok(client::ClientExitReason::Detached) => {
tracing::debug!("Client exit: Detached");
eprintln!("Detached from session. Server continues running.");
eprintln!("Reattach with: fresh -a or fresh session attach");
}
Ok(client::ClientExitReason::VersionMismatch { server_version }) => {
tracing::debug!("Client exit: VersionMismatch");
eprintln!("Version mismatch: server is v{}", server_version);
eprintln!("Please restart the server with the same version as the client.");
}
Ok(client::ClientExitReason::Error(e)) => {
tracing::debug!("Client exit: Error({})", e);
eprintln!("Connection error: {}", e);
return Err(e.into());
}
}
Ok(())
}
fn print_deprecation_warnings(cli: &Cli) {
if !cli.cmd.is_empty() {
return;
}
if cli.dump_config {
eprintln!("warning: --dump-config is deprecated, use `fresh --cmd config show` instead");
}
if cli.show_paths {
eprintln!("warning: --show-paths is deprecated, use `fresh --cmd config paths` instead");
}
if cli.init.is_some() {
eprintln!("warning: --init is deprecated, use `fresh --cmd init` instead");
}
}
fn is_interactive_launch(args: &Args) -> bool {
std::io::IsTerminal::is_terminal(&std::io::stdin())
&& !args.stdin
&& !args.attach
&& !args.server
&& !args.list_sessions
&& args.kill.is_none()
&& args.open_files_in_session.is_none()
&& args.init.is_none()
&& !args.list_grammars
&& !args.dump_config
&& !args.show_paths
&& args.check_plugin.is_none()
}
fn show_paths_command() -> AnyhowResult<()> {
let dir_context = fresh::config_io::DirectoryContext::from_system()?;
fresh::services::log_dirs::print_all_paths(&dir_context);
Ok(())
}
fn dump_config_command(args: &Args) -> AnyhowResult<()> {
let dir_context = fresh::config_io::DirectoryContext::from_system()?;
let working_dir = std::env::current_dir().unwrap_or_default();
let config = if let Some(config_path) = &args.config {
config::Config::load_from_file(config_path)
.with_context(|| format!("Failed to load config from {}", config_path.display()))?
} else {
config::Config::load_with_layers(&dir_context, &working_dir)
};
println!(
"{}",
serde_json::to_string_pretty(&config).context("Failed to serialize config")?
);
Ok(())
}
fn run_if_subcommand(args: &Args) -> Option<AnyhowResult<()>> {
if args.show_paths {
return Some(show_paths_command());
}
if args.dump_config {
return Some(dump_config_command(args));
}
if args.list_grammars {
return Some(list_grammars_command());
}
#[cfg(feature = "plugins")]
if let Some(plugin_path) = &args.check_plugin {
return Some(check_plugin_bundle(plugin_path));
}
if let Some(ref pkg_type) = args.init {
if pkg_type.as_deref() == Some("check") {
return Some(init_check_command());
}
return Some(init_package_command(pkg_type.clone()));
}
if args.list_sessions {
return Some(list_sessions_command());
}
if let Some(ref session) = args.kill {
return Some(kill_session_command(session.as_deref(), args));
}
if args.server {
return Some(run_server_command(args));
}
if let Some((session_name, files, wait)) = &args.open_files_in_session {
return Some(run_open_files_command(
session_name.as_deref(),
files,
*wait,
));
}
if args.attach {
return Some(run_attach_command(args));
}
#[cfg(feature = "gui")]
if args.gui {
return Some(fresh::gui::run_gui(
&args.files,
args.no_plugins,
args.no_init,
args.config.as_ref(),
args.locale.as_deref(),
args.no_session,
args.log_file.as_ref(),
));
}
None
}
fn restore_editor_workspace(editor: &mut Editor, args: &Args) {
if args.force_restore || editor.config().editor.restore_previous_session {
match editor.try_restore_workspace() {
Ok(true) => tracing::info!("Workspace restored successfully"),
Ok(false) => tracing::debug!("No previous workspace found"),
Err(e) => tracing::warn!("Failed to restore workspace: {}", e),
}
} else {
tracing::info!(
"Skipping workspace restore on restart: editor.restore_previous_session is disabled"
);
match editor.try_restore_hot_exit_buffers() {
Ok(n) if n > 0 => tracing::info!(
"Restored {} hot-exit buffer(s) on restart despite skipping session restore",
n
),
Ok(_) => {}
Err(e) => tracing::warn!("Failed to restore hot-exit buffers on restart: {}", e),
}
}
}
fn main() -> AnyhowResult<()> {
match real_main() {
Ok(()) => Ok(()),
Err(e) => {
if e.downcast_ref::<remote::SshError>().is_some()
|| e.chain()
.any(|cause| cause.downcast_ref::<remote::SshError>().is_some())
{
eprintln!("Error: {:#}", e);
std::process::exit(1);
}
Err(e)
}
}
}
fn real_main() -> AnyhowResult<()> {
if std::env::var_os("RUST_BACKTRACE").is_none() {
std::env::set_var("RUST_BACKTRACE", "1");
}
let cli = Cli::parse();
print_deprecation_warnings(&cli);
let args: Args = cli.into();
if is_interactive_launch(&args) {
std::env::set_var("FRESH_INTERACTIVE", "1");
}
if let Some(result) = run_if_subcommand(&args) {
return result;
}
#[cfg(windows)]
let original_console_mode = fresh_winterm::save_console_mode();
let SetupState {
config,
mut tracing_handles,
mut terminal,
terminal_size,
file_locations,
show_file_explorer,
dir_context,
current_working_dir: initial_working_dir,
mut stdin_stream,
key_translator,
#[cfg(target_os = "linux")]
gpm_client,
#[cfg(not(target_os = "linux"))]
gpm_client,
mut terminal_modes,
authority: startup_authority,
_remote_session,
} = initialize_app(&args).context("Failed to initialize application")?;
let mut current_working_dir = initial_working_dir;
let (terminal_width, terminal_height) = terminal_size;
let mut is_first_run = true;
let mut restore_workspace_on_restart = false;
let mut current_authority = startup_authority;
let status_log_path: Option<PathBuf> = tracing_handles.as_ref().map(|h| h.status.path.clone());
let mut warning_log_slot: Option<(std::sync::mpsc::Receiver<()>, PathBuf)> = tracing_handles
.take()
.map(|h| (h.warning.receiver, h.warning.path));
let (result, last_update_result) = loop {
let first_run = is_first_run;
let workspace_enabled = !args.no_session;
let color_capability = fresh::view::color_support::ColorCapability::detect();
let fs = current_authority.filesystem.clone();
tracing::info!("Creating editor instance...");
let mut editor = Editor::with_working_dir(
config.clone(),
terminal_width,
terminal_height,
current_working_dir.clone(),
dir_context.clone(),
!args.no_plugins,
color_capability,
fs,
)
.context("Failed to create editor instance")?;
tracing::info!("Editor instance created");
editor.set_boot_authority(current_authority.clone());
editor.load_init_script(!args.no_init);
editor.fire_plugins_loaded_hook();
#[cfg(target_os = "linux")]
if gpm_client.is_some() {
editor.set_gpm_active(true);
}
if let Some(p) = status_log_path.as_ref() {
editor.set_status_log_path(p.clone());
}
if let Some((rx, p)) = warning_log_slot.take() {
editor.set_warning_log(rx, p);
}
if first_run {
tracing::info!("Running first-run setup...");
handle_first_run_setup(
&mut editor,
&args,
&file_locations,
show_file_explorer,
&mut stdin_stream,
workspace_enabled,
)
.context("Failed first run setup")?;
tracing::info!("First-run setup complete");
} else {
if restore_workspace_on_restart {
restore_editor_workspace(&mut editor, &args);
}
editor.show_file_explorer();
let path = current_working_dir
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| ".".to_string());
editor.set_status_message(fresh::i18n::switched_to_project_message(&path));
}
if let Err(e) = editor.start_recovery_session() {
tracing::warn!("Failed to start recovery session: {}", e);
}
editor.process_pending_file_opens();
editor.fire_ready_hook();
let iteration = run_editor_iteration(
&mut editor,
workspace_enabled,
&mut terminal,
&key_translator,
#[cfg(target_os = "linux")]
&gpm_client,
&mut terminal_modes,
)
.context("Editor iteration failed")?;
let update_result = iteration.update_result;
let restart_dir = iteration.restart_dir;
let loop_result = iteration.loop_result;
if let Some(new_authority) = editor.take_pending_authority() {
tracing::info!("Authority transition queued; restarting editor");
current_authority = new_authority;
}
warning_log_slot = editor.take_warning_log();
drop(editor);
if let Some(new_dir) = restart_dir {
tracing::info!(
"Restarting editor with new working directory: {}",
new_dir.display()
);
current_working_dir = Some(new_dir);
is_first_run = false;
restore_workspace_on_restart = true; terminal
.clear()
.context("Failed to clear terminal for restart")?;
continue;
}
break (loop_result, update_result);
};
terminal_modes.undo();
#[cfg(windows)]
let _ = fresh_winterm::restore_console_mode(original_console_mode);
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.context("Editor loop returned an error")
}
fn handle_suspend_request(
editor: &mut Editor,
terminal_modes: &mut TerminalModes,
) -> AnyhowResult<()> {
#[cfg(unix)]
{
let keyboard_config = KeyboardConfig {
disambiguate_escape_codes: editor.config().editor.keyboard_disambiguate_escape_codes,
report_event_types: editor.config().editor.keyboard_report_event_types,
report_alternate_keys: editor.config().editor.keyboard_report_alternate_keys,
report_all_keys_as_escape_codes: editor
.config()
.editor
.keyboard_report_all_keys_as_escape_codes,
};
terminal_modes::suspend_and_resume(terminal_modes, Some(&keyboard_config))
.context("Failed to suspend process")?;
editor.request_full_redraw();
editor.set_status_message(fresh::i18n::resumed_after_suspend_message());
}
#[cfg(not(unix))]
{
let _ = terminal_modes;
editor.set_status_message(fresh::i18n::suspend_unsupported_message());
}
Ok(())
}
#[cfg(target_os = "linux")]
fn run_event_loop(
editor: &mut Editor,
terminal: &mut Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>,
workspace_enabled: bool,
key_translator: &KeyTranslator,
gpm_client: &Option<GpmClient>,
terminal_modes: &mut TerminalModes,
) -> AnyhowResult<()> {
run_event_loop_common(
editor,
terminal,
workspace_enabled,
key_translator,
terminal_modes,
|timeout| poll_with_gpm(gpm_client.as_ref(), timeout),
)
}
#[cfg(windows)]
fn run_event_loop(
editor: &mut Editor,
terminal: &mut Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>,
workspace_enabled: bool,
key_translator: &KeyTranslator,
terminal_modes: &mut TerminalModes,
) -> AnyhowResult<()> {
use fresh::server::input_parser::InputParser;
use fresh_winterm::{VtInputEvent, VtInputReader};
let old_console_mode = fresh_winterm::enable_vt_input()?;
let mouse_mode = if editor.config().editor.mouse_hover_enabled {
fresh_winterm::MouseMode::AllMotion
} else {
fresh_winterm::MouseMode::CellMotion
};
fresh_winterm::enable_mouse_tracking(mouse_mode)?;
let reader = VtInputReader::spawn();
let mut input_parser = InputParser::new();
let mut event_buffer: std::collections::VecDeque<CrosstermEvent> =
std::collections::VecDeque::new();
let result = run_event_loop_common(
editor,
terminal,
workspace_enabled,
key_translator,
terminal_modes,
|timeout| -> AnyhowResult<Option<CrosstermEvent>> {
if let Some(event) = event_buffer.pop_front() {
return Ok(Some(event));
}
let mut got_any = false;
loop {
let event = if !got_any {
reader.poll(timeout)
} else {
reader.try_recv()
};
match event {
Some(VtInputEvent::VtBytes(bytes)) => {
let parsed = input_parser.parse(&bytes);
for ev in parsed {
event_buffer.push_back(ev);
}
got_any = true;
}
Some(VtInputEvent::Resize) => {
if let Ok((cols, rows)) = crossterm::terminal::size() {
event_buffer.push_back(CrosstermEvent::Resize(cols, rows));
}
got_any = true;
}
Some(VtInputEvent::FocusGained) => {
event_buffer.push_back(CrosstermEvent::FocusGained);
got_any = true;
}
Some(VtInputEvent::FocusLost) => {
event_buffer.push_back(CrosstermEvent::FocusLost);
got_any = true;
}
None => break,
}
}
if !got_any {
let flushed = input_parser.parse(b"");
for ev in flushed {
event_buffer.push_back(ev);
}
}
Ok(event_buffer.pop_front())
},
);
let _ = fresh_winterm::disable_mouse_tracking();
let _ = fresh_winterm::restore_console_mode(old_console_mode);
result
}
#[cfg(not(any(target_os = "linux", windows)))]
fn run_event_loop(
editor: &mut Editor,
terminal: &mut Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>,
workspace_enabled: bool,
key_translator: &KeyTranslator,
terminal_modes: &mut TerminalModes,
) -> AnyhowResult<()> {
run_event_loop_common(
editor,
terminal,
workspace_enabled,
key_translator,
terminal_modes,
|timeout| {
if event_poll(timeout)? {
Ok(Some(event_read()?))
} else {
Ok(None)
}
},
)
}
fn run_event_loop_common<F>(
editor: &mut Editor,
terminal: &mut Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>,
workspace_enabled: bool,
_key_translator: &KeyTranslator,
terminal_modes: &mut TerminalModes,
mut poll_event: F,
) -> AnyhowResult<()>
where
F: FnMut(Duration) -> AnyhowResult<Option<CrosstermEvent>>,
{
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 {
{
let _span = tracing::info_span!("editor_tick").entered();
if fresh::app::editor_tick(editor, || {
terminal.clear()?;
Ok(())
})? {
needs_render = true;
}
}
if editor.should_quit() {
if editor.config().editor.auto_save_enabled {
match editor.save_all_on_exit() {
Ok(count) if count > 0 => {
tracing::info!("Auto-saved {} buffer(s) on exit", count);
}
Ok(_) => {}
Err(e) => {
tracing::warn!("Failed to auto-save on exit: {}", e);
}
}
}
if let Err(e) = editor.end_recovery_session() {
tracing::warn!("Failed to end recovery session: {}", e);
}
if workspace_enabled {
if let Err(e) = editor.save_workspace() {
tracing::warn!("Failed to save workspace: {}", e);
} else {
tracing::debug!("Workspace saved successfully");
}
}
break;
}
if editor.take_suspend_request() {
handle_suspend_request(editor, terminal_modes)?;
needs_render = true;
last_render = Instant::now() - FRAME_DURATION;
continue;
}
let animations_active = editor.animations.is_active();
if animations_active {
needs_render = true;
}
if needs_render && last_render.elapsed() >= FRAME_DURATION {
{
let _span = tracing::info_span!("terminal_draw").entered();
use crossterm::ExecutableCommand;
stdout().execute(crossterm::terminal::BeginSynchronizedUpdate)?;
terminal.draw(|frame| editor.render(frame))?;
stdout().execute(crossterm::terminal::EndSynchronizedUpdate)?;
}
last_render = Instant::now();
needs_render = false;
}
let event = if let Some(e) = pending_event.take() {
Some(e)
} else {
let mut timeout = if needs_render {
FRAME_DURATION.saturating_sub(last_render.elapsed())
} else {
Duration::from_millis(50)
};
if editor.animations.is_active() {
let until_next_frame = FRAME_DURATION.saturating_sub(last_render.elapsed());
timeout = timeout.min(until_next_frame);
if let Some(deadline) = editor.animations.next_deadline() {
let until_deadline = deadline.saturating_duration_since(Instant::now());
timeout = timeout.min(until_deadline);
}
}
poll_event(timeout)?
};
let Some(event) = event else { continue };
let (event, next) = coalesce_mouse_moves(event)?;
pending_event = next;
if editor.is_event_debug_active() {
if let CrosstermEvent::Key(key_event) = event {
if key_event.kind == KeyEventKind::Press {
editor.handle_event_debug_input(&key_event);
needs_render = true;
}
}
continue;
}
match event {
CrosstermEvent::Key(key_event) => {
if key_event.kind == KeyEventKind::Press {
let _span = tracing::trace_span!(
"handle_key",
code = ?key_event.code,
modifiers = ?key_event.modifiers,
)
.entered();
let translated_event = editor.key_translator().translate(key_event);
handle_key_event(editor, translated_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;
}
CrosstermEvent::Paste(text) => {
editor.paste_text(text);
needs_render = true;
}
CrosstermEvent::FocusGained => {
editor.focus_gained();
needs_render = true;
}
_ => {}
}
}
Ok(())
}
#[cfg(target_os = "linux")]
fn poll_with_gpm(
gpm_client: Option<&GpmClient>,
timeout: Duration,
) -> AnyhowResult<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.is_some_and(|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.is_some_and(|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) -> AnyhowResult<()> {
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) -> AnyhowResult<bool> {
tracing::trace!(
"Mouse event received: kind={:?}, column={}, row={}, modifiers={:?}",
mouse_event.kind,
mouse_event.column,
mouse_event.row,
mouse_event.modifiers
);
editor
.handle_mouse(mouse_event)
.context("Failed to handle mouse event")
}
fn coalesce_mouse_moves(
event: CrosstermEvent,
) -> AnyhowResult<(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_multiple_files() {
let inputs = ["file1.txt", "sub/file2.rs:10", "file3.cpp:20:5"];
let locs: Vec<FileLocation> = inputs.iter().map(|i| parse_file_location(i)).collect();
assert_eq!(locs.len(), 3);
assert_eq!(locs[0].path, PathBuf::from("file1.txt"));
assert_eq!(locs[0].line, None);
assert_eq!(locs[0].column, None);
assert_eq!(locs[1].path, PathBuf::from("sub/file2.rs"));
assert_eq!(locs[1].line, Some(10));
assert_eq!(locs[1].column, None);
assert_eq!(locs[2].path, PathBuf::from("file3.cpp"));
assert_eq!(locs[2].line, Some(20));
assert_eq!(locs[2].column, Some(5));
}
#[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);
}
#[test]
fn test_parse_location_local_simple() {
let loc = parse_location("file.txt");
match loc {
ParsedLocation::Local(fl) => {
assert_eq!(fl.path, PathBuf::from("file.txt"));
assert_eq!(fl.line, None);
}
ParsedLocation::Remote(_) => panic!("Expected local, got remote"),
}
}
#[test]
fn test_parse_location_local_with_line() {
let loc = parse_location("/path/to/file.rs:42");
match loc {
ParsedLocation::Local(fl) => {
assert_eq!(fl.path, PathBuf::from("/path/to/file.rs"));
assert_eq!(fl.line, Some(42));
}
ParsedLocation::Remote(_) => panic!("Expected local, got remote"),
}
}
#[test]
fn test_parse_location_remote_simple() {
let loc = parse_location("user@host:/path/to/file.rs");
match loc {
ParsedLocation::Remote(rl) => {
assert_eq!(rl.user, "user");
assert_eq!(rl.host, "host");
assert_eq!(rl.path, "/path/to/file.rs");
assert_eq!(rl.line, None);
assert_eq!(rl.column, None);
}
ParsedLocation::Local(_) => panic!("Expected remote, got local"),
}
}
#[test]
fn test_parse_location_remote_with_line() {
let loc = parse_location("alice@server.com:/home/alice/project/main.rs:42");
match loc {
ParsedLocation::Remote(rl) => {
assert_eq!(rl.user, "alice");
assert_eq!(rl.host, "server.com");
assert_eq!(rl.path, "/home/alice/project/main.rs");
assert_eq!(rl.line, Some(42));
assert_eq!(rl.column, None);
}
ParsedLocation::Local(_) => panic!("Expected remote, got local"),
}
}
#[test]
fn test_parse_location_remote_with_line_and_col() {
let loc = parse_location("bob@example.org:src/lib.rs:100:25");
match loc {
ParsedLocation::Remote(rl) => {
assert_eq!(rl.user, "bob");
assert_eq!(rl.host, "example.org");
assert_eq!(rl.path, "src/lib.rs");
assert_eq!(rl.line, Some(100));
assert_eq!(rl.column, Some(25));
}
ParsedLocation::Local(_) => panic!("Expected remote, got local"),
}
}
#[test]
fn test_parse_location_remote_relative_path() {
let loc = parse_location("user@host:relative/path/file.txt");
match loc {
ParsedLocation::Remote(rl) => {
assert_eq!(rl.user, "user");
assert_eq!(rl.host, "host");
assert_eq!(rl.path, "relative/path/file.txt");
}
ParsedLocation::Local(_) => panic!("Expected remote, got local"),
}
}
#[test]
fn test_parse_location_email_like_not_remote() {
let loc = parse_location("user@host");
match loc {
ParsedLocation::Local(fl) => {
assert_eq!(fl.path, PathBuf::from("user@host"));
}
ParsedLocation::Remote(_) => panic!("Expected local, got remote"),
}
}
#[test]
fn test_parse_location_at_in_path_local() {
let loc = parse_location("/path/with@sign/file.txt");
match loc {
ParsedLocation::Local(fl) => {
assert_eq!(fl.path, PathBuf::from("/path/with@sign/file.txt"));
}
ParsedLocation::Remote(_) => panic!("Expected local, got remote"),
}
}
#[test]
fn test_parse_location_ssh_url_user_and_path() {
let loc = parse_location("ssh://alice@host.example/home/alice/main.rs");
match loc {
ParsedLocation::Remote(rl) => {
assert_eq!(rl.user, "alice");
assert_eq!(rl.host, "host.example");
assert_eq!(rl.port, None);
assert_eq!(rl.path, "/home/alice/main.rs");
assert_eq!(rl.line, None);
assert_eq!(rl.column, None);
}
ParsedLocation::Local(_) => panic!("Expected remote, got local"),
}
}
#[test]
fn test_parse_location_ssh_url_with_port() {
let loc = parse_location("ssh://bob@server:2222/etc/hosts");
match loc {
ParsedLocation::Remote(rl) => {
assert_eq!(rl.user, "bob");
assert_eq!(rl.host, "server");
assert_eq!(rl.port, Some(2222));
assert_eq!(rl.path, "/etc/hosts");
}
ParsedLocation::Local(_) => panic!("Expected remote, got local"),
}
}
#[test]
fn test_parse_location_ssh_url_with_port_and_line_col() {
let loc = parse_location("ssh://bob@server:2222/src/lib.rs:42:7");
match loc {
ParsedLocation::Remote(rl) => {
assert_eq!(rl.user, "bob");
assert_eq!(rl.host, "server");
assert_eq!(rl.port, Some(2222));
assert_eq!(rl.path, "/src/lib.rs");
assert_eq!(rl.line, Some(42));
assert_eq!(rl.column, Some(7));
}
ParsedLocation::Local(_) => panic!("Expected remote, got local"),
}
}
#[test]
fn test_parse_location_ssh_url_default_user_from_env() {
let prev_user = std::env::var("USER").ok();
let prev_username = std::env::var("USERNAME").ok();
unsafe {
std::env::set_var("USER", "envuser");
}
let loc = parse_location("ssh://host.example/tmp/file.txt");
unsafe {
match prev_user {
Some(ref v) => std::env::set_var("USER", v),
None => std::env::remove_var("USER"),
}
match prev_username {
Some(ref v) => std::env::set_var("USERNAME", v),
None => std::env::remove_var("USERNAME"),
}
}
match loc {
ParsedLocation::Remote(rl) => {
assert_eq!(rl.user, "envuser");
assert_eq!(rl.host, "host.example");
assert_eq!(rl.port, None);
assert_eq!(rl.path, "/tmp/file.txt");
}
ParsedLocation::Local(_) => panic!("Expected remote, got local"),
}
}
#[test]
fn test_parse_location_ssh_url_missing_path_is_local() {
let loc = parse_location("ssh://host.example");
match loc {
ParsedLocation::Local(fl) => {
assert_eq!(fl.path, PathBuf::from("ssh://host.example"));
}
ParsedLocation::Remote(_) => panic!("Expected local, got remote"),
}
}
#[test]
fn test_parse_location_ssh_url_bad_port_is_local() {
let loc = parse_location("ssh://alice@host:not-a-port/file");
match loc {
ParsedLocation::Local(fl) => {
assert_eq!(fl.path, PathBuf::from("ssh://alice@host:not-a-port/file"));
}
ParsedLocation::Remote(_) => panic!("Expected local, got remote"),
}
}
#[test]
fn test_parse_location_ssh_url_empty_user_is_local() {
let loc = parse_location("ssh://@host/path");
match loc {
ParsedLocation::Local(fl) => {
assert_eq!(fl.path, PathBuf::from("ssh://@host/path"));
}
ParsedLocation::Remote(_) => panic!("Expected local, got remote"),
}
}
#[test]
fn test_remote_location_to_ssh_url_with_port() {
let remote = RemoteLocation {
user: "alice".into(),
host: "host.example".into(),
port: Some(2222),
path: "/etc/hosts".into(),
line: Some(10),
column: None,
};
assert_eq!(
remote_location_to_ssh_url(&remote),
"ssh://alice@host.example:2222/etc/hosts"
);
}
#[test]
fn test_remote_location_to_ssh_url_no_port() {
let remote = RemoteLocation {
user: "bob".into(),
host: "server".into(),
port: None,
path: "/home/bob".into(),
line: None,
column: None,
};
assert_eq!(
remote_location_to_ssh_url(&remote),
"ssh://bob@server/home/bob"
);
}
#[test]
fn test_extract_ssh_url_none_for_local_only() {
let files = vec!["foo.txt".to_string(), "bar:42".to_string()];
assert_eq!(extract_ssh_url_from_files(&files).unwrap(), None);
}
#[test]
fn test_extract_ssh_url_from_ssh_urls() {
let files = vec![
"ssh://alice@host/a".to_string(),
"ssh://alice@host/b:10".to_string(),
];
assert_eq!(
extract_ssh_url_from_files(&files).unwrap(),
Some("ssh://alice@host/a".to_string())
);
}
#[test]
fn test_extract_ssh_url_from_scp_style() {
let files = vec!["alice@host:/etc/hosts".to_string()];
assert_eq!(
extract_ssh_url_from_files(&files).unwrap(),
Some("ssh://alice@host/etc/hosts".to_string())
);
}
#[test]
fn test_extract_ssh_url_rejects_mismatched_hosts() {
let files = vec![
"ssh://alice@host1/a".to_string(),
"ssh://alice@host2/b".to_string(),
];
assert!(extract_ssh_url_from_files(&files).is_err());
}
#[test]
fn test_extract_ssh_url_rejects_mixed_local_and_remote() {
let files = vec!["ssh://alice@host/a".to_string(), "local.txt".to_string()];
assert!(extract_ssh_url_from_files(&files).is_err());
}
#[test]
fn test_parse_ssh_url_arg_accepts_valid_url() {
let rl = parse_ssh_url_arg("ssh://alice@host:2222/path").unwrap();
assert_eq!(rl.user, "alice");
assert_eq!(rl.host, "host");
assert_eq!(rl.port, Some(2222));
assert_eq!(rl.path, "/path");
}
#[test]
fn test_parse_ssh_url_arg_rejects_scp_style() {
assert!(parse_ssh_url_arg("alice@host:/path").is_err());
}
#[test]
fn test_parse_ssh_url_arg_rejects_malformed() {
assert!(parse_ssh_url_arg("ssh://host").is_err()); assert!(parse_ssh_url_arg("ssh://alice@host:bad/path").is_err()); }
#[test]
fn test_parse_file_location_line_range() {
let loc = parse_file_location("file.txt:13-16");
assert_eq!(loc.path, PathBuf::from("file.txt"));
assert_eq!(loc.line, Some(13));
assert_eq!(loc.column, None);
assert_eq!(loc.end_line, Some(16));
assert_eq!(loc.end_column, None);
assert_eq!(loc.message, None);
}
#[test]
fn test_parse_file_location_full_range() {
let loc = parse_file_location("file.txt:13:17-21:1");
assert_eq!(loc.path, PathBuf::from("file.txt"));
assert_eq!(loc.line, Some(13));
assert_eq!(loc.column, Some(17));
assert_eq!(loc.end_line, Some(21));
assert_eq!(loc.end_column, Some(1));
assert_eq!(loc.message, None);
}
#[test]
fn test_parse_file_location_line_range_with_message() {
let loc = parse_file_location("file.txt:13-16@\"hello world\"");
assert_eq!(loc.path, PathBuf::from("file.txt"));
assert_eq!(loc.line, Some(13));
assert_eq!(loc.end_line, Some(16));
assert_eq!(loc.message, Some("hello world".to_string()));
}
#[test]
fn test_parse_file_location_point_with_message() {
let loc = parse_file_location("file.txt:13:5@\"msg\"");
assert_eq!(loc.path, PathBuf::from("file.txt"));
assert_eq!(loc.line, Some(13));
assert_eq!(loc.column, Some(5));
assert_eq!(loc.end_line, None);
assert_eq!(loc.end_column, None);
assert_eq!(loc.message, Some("msg".to_string()));
}
#[test]
fn test_parse_file_location_full_range_with_message() {
let loc = parse_file_location("file.txt:13:17-21:1@\"explanation\"");
assert_eq!(loc.path, PathBuf::from("file.txt"));
assert_eq!(loc.line, Some(13));
assert_eq!(loc.column, Some(17));
assert_eq!(loc.end_line, Some(21));
assert_eq!(loc.end_column, Some(1));
assert_eq!(loc.message, Some("explanation".to_string()));
}
#[test]
fn test_parse_file_location_message_with_escaped_quotes() {
let loc = parse_file_location(r#"file.txt:5@"say \"hello\"""#);
assert_eq!(loc.path, PathBuf::from("file.txt"));
assert_eq!(loc.line, Some(5));
assert_eq!(loc.message, Some("say \"hello\"".to_string()));
}
#[test]
fn test_parse_file_location_empty_message() {
let loc = parse_file_location("file.txt:5@\"\"");
assert_eq!(loc.path, PathBuf::from("file.txt"));
assert_eq!(loc.line, Some(5));
assert_eq!(loc.message, Some("".to_string()));
}
#[test]
fn test_parse_file_location_line_only_with_message() {
let loc = parse_file_location("file.txt:10@\"check this\"");
assert_eq!(loc.path, PathBuf::from("file.txt"));
assert_eq!(loc.line, Some(10));
assert_eq!(loc.column, None);
assert_eq!(loc.end_line, None);
assert_eq!(loc.message, Some("check this".to_string()));
}
#[test]
fn test_parse_file_location_absolute_path_with_range() {
let loc = parse_file_location("/home/user/file.txt:5-10");
assert_eq!(loc.path, PathBuf::from("/home/user/file.txt"));
assert_eq!(loc.line, Some(5));
assert_eq!(loc.end_line, Some(10));
}
#[test]
fn test_parse_file_location_no_range_fields_for_simple() {
let loc = parse_file_location("foo.txt:42:10");
assert_eq!(loc.end_line, None);
assert_eq!(loc.end_column, None);
assert_eq!(loc.message, None);
}
#[test]
fn test_extract_message_suffix() {
let (rest, msg) = extract_message_suffix("file.txt:10@\"hello\"");
assert_eq!(rest, "file.txt:10");
assert_eq!(msg, Some("hello".to_string()));
}
#[test]
fn test_extract_message_suffix_no_message() {
let (rest, msg) = extract_message_suffix("file.txt:10");
assert_eq!(rest, "file.txt:10");
assert_eq!(msg, 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));
}
}
}