260 lines
14 KiB
Rust
260 lines
14 KiB
Rust
use std::{env, io, path::PathBuf};
|
|
|
|
use vamp::{session::EditorSession, tui};
|
|
|
|
mod review_cli;
|
|
|
|
// Only the first argument can be this process-to-process terminal handoff marker.
|
|
const INHERITED_SCREEN: &str = "--vamp-inherit-alternate-screen";
|
|
|
|
const HELP: &str = "vamp — modal editor with isolated Ruby plugins
|
|
|
|
Usage: vamp [--config PATH | --no-config] [FILE]
|
|
vamp [--config PATH | --no-config] --review list [--repo PATH] [--json]
|
|
vamp [--config PATH | --no-config] --review show ID [--repo PATH] [--json] [--all]
|
|
|
|
Review CLI: read-only local storage; show defaults to unresolved comments; --all includes resolved.
|
|
Use vamp -- --review to edit a file named --review.
|
|
|
|
Default keys (configurable with config.bind):
|
|
All modes: Ctrl+p command palette; type to filter, Ctrl+j/k or arrows select, Enter accept, Esc cancel
|
|
Normal/Visual: Space is leader; Space Space opens palette; Esc cancels prefix
|
|
Normal leader: Space w saves; Space b n/p/l/d next/previous/pick/close buffer
|
|
Files: Space o floating tree INSERT; Space e docked tree NORMAL (right by default)
|
|
tree NORMAL: hjkl navigate; INSERT: type to filter, Ctrl+hjkl navigate; Enter toggles/opens
|
|
i filters; Esc retains filter and returns NORMAL, then editor; Ctrl+u clears in either mode
|
|
Ctrl+t view, Ctrl+w close, Ctrl+r refresh
|
|
Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
|
|
h/j/k/l move, 0/$ line ends, x/Delete cut hovered graphemes, J join lines
|
|
w/b/e word next/previous/end, W/B/E whitespace-delimited WORDs
|
|
Normal/Visual: f<char>/F<char> seek next/previous on current line; 2fx seeks second x
|
|
gf follows Markdown file links/reference links and heading fragments (no LSP)
|
|
Normal/Visual: gt first line, gb last line, at first nonblank; Esc cancels g
|
|
Normal/Visual: <line>g jumps to that one-based source line at first nonblank, clamped at EOF
|
|
Ctrl+o older jump location; Ctrl+i newer (Tab too outside comparisons; counts supported)
|
|
_ splits above/below, | left/right; Option/Alt+h/j/k/l focuses neighboring panes
|
|
Ctrl+Option/Alt+h/l previous/next tab; :tab.new opens current buffer in another tab
|
|
Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count
|
|
Normal/Visual: Ctrl+u/d scroll up/down without moving carets; configurable distance (default half viewport), counts supported
|
|
u undo, U redo, : command, / search, * search word, n/N next/previous match
|
|
yy copies whole lines; dd deletes them; cc changes them and enters Insert; counts: 3yy, 2dd, 2cc
|
|
dw/db cut forward/backward words on the current line; cw/cb cut the same spans and enter Insert
|
|
Word counts before/after the operator multiply: d2w, 2cw, 2c3b; newlines are preserved
|
|
Linewise p/P paste below/above the current row; characterwise p/P paste after/before the grapheme
|
|
Normal/Visual: . repeats last native edit at current targets (one undo group)
|
|
v characterwise select, V linewise select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below
|
|
y/d/x/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it
|
|
Esc keeps only the primary caret
|
|
Visual: motions extend selections, y copy, d/x/Delete cut, c change, p/P replace selections
|
|
: opens command input with the selection preserved; Esc returns to that selection
|
|
r<char> replaces every selected grapheme, preserving newlines; one undo action
|
|
I/A insert at each selected line's first nonblank/end
|
|
Ctrl+l split rows, Ctrl+n add occurrence, Ctrl+s skip, Ctrl+a all
|
|
v/V switches selection shape; same selector or Esc cancels retaining carets
|
|
Insert: Ctrl+n requests LSP completion at primary caret, then cycles next; Ctrl+j/k next/previous
|
|
Candidates replace text live; Original text restores it; other keys close and act normally
|
|
Snippets expand fully: type replaces argument, Tab/Shift+Tab next/previous, final $0 ends
|
|
Outside snippets Enter inserts newline, Tab inserts a tab or configured spaces; Esc returns to Normal
|
|
Insert/Command/Search: Ctrl+u deletes backward to the logical line start
|
|
Command/Search: Up/Ctrl+k older history; Down/Ctrl+j newer history or unfinished input; Enter submits
|
|
Search: Enter accepts current/next literal match, Esc cancels (case-sensitive, wraps)
|
|
Commands: :save [PATH], :force_save [PATH], :quit, :force_quit,
|
|
:save_and_quit [PATH], :force_save_and_quit [PATH], :plugins, :PLUGIN [ARGS]
|
|
Aliases: :w=save, :w!=force_save, :q=quit, :q!=force_quit,
|
|
:wq=save_and_quit, :wq!=force_save_and_quit
|
|
Save actions: core.save, core.force_save, core.save_and_quit, core.force_save_and_quit
|
|
Normal saves recheck LF-normalized disk text; external changes/deletion require force
|
|
Force never bypasses snapshot read-only, symlink, or another-buffer path protection
|
|
Clean live buffers reload on 250ms metadata checks, including inactive tabs/panes
|
|
Reload preserves/clamps views and clears stale undo/redo; snapshots never reload
|
|
Dirty buffers keep edits; missing/unreadable/invalid changed files keep text and warn
|
|
Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffer_delete [N], :buffer_force_delete [N]
|
|
:buffers / Space b l searches IDs/paths; Enter switches, Esc/Ctrl+p cancels, Ctrl+u clears
|
|
Aliases: bnext, bprev, bdelete, bdelete!; arguments and safety rules are unchanged
|
|
:quit refuses any dirty buffer; both save-and-quit commands require all buffers clean
|
|
Restart: :restart closes clean buffers; unsaved buffers remain open and block restart with a warning
|
|
:force_restart / :restart! discards unsaved text without writing
|
|
Actions: core.restart, core.force_restart (no default binding)
|
|
Relaunches the executable with original CLI args/config/startup file; tabs/views reset
|
|
Tabs: :tab.new, :tab.previous, :tab.next, :tab.close, :tab.force_close
|
|
Tabs retain pane layouts and independent views; close protects exclusively displayed unsaved edits
|
|
Markdown links: :follow_link (primary caret; local files only)
|
|
Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers
|
|
Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus
|
|
All text panes use native motions, selections and copy; read-only panes reject edits/saves
|
|
Switching Before/After preserves the aligned cursor row and display column
|
|
Selector queries own printable keys, including :; text navigation skips omitted lines
|
|
Normal [/] explicitly expands context; Insert brackets remain literal text
|
|
Normal Space v cycles split/unified; Space s flips left/right vs above/below split
|
|
Defaults: config.diff_view_mode = \"split\"; config.diff_split_orientation = \"vertical\"
|
|
Working After follows descendant checkouts; config.diff_follow_working_copy = false disables it
|
|
Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly
|
|
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
|
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
|
|
Local reviews: :diff.comment anchors exact Visual text, Visual Line rows, or the current line
|
|
Selected comments highlight their precise text
|
|
:diff.review.edit/delete [ID], :diff.review.next/previous, :diff.review.overview
|
|
:diff.review.summary; :diff.review.verdict looks_good|changes_requested|none
|
|
:w saves comment/summary text, including hidden drafts; verdicts/deletions commit immediately
|
|
:diff.review.save PATH exports JSON; :diff.review.open [ID|PATH] selects/imports; :diff.review.new
|
|
Normal Esc or :pane.close discards unsaved text and dismisses the floating editor
|
|
Snapshot anchors stay immutable; stale comments lose gutter markers; no publishing
|
|
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/All tabs, type to search
|
|
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
|
|
Resolve jj conflicts: :resolve opens the current working-copy conflicts in a dedicated workspace
|
|
Editable marker-free Draft vs readonly Alternative; :resolve.history shows inputs/base
|
|
config.resolve_history_layout = \"columns\" (left/base/right) or \"base_below\" (left/right above base)
|
|
:resolve.next/previous navigates differing hunks; :resolve.use_source [N] takes a hunk
|
|
:resolve.source N selects Alternative; :resolve.base N selects History base
|
|
:resolve.draft returns to Draft; :resolve.view.toggle switches Resolution/History
|
|
:w saves and checks jj; :resolve.files toggles the explorer; :resolve.close closes cleanly
|
|
|
|
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
|
|
Set VAMP_RUBY to choose the Ruby executable.
|
|
|
|
Native LSP is disabled by default: config.lsp[\"rust\"] = true enables a preset
|
|
Presets: rust -> rust-analyzer, python -> ty server + ruff server, ruby -> solargraph stdio
|
|
Custom languages: config.lsp[\"my-language\"] = { command: [...], extensions: [...] }
|
|
Server stacks: config.lsp[\"python\"] = { servers: [{ command: [...] }, { command: [...] }] }
|
|
LSP commands: :lsp.definition, :lsp.diagnostics, :lsp.hover
|
|
Semantic highlighting: automatic when the first enabled server supports semantic tokens
|
|
Hover binding: config.bind \"K\", config.core.lsp.hover (no default key)";
|
|
|
|
struct Options {
|
|
file: Option<PathBuf>,
|
|
config: Option<PathBuf>,
|
|
review: Option<review_cli::Request>,
|
|
}
|
|
|
|
fn options(inherited_screen: bool) -> Result<Option<Options>, String> {
|
|
parse_options(env::args_os().skip(1 + usize::from(inherited_screen)))
|
|
}
|
|
|
|
fn parse_options(
|
|
args: impl IntoIterator<Item = std::ffi::OsString>,
|
|
) -> Result<Option<Options>, String> {
|
|
let mut args = args.into_iter();
|
|
let mut file = None;
|
|
let mut config = None;
|
|
let mut config_selected = false;
|
|
let mut positional = false;
|
|
let mut review = None;
|
|
while let Some(arg) = args.next() {
|
|
if !positional && (arg == "--help" || arg == "-h") {
|
|
println!("{HELP}");
|
|
return Ok(None);
|
|
} else if !positional && arg == "--" {
|
|
if review.is_some() {
|
|
return Err("unexpected review argument: --".into());
|
|
}
|
|
positional = true;
|
|
} else if !positional && arg == "--review" && file.is_none() && review.is_none() {
|
|
review = Some(review_cli::Request::parse(&mut args)?);
|
|
} else if !positional && arg == "--config" {
|
|
if config_selected {
|
|
return Err("use only one of --config and --no-config".into());
|
|
}
|
|
config = Some(PathBuf::from(
|
|
args.next().ok_or("--config requires a path")?,
|
|
));
|
|
config_selected = true;
|
|
} else if !positional && arg == "--no-config" {
|
|
if config_selected {
|
|
return Err("use only one of --config and --no-config".into());
|
|
}
|
|
config_selected = true;
|
|
} else if !positional && review.is_some() {
|
|
review.as_mut().unwrap().option(&arg, &mut args)?;
|
|
} else if !positional && arg.to_string_lossy().starts_with('-') {
|
|
return Err(format!("unknown option: {}", arg.to_string_lossy()));
|
|
} else if file.replace(PathBuf::from(arg)).is_some() {
|
|
return Err("only one file can be opened".into());
|
|
}
|
|
}
|
|
if !config_selected {
|
|
let home = env::var_os("XDG_CONFIG_HOME")
|
|
.map(PathBuf::from)
|
|
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")));
|
|
config = home
|
|
.map(|home| home.join("vamp/config.rb"))
|
|
.filter(|path| path.exists());
|
|
}
|
|
Ok(Some(Options {
|
|
file,
|
|
config,
|
|
review,
|
|
}))
|
|
}
|
|
|
|
fn run() -> Result<(), String> {
|
|
let inherited_screen = env::args_os()
|
|
.nth(1)
|
|
.is_some_and(|arg| arg == INHERITED_SCREEN);
|
|
let options = options(inherited_screen)?;
|
|
if let Some(options) = &options {
|
|
if let Some(request) = &options.review {
|
|
return review_cli::run(request, options.config.as_deref());
|
|
}
|
|
}
|
|
if options.is_none() {
|
|
return Ok(());
|
|
}
|
|
tui::TerminalSession::run(inherited_screen, || run_editor(options, inherited_screen))
|
|
.map_err(|error| error.to_string())
|
|
}
|
|
|
|
fn run_editor(options: Option<Options>, inherited_screen: bool) -> io::Result<()> {
|
|
let Some(options) = options else {
|
|
return Ok(());
|
|
};
|
|
let mut session = EditorSession::open(
|
|
options.file,
|
|
tui::CAPABILITIES.iter().map(|s| (*s).to_owned()).collect(),
|
|
options.config.as_deref(),
|
|
)
|
|
.map_err(|error| io::Error::other(format!("cannot open file: {error}")))?;
|
|
tui::run(&mut session, inherited_screen)?;
|
|
let restart = session.editor().should_restart();
|
|
drop(session);
|
|
if restart {
|
|
tui::TerminalSession::prepare_restart()?;
|
|
restart_editor(inherited_screen)
|
|
.map_err(|error| io::Error::other(format!("Restart failed: {error}")))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn restart_editor(inherited_screen: bool) -> io::Result<()> {
|
|
let executable = env::current_exe()?;
|
|
let mut command = std::process::Command::new(executable);
|
|
command.arg(INHERITED_SCREEN);
|
|
command.args(env::args_os().skip(1 + usize::from(inherited_screen)));
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::process::CommandExt;
|
|
Err(command.exec())
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
let status = command.status()?;
|
|
if status.success() {
|
|
Ok(())
|
|
} else {
|
|
Err(io::Error::other(format!(
|
|
"Restarted editor exited with {status}"
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn main() {
|
|
if let Err(error) = run() {
|
|
eprintln!("vamp: {error}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "../tests/unit/cli_args.rs"]
|
|
mod tests;
|