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, config, config_io::DirectoryContext, model::filesystem::StdFileSystem,
services::release_checker, services::signal_handler, services::tracing_setup::TracingHandles,
};
use ratatui::Terminal;
use std::{
io::{self, stdout},
path::PathBuf,
time::Duration,
};
#[derive(Parser, Debug)]
#[command(name = "fresh")]
#[command(about = "A terminal text editor with multi-cursor support", long_about = None)]
#[command(version)]
struct Args {
#[arg(value_name = "FILES")]
files: Vec<String>,
#[arg(long)]
stdin: bool,
#[arg(long)]
no_plugins: bool,
#[arg(long, value_name = "PATH")]
config: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
log_file: Option<PathBuf>,
#[arg(long, value_name = "LOG_FILE")]
event_log: Option<PathBuf>,
#[arg(long)]
no_session: bool,
#[arg(long)]
no_upgrade_check: bool,
#[arg(long)]
dump_config: bool,
#[arg(long)]
show_paths: bool,
#[arg(long, value_name = "LOCALE")]
locale: Option<String>,
#[arg(long, value_name = "PLUGIN_PATH")]
check_plugin: Option<PathBuf>,
#[arg(long, value_name = "TYPE")]
init: Option<Option<String>>,
}
#[derive(Debug)]
struct FileLocation {
path: PathBuf,
line: Option<usize>,
column: Option<usize>,
}
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>,
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> {
anyhow::bail!(io::Error::new(
io::ErrorKind::Unsupported,
"Reading from stdin is not yet supported on Windows",
))
}
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<()> {
anyhow::bail!(io::Error::new(
io::ErrorKind::Unsupported,
"Reading from stdin is not yet supported on Windows",
))
}
fn handle_first_run_setup(
editor: &mut Editor,
args: &Args,
file_locations: &[FileLocation],
show_file_explorer: bool,
stdin_stream: &mut Option<StdinStreamState>,
tracing_handles: &mut Option<TracingHandles>,
session_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)?;
}
if let Some(handles) = tracing_handles.take() {
editor.set_warning_log(handles.warning.receiver, handles.warning.path);
editor.set_status_log_path(handles.status.path);
}
if session_enabled {
match editor.try_restore_session() {
Ok(true) => {
tracing::info!("Session restored successfully");
}
Ok(false) => {
tracing::debug!("No previous session found");
}
Err(e) => {
tracing::warn!("Failed to restore session: {}", e);
}
}
}
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())?;
}
for loc in file_locations {
if loc.path.is_dir() {
continue;
}
editor.open_file(&loc.path)?;
if let Some(line) = loc.line {
editor.goto_line_col(line, loc.column);
}
}
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 parse_file_location(input: &str) -> FileLocation {
use std::path::{Component, Path};
let full_path = PathBuf::from(input);
if full_path.is_file() {
return FileLocation {
path: full_path,
line: None,
column: None,
};
}
let has_prefix = Path::new(input)
.components()
.next()
.map(|c| matches!(c, Component::Prefix(_)))
.unwrap_or(false);
let search_start = if has_prefix {
input.find(':').map(|i| i + 1).unwrap_or(0)
} else {
0
};
let suffix = &input[search_start..];
let parts: Vec<&str> = suffix.rsplitn(3, ':').collect();
match parts.as_slice() {
[maybe_col, maybe_line, rest] => {
if let (Ok(line), Ok(col)) = (maybe_line.parse::<usize>(), maybe_col.parse::<usize>()) {
let path_str = if has_prefix {
format!("{}{}", &input[..search_start], rest)
} else {
rest.to_string()
};
return FileLocation {
path: PathBuf::from(path_str),
line: Some(line),
column: Some(col),
};
}
}
[maybe_line, rest] => {
if let Ok(line) = maybe_line.parse::<usize>() {
let path_str = if has_prefix {
format!("{}{}", &input[..search_start], rest)
} else {
rest.to_string()
};
return FileLocation {
path: PathBuf::from(path_str),
line: Some(line),
column: None,
};
}
}
_ => {}
}
FileLocation {
path: full_path,
line: None,
column: None,
}
}
fn 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");
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 file_locations: Vec<FileLocation> = args
.files
.iter()
.filter(|f| *f != "-")
.map(|f| parse_file_location(f))
.collect();
let mut working_dir = None;
let mut show_file_explorer = false;
if file_locations.len() == 1 {
if let Some(first_loc) = file_locations.first() {
if first_loc.path.is_dir() {
working_dir = Some(first_loc.path.clone());
show_file_explorer = true;
}
}
}
let effective_working_dir = 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)
};
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;
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);
let dir_context = DirectoryContext::from_system()?;
let current_working_dir = working_dir;
let key_translator = match KeyTranslator::load_default() {
Ok(translator) => translator,
Err(e) => {
tracing::warn!("Failed to load key calibration: {}", e);
KeyTranslator::new()
}
};
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,
})
}
#[cfg_attr(not(target_os = "linux"), allow(unused_variables))]
fn run_editor_iteration(
editor: &mut Editor,
session_enabled: bool,
terminal: &mut Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>,
key_translator: &KeyTranslator,
#[cfg(target_os = "linux")] gpm_client: &Option<GpmClient>,
) -> AnyhowResult<IterationOutcome> {
#[cfg(target_os = "linux")]
let loop_result = run_event_loop(
editor,
terminal,
session_enabled,
key_translator,
gpm_client,
);
#[cfg(not(target_os = "linux"))]
let loop_result = run_event_loop(editor, terminal, session_enabled, key_translator);
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_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);
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. Add your grammar file to grammars/");
println!(" 3. Edit package.json to configure LSP and formatting");
println!(" 4. 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: &PathBuf) -> 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: &PathBuf) -> 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: &PathBuf, 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 create_plugin_package(
dir: &PathBuf,
name: &str,
description: &str,
author: &str,
) -> AnyhowResult<()> {
let package_json = format!(
r#"{{
"$schema": "https://raw.githubusercontent.com/sinelaw/fresh/main/crates/fresh-editor/plugins/schemas/package.schema.json",
"name": "{}",
"version": "0.1.0",
"description": "{}",
"type": "plugin",
"author": "{}",
"license": "MIT",
"fresh": {{
"main": "plugin.ts"
}}
}}
"#,
name,
if description.is_empty() {
format!("A Fresh plugin")
} else {
description.to_string()
},
author
);
std::fs::write(dir.join("package.json"), package_json)?;
write_validate_script(dir)?;
let plugin_ts = r#"// Fresh Plugin
// Documentation: https://github.com/user/fresh/blob/main/docs/plugins.md
// Register a command that users can invoke
editor.registerCommand("hello", "Say Hello", async () => {
editor.showStatusMessage("Hello from your plugin!");
});
// React to editor events
editor.on("buffer_opened", (event) => {
const info = editor.getBufferInfo();
if (info) {
console.log(`Opened: ${info.path}`);
}
});
// 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: &PathBuf,
name: &str,
description: &str,
author: &str,
) -> AnyhowResult<()> {
let package_json = format!(
r#"{{
"$schema": "https://raw.githubusercontent.com/sinelaw/fresh/main/crates/fresh-editor/plugins/schemas/package.schema.json",
"name": "{}",
"version": "0.1.0",
"description": "{}",
"type": "theme",
"author": "{}",
"license": "MIT",
"fresh": {{
"theme": "theme.json"
}}
}}
"#,
name,
if description.is_empty() {
format!("A Fresh theme")
} else {
description.to_string()
},
author
);
std::fs::write(dir.join("package.json"), package_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: &PathBuf,
name: &str,
description: &str,
author: &str,
) -> AnyhowResult<()> {
std::fs::create_dir_all(dir.join("grammars"))?;
let package_json = format!(
r#"{{
"$schema": "https://raw.githubusercontent.com/sinelaw/fresh/main/crates/fresh-editor/plugins/schemas/package.schema.json",
"name": "{}",
"version": "0.1.0",
"description": "{}",
"type": "language",
"author": "{}",
"license": "MIT",
"fresh": {{
"grammar": {{
"file": "grammars/syntax.tmLanguage.json",
"extensions": ["ext"]
}},
"language": {{
"commentPrefix": "//",
"tabSize": 4,
"autoIndent": true
}},
"lsp": {{
"command": "language-server",
"args": ["--stdio"],
"autoStart": true
}}
}}
}}
"#,
name,
if description.is_empty() {
format!("Language support for Fresh")
} else {
description.to_string()
},
author
);
std::fs::write(dir.join("package.json"), package_json)?;
write_validate_script(dir)?;
let grammar = r#"{
"name": "My Language",
"scopeName": "source.mylang",
"fileTypes": ["ext"],
"patterns": [
{
"name": "comment.line",
"match": "//.*$"
},
{
"name": "string.quoted.double",
"begin": "\"",
"end": "\"",
"patterns": [
{
"name": "constant.character.escape",
"match": "\\\\."
}
]
},
{
"name": "constant.numeric",
"match": "\\b[0-9]+\\b"
},
{
"name": "keyword.control",
"match": "\\b(if|else|while|for|return)\\b"
}
]
}
"#;
std::fs::write(dir.join("grammars/syntax.tmLanguage.json"), grammar)?;
let readme = format!(
r#"# {}
{}
## Features
- Syntax highlighting via TextMate 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.tmLanguage.json`
### 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.tmLanguage.json` for syntax highlighting
2. Update `package.json` with correct file extensions and LSP command
3. Test by installing locally
## Resources
- [TextMate Grammar Guide](https://macromates.com/manual/en/language_grammars)
- [VS Code Language Extension Guide](https://code.visualstudio.com/api/language-extensions/syntax-highlight-guide)
## License
MIT
"#,
name,
if description.is_empty() {
"Language support for Fresh."
} else {
description
},
name
);
std::fs::write(dir.join("README.md"), readme)?;
Ok(())
}
fn main() -> AnyhowResult<()> {
let args = Args::parse();
if args.show_paths {
fresh::services::log_dirs::print_all_paths();
return Ok(());
}
if args.dump_config {
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 {
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!(
"Failed to load config from {}: {}",
config_path.display(),
e
);
}
}
} else {
config::Config::load_with_layers(&dir_context, &working_dir)
};
match serde_json::to_string_pretty(&config) {
Ok(json) => {
println!("{}", json);
return Ok(());
}
Err(e) => {
eprintln!("Error: Failed to serialize config: {}", e);
anyhow::bail!("Failed to serialize config: {}", e);
}
}
}
#[cfg(feature = "plugins")]
if let Some(plugin_path) = &args.check_plugin {
return check_plugin_bundle(plugin_path);
}
if let Some(ref pkg_type) = args.init {
return init_package_command(pkg_type.clone());
}
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,
} = 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_session_on_restart = false;
let (result, last_update_result) = loop {
let first_run = is_first_run;
let session_enabled = !args.no_session && file_locations.is_empty();
let color_capability = fresh::view::color_support::ColorCapability::detect();
let filesystem = std::sync::Arc::new(StdFileSystem);
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,
filesystem,
)
.context("Failed to create editor instance")?;
#[cfg(target_os = "linux")]
if gpm_client.is_some() {
editor.set_gpm_active(true);
}
if first_run {
handle_first_run_setup(
&mut editor,
&args,
&file_locations,
show_file_explorer,
&mut stdin_stream,
&mut tracing_handles,
session_enabled,
)
.context("Failed first run setup")?;
} else {
if restore_session_on_restart {
match editor.try_restore_session() {
Ok(true) => {
tracing::info!("Session restored successfully");
}
Ok(false) => {
tracing::debug!("No previous session found");
}
Err(e) => {
tracing::warn!("Failed to restore session: {}", e);
}
}
}
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);
}
let iteration = run_editor_iteration(
&mut editor,
session_enabled,
&mut terminal,
&key_translator,
#[cfg(target_os = "linux")]
&gpm_client,
)
.context("Editor iteration failed")?;
let update_result = iteration.update_result;
let restart_dir = iteration.restart_dir;
let loop_result = iteration.loop_result;
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_session_on_restart = true; terminal
.clear()
.context("Failed to clear terminal for restart")?;
continue;
}
break (loop_result, update_result);
};
terminal_modes.undo();
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")
}
#[cfg(target_os = "linux")]
fn run_event_loop(
editor: &mut Editor,
terminal: &mut Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>,
session_enabled: bool,
key_translator: &KeyTranslator,
gpm_client: &Option<GpmClient>,
) -> AnyhowResult<()> {
run_event_loop_common(
editor,
terminal,
session_enabled,
key_translator,
|timeout| poll_with_gpm(gpm_client.as_ref(), timeout),
)
}
#[cfg(not(target_os = "linux"))]
fn run_event_loop(
editor: &mut Editor,
terminal: &mut Terminal<ratatui::backend::CrosstermBackend<io::Stdout>>,
session_enabled: bool,
key_translator: &KeyTranslator,
) -> AnyhowResult<()> {
run_event_loop_common(
editor,
terminal,
session_enabled,
key_translator,
|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>>,
session_enabled: bool,
_key_translator: &KeyTranslator,
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 {
if editor.process_async_messages() {
needs_render = true;
}
if editor.check_mouse_hover_timer() {
needs_render = true;
}
if editor.check_semantic_highlight_timer() {
needs_render = true;
}
if editor.check_completion_trigger_timer() {
needs_render = true;
}
if editor.check_warning_log() {
needs_render = true;
}
if editor.poll_stdin_streaming() {
needs_render = true;
}
if let Err(e) = editor.auto_save_dirty_buffers() {
tracing::debug!("Auto-save error: {}", e);
}
if editor.take_full_redraw_request() {
terminal.clear()?;
needs_render = true;
}
if editor.should_quit() {
if session_enabled {
if let Err(e) = editor.save_session() {
tracing::warn!("Failed to save session: {}", e);
} else {
tracing::debug!("Session saved successfully");
}
}
break;
}
if needs_render && last_render.elapsed() >= FRAME_DURATION {
terminal.draw(|frame| editor.render(frame))?;
last_render = Instant::now();
needs_render = false;
}
let event = if let Some(e) = pending_event.take() {
Some(e)
} else {
let timeout = if needs_render {
FRAME_DURATION.saturating_sub(last_render.elapsed())
} else {
Duration::from_millis(50)
};
poll_event(timeout)?
};
let Some(event) = event else { continue };
let (event, next) = coalesce_mouse_moves(event)?;
pending_event = next;
match event {
CrosstermEvent::Key(key_event) => {
if key_event.kind == KeyEventKind::Press {
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;
}
_ => {}
}
}
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);
}
}
#[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));
}
}
}