feat(find-file): open a file by path with C-x C-f
Dired arc Stage 0 (docs/dired-framing.md section 10, Q#DR11). Until now pmacs had no discoverable way to open a file by path: no find-file command and no C-x C-f binding, so a file entered a session only from the CLI, an LSP jump, a project-search visit, or C-x C-r, whose prompt does pass free text through but completes only over the recent list. The command prompts with completion rooted at the active buffer's directory, or the process cwd when the buffer has no backing path, and opens the result through pmacs.window.display_file. A path that does not exist yet creates a buffer bound to it with the "[new file]" status, which is Emacs parity and comes from resolve_target_buffer rather than anything added here. Nothing is written to disk until the user saves. Two substrate facts shape the design and are documented at the command rather than left to be rediscovered. Completion is flat: the files source lists one directory and yields bare basenames, and a custom function source could not do better, because sources are called with no arguments and run synchronously outside any coroutine, so a callback can neither see the input to re-root on nor await a directory listing. Hierarchical completion is a named Rust change in the framing. A selected candidate shadows typed text: recompute_candidates selects index 0 whenever the candidate list is non-empty, and resolve_accepted_value returns the candidate over the typed contents. So typed text reaches the accept handler exactly when the input filters every candidate away, which for basename candidates under a subsequence filter means when it contains a separator. That makes the deeper-path case work verbatim and leaves one hole: a new bare name that is a subsequence of an existing entry opens the existing file. The acceptance pins that as a decision rather than an accident; closing it needs a Rust change to accept semantics that Stage 0 deliberately does not make. A leading tilde is expanded before the path reaches the core, because get_or_load_buffer normalizes the path it stores but loads from the raw one -- so an unexpanded tilde path deduplicates against an already-open buffer yet fails to load a file that is not open yet. The prompt field starts empty and names its root in the prompt string instead: any prefill would contain a separator and silently disable completion. Acceptance is dispatch-driven throughout -- a real C-x C-f, real typing, a real RET -- so a dead binding cannot pass vacuously and the Lua lifecycle accept(), which bypasses the path interactive input takes, is not used.
This commit is contained in:
parent
0827dd1416
commit
2a0884b377
|
|
@ -611,6 +611,116 @@ cmd { name = "editor.switch-buffer",
|
|||
}
|
||||
end }
|
||||
|
||||
-- find-file (dired arc Stage 0; docs/dired-framing.md Q#DR11) ----------------
|
||||
--
|
||||
-- Until now pmacs had no discoverable way to open a file by path: a file
|
||||
-- entered a session only from the CLI, an LSP jump, a project-search
|
||||
-- visit, or `C-x C-r` (whose prompt does pass free text through, but
|
||||
-- completes only over the recent list). This is that surface.
|
||||
--
|
||||
-- Two substrate facts shape it, and both are load-bearing:
|
||||
--
|
||||
-- 1. COMPLETION IS FLAT. `source = "files"` lists ONE directory and
|
||||
-- yields bare basenames (`minibuffer.rs` `list_directory`), capped at
|
||||
-- the shared candidate limit. A custom function source could not do
|
||||
-- better: sources are called with NO arguments, so a callback cannot
|
||||
-- see the input to re-root on, and it runs synchronously outside any
|
||||
-- coroutine, where `Handle:await()` raises --- so it cannot list a
|
||||
-- directory either. Hierarchical completion is a named Rust change in
|
||||
-- the framing, not something this command can fake.
|
||||
--
|
||||
-- 2. A SELECTED CANDIDATE SHADOWS TYPED TEXT. `recompute_candidates`
|
||||
-- sets `selected = Some(0)` whenever the candidate list is non-empty,
|
||||
-- and `resolve_accepted_value` returns the CANDIDATE whenever
|
||||
-- anything is selected. So `on_accept` receives typed text only when
|
||||
-- the input filters every candidate away --- which, since candidates
|
||||
-- are basenames and the filter is a subsequence match, is exactly
|
||||
-- when the input contains a `/`. That makes the deeper-path case work
|
||||
-- (`sub/inner.txt` matches no basename, so it arrives verbatim) and
|
||||
-- leaves one documented hole: typing a NEW bare name that happens to
|
||||
-- be a subsequence of an existing entry opens the existing file
|
||||
-- instead of creating the new one. `acc4` pins that as a known
|
||||
-- behavior rather than letting it be an accident.
|
||||
--
|
||||
-- The root is the active buffer's directory, or the process cwd when the
|
||||
-- buffer has no backing path (`source_root` defaults to "." Rust-side,
|
||||
-- so the nil case needs no special handling here). It appears in the
|
||||
-- prompt because the field itself must stay empty: any prefill would
|
||||
-- contain a `/` and filter every candidate away, killing completion.
|
||||
|
||||
-- Directory part of a path. "/a/b" -> "/a"; "/a" -> "/"; "a" -> nil.
|
||||
local function find_file_dirname(path)
|
||||
local dir = path:match("^(.*)/[^/]*$")
|
||||
if dir == nil then return nil end
|
||||
if dir == "" then return "/" end
|
||||
return dir
|
||||
end
|
||||
|
||||
-- Expand a leading `~` component using $HOME: `~` -> $HOME, `~/x` ->
|
||||
-- $HOME/x. `~user` is left alone (no passwd lookup), matching the core's
|
||||
-- own `expand_tilde`.
|
||||
--
|
||||
-- This has to happen HERE, before the path reaches the core, because
|
||||
-- `get_or_load_buffer` normalizes the path it STORES but loads from the
|
||||
-- raw one --- so a `~/...` path deduplicates against an already-open
|
||||
-- buffer yet fails to load when the file is not open yet. Expanding up
|
||||
-- front makes both halves agree.
|
||||
local function find_file_expand_tilde(path)
|
||||
local home = os.getenv("HOME")
|
||||
if home == nil or home == "" then return path end
|
||||
if home:sub(-1) == "/" then home = home:sub(1, -2) end
|
||||
if path == "~" then return home end
|
||||
local rest = path:match("^~/(.*)$")
|
||||
if rest == nil then return path end
|
||||
return home .. "/" .. rest
|
||||
end
|
||||
|
||||
-- Turn an accepted value into a path. The value is either a bare
|
||||
-- basename (a selected candidate) or whatever the user typed, so a
|
||||
-- non-absolute value joins onto the prompt's root --- which resolves
|
||||
-- both cases to the same file when they name the same one.
|
||||
local function find_file_resolve(root, value)
|
||||
local path = find_file_expand_tilde(value)
|
||||
if path:sub(1, 1) == "/" then return path end
|
||||
local base = root or "."
|
||||
if base:sub(-1) == "/" then return base .. path end
|
||||
return base .. "/" .. path
|
||||
end
|
||||
|
||||
-- The active buffer's directory, or nil when it has no backing path.
|
||||
local function find_file_root()
|
||||
local buf = pmacs.window.buffer()
|
||||
if buf == nil then return nil end
|
||||
local ok, path = pcall(function() return buf:path() end)
|
||||
if not (ok and path) then return nil end
|
||||
return find_file_dirname(path)
|
||||
end
|
||||
|
||||
cmd { name = "find-file",
|
||||
description = "Open a file by path, completing within one directory.",
|
||||
fn = function()
|
||||
local root = find_file_root()
|
||||
pmacs.minibuffer.read {
|
||||
prompt = "Find file (" .. (root or ".") .. "): ",
|
||||
source = "files",
|
||||
source_root = root,
|
||||
history = "find-file",
|
||||
on_accept = function(value)
|
||||
if value == nil or value == "" then return end
|
||||
local path = find_file_resolve(root, value)
|
||||
-- A path that does not exist yet CREATES a buffer bound to
|
||||
-- it: `display_file` routes through `resolve_target_buffer`,
|
||||
-- which on NotFound creates, binds, and sets "[new file]".
|
||||
-- That is Emacs parity and deliberate, so only a real
|
||||
-- failure (a directory, a permission error) reaches here.
|
||||
local ok, err = pcall(pmacs.window.display_file, path, { select = true })
|
||||
if not ok then
|
||||
pmacs.editor.set_status("find-file: " .. tostring(err))
|
||||
end
|
||||
end,
|
||||
}
|
||||
end }
|
||||
|
||||
-- Command palette (M-x) ------------------------------------------------------
|
||||
--
|
||||
-- Opens the minibuffer with a "commands" completion source, then
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ bind("C-x o", "window.focus-next")
|
|||
bind("C-x O", "window.focus-prev")
|
||||
bind("C-x 0", "window.close")
|
||||
bind("C-x 1", "window.close-others")
|
||||
bind("C-x C-f", "find-file")
|
||||
bind("C-x b", "editor.switch-buffer")
|
||||
bind("C-x C-b", "editor.list-buffers")
|
||||
bind("C-x <right>", "editor.next-buffer")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,291 @@
|
|||
// tests/find_file_acceptance.rs --- dired arc Stage 0 (`C-x C-f`) acceptance.
|
||||
|
||||
//! Acceptance for `find-file`, the dired arc's Stage 0
|
||||
//! (`docs/dired-framing.md` §14, items 0a-0d, Q#DR11).
|
||||
//!
|
||||
//! Dispatch-driven throughout: the prompt is opened with a real
|
||||
//! `C-x C-f`, filled by typing real keys, and completed with a real
|
||||
//! RET. `pmacs.command.invoke` would bypass the binding (a dead
|
||||
//! keymap entry would pass vacuously) and the Lua lifecycle
|
||||
//! `minibuffer.accept()` bypasses the dispatch path interactive input
|
||||
//! actually takes --- the editops suite's discipline, for the same
|
||||
//! reasons.
|
||||
//!
|
||||
//! Fixtures use `.txt` files so no `buffer.after-load` hook spawns a
|
||||
//! language server.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
|
||||
use pmacs::editor::EditorState;
|
||||
use pmacs::protocol::FrontendId;
|
||||
|
||||
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
|
||||
KeyEvent {
|
||||
code,
|
||||
modifiers: mods,
|
||||
kind: KeyEventKind::Press,
|
||||
state: KeyEventState::NONE,
|
||||
}
|
||||
}
|
||||
|
||||
fn ctrl(s: &mut EditorState, c: char) {
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char(c), KeyModifiers::CONTROL),
|
||||
);
|
||||
}
|
||||
|
||||
fn press(s: &mut EditorState, code: KeyCode) {
|
||||
s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
fn type_str(s: &mut EditorState, text: &str) {
|
||||
for ch in text.chars() {
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char(ch), KeyModifiers::NONE),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn exec(s: &EditorState, src: &str) {
|
||||
s.lua_host.lua().load(src.to_string()).exec().unwrap();
|
||||
}
|
||||
|
||||
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
|
||||
s.lua_host.lua().load(src.to_string()).eval().unwrap()
|
||||
}
|
||||
|
||||
/// Open the find-file prompt through the real `C-x C-f` binding.
|
||||
fn open_prompt(s: &mut EditorState) {
|
||||
ctrl(s, 'x');
|
||||
ctrl(s, 'f');
|
||||
assert!(
|
||||
eval::<bool>(s, "return pmacs.minibuffer.is_active()"),
|
||||
"C-x C-f must open a minibuffer prompt"
|
||||
);
|
||||
}
|
||||
|
||||
/// The active buffer's backing path, or `None`.
|
||||
fn active_path(s: &EditorState) -> Option<String> {
|
||||
eval::<Option<String>>(
|
||||
s,
|
||||
"local b = pmacs.window.buffer()\n\
|
||||
if b == nil then return nil end\n\
|
||||
local ok, p = pcall(function() return b:path() end)\n\
|
||||
if ok then return p end\n\
|
||||
return nil",
|
||||
)
|
||||
}
|
||||
|
||||
fn candidates(s: &EditorState) -> Vec<String> {
|
||||
eval::<Vec<String>>(s, "return pmacs.minibuffer.candidates()")
|
||||
}
|
||||
|
||||
fn status(s: &EditorState) -> String {
|
||||
s.core.borrow().status.clone()
|
||||
}
|
||||
|
||||
/// An editor whose active buffer is a real file inside `dir`, so
|
||||
/// find-file's root resolves to that directory.
|
||||
fn editor_in(dir: &std::path::Path) -> EditorState {
|
||||
let anchor = dir.join("anchor.txt");
|
||||
std::fs::write(&anchor, b"anchor\n").expect("write anchor");
|
||||
let state = EditorState::new();
|
||||
state.lua_host.reopen_init_phase_for_testing();
|
||||
let anchor_str = anchor.display().to_string();
|
||||
exec(&state, &format!("pmacs.buffer.find_or_open({anchor_str:?})"));
|
||||
state
|
||||
}
|
||||
|
||||
/// 0a --- completion is flat: it offers the root's own entries and
|
||||
/// never descends into a subdirectory.
|
||||
#[test]
|
||||
fn find_file_completion_lists_the_root_only_and_does_not_descend() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(td.path().join("alpha.txt"), b"a").expect("write");
|
||||
std::fs::create_dir(td.path().join("sub")).expect("mkdir");
|
||||
std::fs::write(td.path().join("sub").join("inner.txt"), b"i").expect("write");
|
||||
|
||||
let mut s = editor_in(td.path());
|
||||
open_prompt(&mut s);
|
||||
|
||||
let cands = candidates(&s);
|
||||
assert!(
|
||||
cands.iter().any(|c| c == "alpha.txt"),
|
||||
"root entry must be offered; got {cands:?}"
|
||||
);
|
||||
assert!(
|
||||
cands.iter().any(|c| c == "sub"),
|
||||
"the subdirectory itself must be offered; got {cands:?}"
|
||||
);
|
||||
assert!(
|
||||
!cands.iter().any(|c| c == "inner.txt"),
|
||||
"completion must NOT descend into subdirectories; got {cands:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 0b --- free text carries the deeper case. `sub/inner.txt` matches no
|
||||
/// bare-basename candidate, so it reaches `on_accept` verbatim and is
|
||||
/// joined onto the prompt's root.
|
||||
#[test]
|
||||
fn find_file_free_text_opens_a_path_below_the_root() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::create_dir(td.path().join("sub")).expect("mkdir");
|
||||
let inner = td.path().join("sub").join("inner.txt");
|
||||
std::fs::write(&inner, b"deep contents\n").expect("write");
|
||||
|
||||
let mut s = editor_in(td.path());
|
||||
open_prompt(&mut s);
|
||||
type_str(&mut s, "sub/inner.txt");
|
||||
|
||||
assert!(
|
||||
candidates(&s).is_empty(),
|
||||
"a needle containing '/' must filter every basename candidate away, \
|
||||
or the selection would shadow the typed text"
|
||||
);
|
||||
|
||||
press(&mut s, KeyCode::Enter);
|
||||
|
||||
let path = active_path(&s).expect("a file must be open");
|
||||
assert_eq!(
|
||||
std::fs::canonicalize(&path).expect("canonicalize opened"),
|
||||
std::fs::canonicalize(&inner).expect("canonicalize fixture"),
|
||||
"free text must open the deeper path"
|
||||
);
|
||||
let text: String = eval(&s, "return pmacs.window.buffer():slice(0, 13)");
|
||||
assert_eq!(text, "deep contents", "the file's real contents must load");
|
||||
}
|
||||
|
||||
/// 0c --- a path that does not exist creates a `[new file]` buffer
|
||||
/// bound to it, rather than erroring. The name contains a `/` so the
|
||||
/// candidate list is empty and the typed text is what arrives (see
|
||||
/// `find_file_selected_candidate_shadows_typed_text` for the other
|
||||
/// half of that rule).
|
||||
#[test]
|
||||
fn find_file_nonexistent_path_creates_a_new_file_buffer() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::create_dir(td.path().join("sub")).expect("mkdir");
|
||||
let fresh = td.path().join("sub").join("brand-new.txt");
|
||||
assert!(!fresh.exists(), "fixture must not exist yet");
|
||||
|
||||
let mut s = editor_in(td.path());
|
||||
open_prompt(&mut s);
|
||||
type_str(&mut s, "sub/brand-new.txt");
|
||||
press(&mut s, KeyCode::Enter);
|
||||
|
||||
let path = active_path(&s).expect("a buffer must be bound to the new path");
|
||||
assert!(
|
||||
path.ends_with("sub/brand-new.txt"),
|
||||
"the buffer must be bound to the typed path; got {path}"
|
||||
);
|
||||
let len: usize = eval(&s, "return pmacs.window.buffer():len()");
|
||||
assert_eq!(len, 0, "a new-file buffer starts empty");
|
||||
assert!(
|
||||
!fresh.exists(),
|
||||
"find-file must not create the file on disk --- only the buffer"
|
||||
);
|
||||
let line = status(&s);
|
||||
assert!(
|
||||
line.contains("[new file]"),
|
||||
"the new-file status must surface; got {line:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 0d --- with no backing path, the prompt roots at the process cwd
|
||||
/// (`source_root` is omitted, and the Rust side defaults to ".").
|
||||
/// The test crate's cwd is the crate root, so `Cargo.toml` is a
|
||||
/// stable, real candidate there.
|
||||
#[test]
|
||||
fn find_file_without_a_backing_path_roots_at_the_process_cwd() {
|
||||
let mut s = EditorState::new();
|
||||
s.lua_host.reopen_init_phase_for_testing();
|
||||
assert!(
|
||||
active_path(&s).is_none(),
|
||||
"the scratch buffer must have no backing path"
|
||||
);
|
||||
|
||||
open_prompt(&mut s);
|
||||
|
||||
let cands = candidates(&s);
|
||||
assert!(
|
||||
cands.iter().any(|c| c == "Cargo.toml"),
|
||||
"a pathless buffer must root the prompt at the process cwd; got {cands:?}"
|
||||
);
|
||||
// The field must start EMPTY. Any prefill (e.g. Emacs's
|
||||
// directory-in-the-field) would contain a `/`, which filters every
|
||||
// basename candidate away and silently disables completion --- the
|
||||
// reason the root is named in the prompt string instead.
|
||||
let typed: String = eval(&s, "return pmacs.minibuffer.contents()");
|
||||
assert_eq!(
|
||||
typed, "",
|
||||
"the prompt field must start empty or completion is dead on arrival"
|
||||
);
|
||||
}
|
||||
|
||||
/// The documented hole in Q#DR11, pinned so it is a decision rather
|
||||
/// than an accident: `recompute_candidates` selects index 0 whenever
|
||||
/// the list is non-empty and `resolve_accepted_value` returns the
|
||||
/// SELECTED CANDIDATE over the typed text, so typing a new bare name
|
||||
/// that is a subsequence of an existing entry opens the existing file.
|
||||
/// Fixing this needs a Rust change to accept semantics, which Stage 0
|
||||
/// deliberately does not make.
|
||||
#[test]
|
||||
fn find_file_selected_candidate_shadows_typed_text() {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(td.path().join("notes.md"), b"existing\n").expect("write");
|
||||
|
||||
let mut s = editor_in(td.path());
|
||||
open_prompt(&mut s);
|
||||
// "nots" is a subsequence of "notes.md", so the candidate survives
|
||||
// the filter and shadows the typed name.
|
||||
type_str(&mut s, "nots");
|
||||
assert_eq!(
|
||||
candidates(&s),
|
||||
vec!["notes.md".to_string()],
|
||||
"the fixture depends on 'nots' matching 'notes.md'"
|
||||
);
|
||||
|
||||
press(&mut s, KeyCode::Enter);
|
||||
|
||||
let path = active_path(&s).expect("a file must be open");
|
||||
assert!(
|
||||
path.ends_with("notes.md"),
|
||||
"documented behavior: the selected candidate wins over typed text; got {path}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A leading `~` is expanded before the path reaches the core. This
|
||||
/// matters because `get_or_load_buffer` normalizes the path it STORES
|
||||
/// but loads from the RAW one, so an unexpanded `~/...` would dedup
|
||||
/// against an open buffer yet fail to load a file that is not open.
|
||||
#[test]
|
||||
fn find_file_expands_a_leading_tilde() {
|
||||
let Some(home) = std::env::var_os("HOME") else {
|
||||
eprintln!("HOME unset; skipping tilde expansion pin");
|
||||
return;
|
||||
};
|
||||
let home = home.to_string_lossy().into_owned();
|
||||
if home.is_empty() || !std::path::Path::new(&home).is_dir() {
|
||||
eprintln!("HOME is not a usable directory; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut s = EditorState::new();
|
||||
s.lua_host.reopen_init_phase_for_testing();
|
||||
open_prompt(&mut s);
|
||||
// Contains a '/', so the typed text reaches on_accept verbatim.
|
||||
// The leaf does not exist, so this lands on the new-file path and
|
||||
// touches no disk state.
|
||||
type_str(&mut s, "~/pmacs-find-file-tilde-probe.txt");
|
||||
press(&mut s, KeyCode::Enter);
|
||||
|
||||
let path = active_path(&s).expect("a buffer must be bound");
|
||||
assert!(
|
||||
!path.contains('~'),
|
||||
"the tilde must be expanded, not passed through; got {path}"
|
||||
);
|
||||
assert!(
|
||||
path.starts_with(&home),
|
||||
"the expansion must use $HOME; got {path} with HOME={home}"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue