feat: Align editor, workbench, run/debug and VCS interactions with IntelliJ IDEA

Keymap
- Let AltGr characters through instead of firing Ctrl+Alt shortcuts (Windows/Linux)
- Double Shift: strict press/release timing, ignored while typing or after Shift+click
- Chords wait 2s and swallow an unmatched second stroke
- Drop CodeMirror defaults that clash with IDEA keys; ⌘Enter is Split Line, add
  Scroll to Center and Code Block Start/End
- Add missing IDEA defaults (⌃J, ⌘Y, ⇧⌘C, ⌥⌘Y, ⇧⌘M, F9 Resume, ⌘R Rerun, …)

Editor
- Tab inserts at the caret; smart Backspace to the proper indent; Enter continues
  a `//` comment; per-language line comment placement
- Back/Forward records navigations only; ⌘F searches the selected text
- In-place rename; ⌃Space inserts a sole candidate; commit characters only for a
  focused lookup; usage highlighting survives edits; ⌥Enter offers fixes from the
  caret line; ⌘-hover shows declaration info only for navigable targets

Workbench
- Tool windows: primary and split area per side, six Move To anchors, layout migration
- ⌘W closes tool window content; Shift+Esc from the editor; closing a tab
  activates the left neighbour; preview tab and tab limit follow IDEA defaults
- Switcher lists open editors and tool windows; Recent Files Delete removes items
- Search Everywhere tab/scope behaviour; popups close when the window deactivates
- Project view: Copy/Move dialogs on paste, skippable usage search on delete,
  new-file input cancels on blur; Settings search Esc clears the filter
- Status bar breadcrumbs open a children popup; notification timeouts

Run / Debug / Build / Terminal
- Run asks to stop a running single-instance config; Rerun restarts silently;
  "Allow multiple instances" option
- Run chooser: Enter runs, Shift debugs, F4/Delete/digits
- Type into the console for stdin; Stop covers debug sessions
- Log (Shift+click) and temporary (Alt+click) breakpoints; independent suspend/log
  options; Evaluate prefill and history; changed-value highlighting; expandable hints
- Build window opens on failure and jumps to the first error
- Terminal confirms closing a busy tab and lets IDE shortcuts through

VCS
- Correct ours/theirs labels during rebase, cherry-pick and revert
- Smart/Force Checkout; remote branch checkout updates the local branch
- Partial commit by changed block; inline before-commit check results
- F7 continues into the next file; Rollback Lines; Commit and Push; Amend message swap
- Branch delete with Restore; force push confirmation; editable push target;
  update summary with the incoming commits
This commit is contained in:
l0ng-ai committed 2026-10-25 00:35:33 +08:00
1 parent 68dcea409c
commit fb3d42b76d
193 files changed
+8673 -1329

No files matched your search

+15 -9
View File
@@ -102,7 +102,7 @@ pub struct BreadcrumbsSettings {
impl Default for BreadcrumbsSettings {
fn default() -> Self {
Self { show: false, placement: "bottom".into(), disabled_languages: Vec::new() }
Self { show: true, placement: "bottom".into(), disabled_languages: Vec::new() }
}
}
@@ -130,10 +130,10 @@ impl Default for CompletionSettings {
case_sensitivity: "first".into(),
auto_popup: true,
insert_on_special_keys: false,
show_documentation: true,
show_documentation: false,
documentation_delay: 500,
parameter_info: true,
parameter_info_delay: 300,
parameter_info_delay: 1000,
}
}
}
@@ -192,9 +192,9 @@ impl Default for EditorTabsSettings {
show_close_button: true,
preview_tab: true,
open_at_end: false,
tab_limit: 10,
closing_policy: "unchanged".into(),
activate_on_close: "mru".into(),
tab_limit: 30,
closing_policy: "lru".into(),
activate_on_close: "left".into(),
}
}
}
@@ -214,7 +214,7 @@ pub struct SmartKeysSettings {
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
pub reformat_on_paste: String,
pub smart_indent: bool,
/// Backspace › Unindent: `nearest` indent position | `none`.
/// Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`.
pub backspace_unindent: String,
}
@@ -226,10 +226,10 @@ impl Default for SmartKeysSettings {
pair_brackets: true,
pair_quotes: true,
surround_selection: true,
tab_out_of_pair: false,
tab_out_of_pair: true,
reformat_on_paste: "indentBlock".into(),
smart_indent: true,
backspace_unindent: "nearest".into(),
backspace_unindent: "autoindent".into(),
}
}
}
@@ -314,6 +314,12 @@ pub struct LanguageCodeStyle {
pub indent: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hard_wrap_at: Option<u32>,
/// Comment Code › Line comment at first column.
#[serde(skip_serializing_if = "Option::is_none")]
pub line_comment_at_first_column: Option<bool>,
/// Comment Code › Add a space at line comment start.
#[serde(skip_serializing_if = "Option::is_none")]
pub line_comment_add_space: Option<bool>,
}
/// Editor › Code Style (General tab + per-language overrides).
+1
View File
@@ -287,6 +287,7 @@ mod tests {
detected: true,
is_test: true,
debug_adapter: None,
allow_parallel: false,
}
}
+20
View File
@@ -159,6 +159,26 @@ impl ProjectIndex {
self.shared.enqueue(Job::SaveRecent);
}
/// Drops a file from Recent Files (Delete in the ⌘E popup).
pub fn remove_recent(&self, path: &Path) {
let path = match path.to_str() {
Some(s) if ide_core::text::is_virtual_uri(s) => path.to_owned(),
_ => match self.normalize(path) {
Some(p) => p,
None => return,
},
};
{
let mut r = self.shared.recent.lock();
let before = r.len();
r.retain(|p| p != &path);
if r.len() == before {
return;
}
}
self.shared.enqueue(Job::SaveRecent);
}
/// Recently opened files, most recent first.
pub fn recent_files(&self) -> Vec<PathBuf> {
self.shared.recent.lock().clone()
+4
View File
@@ -192,7 +192,11 @@ fn persistence_skips_unchanged_files_on_reopen() {
assert_eq!(idx.status().files, 8);
assert_eq!(symbols(&idx, "OrderController", SymbolFilter::Types)[0], "OrderController");
assert_eq!(idx.recent_files(), vec![fx.root.join("web/app.py")]);
idx.remove_recent(&fx.root.join("web/app.py"));
assert!(idx.recent_files().is_empty());
assert!(idx.wait_idle(WAIT));
}
assert!(fx.open().recent_files().is_empty(), "removal is persisted");
std::thread::sleep(Duration::from_millis(20));
fx.write("src/main.rs", "fn main() {}\nfn renamed_fn() {}\n");
std::fs::remove_file(fx.root.join("docs/maintenance.md")).unwrap();
+1
View File
@@ -232,6 +232,7 @@ mod tests {
detected: true,
is_test: false,
debug_adapter: None,
allow_parallel: false,
}
}
+7
View File
@@ -11,6 +11,7 @@
//! env = { RUST_LOG = "debug" }
//! is-test = false
//! debug-adapter = "codelldb"
//! allow-parallel = true
//! ```
use crate::RunConfig;
@@ -51,6 +52,8 @@ struct FileConfig {
is_test: bool,
#[serde(alias = "debugAdapter", alias = "debug_adapter")]
debug_adapter: Option<String>,
#[serde(default, alias = "allowParallel", alias = "allow_parallel")]
allow_parallel: bool,
}
fn slug(s: &str) -> String {
@@ -91,6 +94,7 @@ pub(crate) fn load(root: &Path) -> IdeResult<Vec<RunConfig>> {
detected: false,
is_test: c.is_test,
debug_adapter: c.debug_adapter.filter(|d| !d.is_empty()),
allow_parallel: c.allow_parallel,
});
}
Ok(out)
@@ -121,6 +125,9 @@ fn to_table(c: &RunConfig) -> Table {
if let Some(adapter) = &c.debug_adapter {
t.insert("debug-adapter", value(adapter));
}
if c.allow_parallel {
t.insert("allow-parallel", value(true));
}
t
}
+1
View File
@@ -37,6 +37,7 @@ fn config(id: impl Into<String>, name: impl Into<String>, kind: &str, command: &
detected: true,
is_test: false,
debug_adapter: None,
allow_parallel: false,
}
}
+4
View File
@@ -34,6 +34,10 @@ pub struct RunConfig {
pub is_test: bool,
/// DAP adapter that can debug this config, if any.
pub debug_adapter: Option<String>,
/// "Allow multiple instances": Run on a running config starts another one instead of restarting it.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
#[specta(optional)]
pub allow_parallel: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
+1
View File
@@ -260,6 +260,7 @@ mod tests {
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
}
}
+2
View File
@@ -16,6 +16,7 @@ fn sample() -> Vec<RunConfig> {
detected: false,
is_test: false,
debug_adapter: Some("codelldb".into()),
allow_parallel: true,
},
RunConfig {
id: "user:tests".into(),
@@ -28,6 +29,7 @@ fn sample() -> Vec<RunConfig> {
detected: false,
is_test: true,
debug_adapter: None,
allow_parallel: false,
},
]
}
+1
View File
@@ -42,6 +42,7 @@ fn cfg(id: &str, command: &str, args: &[&str]) -> RunConfig {
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
}
}
+1
View File
@@ -46,6 +46,7 @@ fn run(kind: &str, command: &str, args: &[&str]) -> RunConfig {
detected: true,
is_test: false,
debug_adapter: None,
allow_parallel: false,
}
}
+108
View File
@@ -160,6 +160,19 @@ impl TerminalManager {
Ok(())
}
/// Whether the shell is running a command, so closing its tab should ask first
/// (as IDEA does). On Unix: the PTY's foreground process
/// group isn't the shell's, i.e. a job is in the foreground (background helpers
/// such as prompt daemons don't count). On Windows: the shell has child processes.
pub fn is_busy(&self, id: TerminalId) -> IdeResult<bool> {
let session = self.session(id)?;
if session.exited.load(Ordering::Acquire) {
return Ok(false);
}
let Some(pid) = session.info.pid else { return Ok(false) };
Ok(foreground_busy(&session, pid))
}
pub fn list(&self) -> Vec<TerminalInfo> {
let mut out: Vec<TerminalInfo> = self.sessions.lock().values().map(|s| s.info.clone()).collect();
out.sort_by_key(|i| i.id);
@@ -191,6 +204,76 @@ fn not_found(id: TerminalId) -> IdeError {
IdeError::not_found(format!("no such terminal: {id}"))
}
#[cfg(unix)]
fn foreground_busy(session: &Session, pid: u32) -> bool {
let leader = session.master.lock().process_group_leader();
leader.is_some_and(|fg| u32::try_from(fg).ok() != Some(pid))
}
#[cfg(windows)]
fn foreground_busy(_session: &Session, pid: u32) -> bool {
win::has_child_processes(pid)
}
#[cfg(not(any(unix, windows)))]
fn foreground_busy(_session: &Session, _pid: u32) -> bool {
false
}
#[cfg(windows)]
mod win {
use std::ffi::c_void;
const TH32CS_SNAPPROCESS: u32 = 0x2;
const INVALID_HANDLE_VALUE: isize = -1;
/// `PROCESSENTRY32W` from `<tlhelp32.h>`.
#[repr(C)]
struct ProcessEntry32W {
dw_size: u32,
cnt_usage: u32,
th32_process_id: u32,
th32_default_heap_id: usize,
th32_module_id: u32,
cnt_threads: u32,
th32_parent_process_id: u32,
pc_pri_class_base: i32,
dw_flags: u32,
sz_exe_file: [u16; 260],
}
#[link(name = "kernel32")]
unsafe extern "system" {
fn CreateToolhelp32Snapshot(flags: u32, pid: u32) -> *mut c_void;
fn Process32FirstW(snapshot: *mut c_void, entry: *mut ProcessEntry32W) -> i32;
fn Process32NextW(snapshot: *mut c_void, entry: *mut ProcessEntry32W) -> i32;
fn CloseHandle(handle: *mut c_void) -> i32;
}
pub fn has_child_processes(pid: u32) -> bool {
// SAFETY: plain Win32 calls on a locally owned snapshot handle and entry buffer.
unsafe {
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if snapshot.is_null() || snapshot as isize == INVALID_HANDLE_VALUE {
return false;
}
let mut entry: ProcessEntry32W = std::mem::zeroed();
entry.dw_size = std::mem::size_of::<ProcessEntry32W>() as u32;
let mut found = false;
let mut ok = Process32FirstW(snapshot, &mut entry);
while ok != 0 {
if entry.th32_parent_process_id == pid && entry.th32_process_id != pid {
found = true;
break;
}
ok = Process32NextW(snapshot, &mut entry);
}
CloseHandle(snapshot);
found
}
}
}
/// Hangs up the shell (and its process group), escalating to SIGKILL if it lingers.
fn terminate(session: &Session) {
if session.exited.load(Ordering::Acquire) {
@@ -650,6 +733,31 @@ mod tests {
assert!(h.manager.kill(info.id).is_err());
}
#[test]
fn busy_while_a_command_runs_in_the_foreground() {
let h = harness();
let mut opts = sh("");
opts.args = vec!["-i".into()];
opts.env.insert("PS1".into(), "$ ".into());
opts.env.insert("ENV".into(), "/dev/null".into());
let (info, _rx) = spawn_collect(&h, opts);
let wait_for = |want: bool| {
let deadline = Instant::now() + Duration::from_secs(10);
while h.manager.is_busy(info.id).unwrap() != want {
assert!(Instant::now() < deadline, "is_busy never became {want}");
std::thread::sleep(Duration::from_millis(20));
}
};
wait_for(false);
h.manager.write(info.id, "sleep 30\n").unwrap();
wait_for(true);
h.manager.write(info.id, "\u{3}").unwrap();
wait_for(false);
h.manager.write(info.id, "exit\n").unwrap();
let _ = wait_exit(&h, info.id);
assert!(h.manager.is_busy(info.id).is_err());
}
#[test]
fn large_output_is_batched_and_complete() {
let h = harness();
+172 -2
View File
@@ -136,6 +136,30 @@ pub struct CommitOptions {
pub author: Option<String>,
/// False skips the pre-commit and commit-msg hooks (`--no-verify`).
pub run_hooks: bool,
/// Files committed with only some of their changes (IDEA's partial commit). They are
/// committed through a temporary index, so neither the working tree nor the other
/// entries of the index change; afterwards their index entries match the new commit.
#[serde(default)]
pub partial: Vec<PartialCommitFile>,
}
/// A file of a partial commit: which of its changes against HEAD to commit.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PartialCommitFile {
pub path: ide_core::IpcPath,
/// The hunks to commit, as [`diff_hunks`] of the HEAD and on-disk texts reports them.
pub hunks: Vec<HunkRange>,
}
/// A hunk identified by its line ranges (0-based), see [`LineHunk`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct HunkRange {
pub start: u32,
pub lines: u32,
pub old_start: u32,
pub old_lines: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
@@ -237,6 +261,106 @@ pub struct ApplyOutcome {
pub conflicts: Vec<String>,
}
/// How Checkout treats local changes that the switch would overwrite (IDEA's Smart
/// Checkout dialog).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum CheckoutMode {
/// Fail with [`CheckoutOutcome::LocalChanges`] and change nothing.
#[default]
Normal,
/// Stash local changes, switch, and restore them (conflicts are left to resolve).
Smart,
/// Discard the local changes in the way (`git switch --discard-changes`).
Force,
}
/// Checking out remote `origin/x` while local `x` has commits `origin/x` lacks.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum DivergedLocal {
/// Rebase local `x` onto `origin/x` (IDEA "Rebase onto Remote").
Rebase,
/// Reset local `x` to `origin/x` (IDEA "Drop Local Commits").
Reset,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CheckoutOptions {
#[serde(default)]
pub mode: CheckoutMode,
/// Required when checking out a remote branch whose local namesake is ahead of it.
#[serde(default)]
pub diverged: Option<DivergedLocal>,
}
/// Result of [`Repo::checkout_with`]; the non-`Done` variants other than `Conflicts`
/// left the repository untouched.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum CheckoutOutcome {
Done,
/// These local changes (repository-relative paths) would be overwritten.
LocalChanges {
files: Vec<String>,
},
/// Local branch `local` has commits that remote branch `remote` does not.
LocalBranchAhead {
local: String,
remote: String,
},
/// Switched, but restoring the local changes (Smart Checkout) or the rebase stopped
/// on conflicts in these files.
Conflicts {
files: Vec<String>,
},
}
/// A deleted local branch, enough to restore it (IDEA's "Restore" notification action).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DeletedBranch {
pub name: String,
/// The commit the branch pointed at.
pub tip: String,
/// Its upstream (`origin/x`), restored with it.
pub upstream: Option<String>,
/// It was not fully merged: its own commits are now unreachable from any branch.
pub unmerged: bool,
}
/// Who stands on each side of the conflicts of the operation in progress (merge editor
/// titles, as IDEA labels them).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct MergeSides {
/// `merge`, `rebase`, `cherry-pick`, `revert`…; `None` outside an operation.
pub operation: Option<String>,
/// Git's "ours" is not the user's work: during a rebase stage 2 holds the commits
/// rebased onto and stage 3 the user's commit being replayed. [`MergeVersions`] is
/// already swapped; conflict markers in the file are not.
pub reversed: bool,
/// The current branch (for a rebase: the branch being rebased).
pub branch: Option<String>,
/// Short id of the commit being applied (REBASE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD)
/// or merged (MERGE_HEAD).
pub commit: Option<String>,
/// The other branch: the one being merged, or the one rebased onto.
pub other_branch: Option<String>,
}
/// The versions of a conflicted file as the merge editor shows them.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct MergeVersions {
pub base: Option<String>,
/// The user's side ("Accept Yours"): git's stage 2, or stage 3 when `sides.reversed`.
pub ours: Option<String>,
pub theirs: Option<String>,
pub sides: MergeSides,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Changelist {
@@ -416,7 +540,7 @@ impl Repo {
/// Commits the index; returns the new HEAD commit id. An empty message with
/// `amend` keeps the previous message.
pub fn commit(&self, message: &str, amend: bool, sign_off: bool) -> IdeResult<String> {
let options = CommitOptions { amend, sign_off, author: None, run_hooks: true };
let options = CommitOptions { amend, sign_off, author: None, run_hooks: true, partial: Vec::new() };
write::commit(self, message, &options)
}
/// Commits with the Commit Options popup's choices.
@@ -429,6 +553,13 @@ impl Repo {
pub fn checkout(&self, branch: &str, create: bool) -> IdeResult<()> {
write::checkout(self, branch, create)
}
/// [`Repo::checkout`] as IDEA does it: local changes in the way are reported (or
/// stashed / discarded per `options.mode`), and a remote branch whose local namesake
/// is behind fast-forwards that branch, while one that is ahead is reported (or
/// rebased / reset per `options.diverged`).
pub fn checkout_with(&self, branch: &str, create: bool, options: &CheckoutOptions) -> IdeResult<CheckoutOutcome> {
write::checkout_with(self, branch, create, options)
}
/// Creates branch `name` at `start` (default HEAD), optionally switching to it. A
/// remote-tracking start point (`origin/x`) becomes the new branch's upstream.
pub fn create_branch(&self, name: &str, start: Option<&str>, checkout: bool) -> IdeResult<()> {
@@ -444,6 +575,16 @@ impl Repo {
pub fn delete_branch(&self, branch: &str, force: bool) -> IdeResult<()> {
write::delete_branch(self, branch, force)
}
/// Deletes a local branch as IDEA does: without asking, force-deleting one that is not
/// fully merged (reported as `unmerged`), and returning what [`Repo::restore_branch`]
/// needs to undo it.
pub fn delete_branch_restorable(&self, branch: &str) -> IdeResult<DeletedBranch> {
write::delete_branch_restorable(self, branch)
}
/// Recreates a deleted branch at its old tip, with its upstream.
pub fn restore_branch(&self, deleted: &DeletedBranch) -> IdeResult<()> {
write::restore_branch(self, deleted)
}
/// Renames a local branch (`git branch -m`); its upstream configuration moves with it.
pub fn rename_branch(&self, from: &str, to: &str) -> IdeResult<()> {
write::rename_branch(self, from, to)
@@ -523,7 +664,20 @@ impl Repo {
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
write::push_branch(self, branch, revision, force_with_lease, cancel, on_output)
write::push_branch(self, branch, revision, None, force_with_lease, cancel, on_output)
}
/// [`Repo::push_branch`] to remote branch `target` instead of the upstream's name (the
/// Push dialog's editable target).
pub fn push_branch_to(
&self,
branch: &str,
revision: Option<&str>,
target: &str,
force_with_lease: bool,
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
write::push_branch(self, branch, revision, Some(target), force_with_lease, cancel, on_output)
}
/// Fast-forwards a local branch that is not checked out to its upstream (Branches ›
/// Update). Fails when the branch has diverged.
@@ -586,6 +740,10 @@ impl Repo {
pub fn tags(&self) -> IdeResult<Vec<String>> {
ops::tags(self)
}
/// Files that differ between revisions `from` and `to`.
pub fn diff_revisions(&self, from: &str, to: &str) -> IdeResult<Vec<ChangedFile>> {
ops::diff_revisions(self, from, to)
}
/// Tracked files whose working-tree content differs from `rev`.
pub fn diff_with_worktree(&self, rev: &str) -> IdeResult<Vec<ChangedFile>> {
ops::diff_with_worktree(self, rev)
@@ -618,6 +776,18 @@ impl Repo {
shelf::unshelve(self, shelf_dir, id, remove)
}
/// Who is on each side of the conflicts of the operation in progress.
pub fn merge_sides(&self) -> IdeResult<MergeSides> {
ops::merge_sides(self)
}
/// The versions of a conflicted file with the user's work as `ours`, as IDEA's merge
/// dialog shows them (sides swapped during a rebase).
pub fn merge_versions(&self, path: &Path) -> IdeResult<MergeVersions> {
let (base, ours, theirs) = self.conflict_versions(path)?;
let sides = self.merge_sides()?;
let (ours, theirs) = if sides.reversed { (theirs, ours) } else { (ours, theirs) };
Ok(MergeVersions { base, ours, theirs, sides })
}
/// For conflict resolution: (base, ours, theirs) versions of a conflicted file.
pub fn conflict_versions(&self, path: &Path) -> IdeResult<(Option<String>, Option<String>, Option<String>)> {
let rel = self.rel_path(path)?;
+66 -1
View File
@@ -3,7 +3,7 @@
use crate::cli::{self, run};
use crate::repo::{gx, resolve};
use crate::{ChangeKind, ChangedFile, RebaseAction, RebaseEntry, Repo, ResetMode};
use crate::{ChangeKind, ChangedFile, MergeSides, RebaseAction, RebaseEntry, Repo, ResetMode};
use ide_core::{CancellationToken, IdeError, IdeResult};
/// The in-progress operation git can continue / abort / skip.
@@ -20,6 +20,64 @@ fn sequencer(r: &Repo) -> IdeResult<&'static str> {
use crate::write::unmerged_paths;
/// The sides of the operation in progress, read from the files git keeps in `.git`.
pub(crate) fn merge_sides(r: &Repo) -> IdeResult<MergeSides> {
let repo = r.gix()?;
let operation = crate::refs::operation(&repo);
let read = |name: &str| {
std::fs::read_to_string(r.git_dir.join(name))
.ok()
.and_then(|s| s.lines().next().map(|l| l.trim().to_string()))
.filter(|s| !s.is_empty())
};
let short = |id: String| id.chars().take(8).collect::<String>();
let head_branch = repo.head_name().map_err(gx)?.map(|n| crate::repo::to_string(n.as_ref().shorten()));
let mut sides = MergeSides { operation: operation.clone(), branch: head_branch, ..Default::default() };
match operation.as_deref() {
Some("rebase") => {
sides.reversed = true;
let dir = ["rebase-merge", "rebase-apply"].into_iter().find(|d| r.git_dir.join(d).is_dir());
if let Some(dir) = dir {
if let Some(head) = read(&format!("{dir}/head-name")) {
sides.branch = Some(head.strip_prefix("refs/heads/").unwrap_or(&head).to_string());
}
sides.other_branch = read(&format!("{dir}/onto")).and_then(|onto| branch_at(r, &onto));
}
sides.commit = read("REBASE_HEAD").map(short);
}
Some("merge") => {
let head = read("MERGE_HEAD");
// git quotes the merged ref on the first line: `Merge branch 'main' into feature`.
sides.other_branch = read("MERGE_MSG")
.and_then(|m| m.split('\'').nth(1).map(str::to_string))
.filter(|b| !b.is_empty())
.or_else(|| head.as_deref().and_then(|h| branch_at(r, h)));
sides.commit = head.map(short);
}
Some("cherry-pick") => sides.commit = read("CHERRY_PICK_HEAD").map(short),
Some("revert") => sides.commit = read("REVERT_HEAD").map(short),
_ => {}
}
Ok(sides)
}
/// The single branch (local first, then remote) pointing at `commit`.
fn branch_at(r: &Repo, commit: &str) -> Option<String> {
if commit.starts_with('-') {
return None;
}
["refs/heads", "refs/remotes"].into_iter().find_map(|refs| {
let out =
run(r.root(), &["for-each-ref", "--points-at", commit, "--format=%(refname:short)", refs], None).ok()?;
let names: Vec<String> =
out.stdout_str().lines().filter(|l| !l.is_empty() && !l.ends_with("/HEAD")).map(str::to_string).collect();
match names.as_slice() {
[one] => Some(one.clone()),
_ => None,
}
})
}
fn check_commits(commits: &[String]) -> IdeResult<()> {
if commits.is_empty() {
return Err(IdeError::invalid("no commits selected"));
@@ -259,6 +317,13 @@ pub(crate) fn diff_with_worktree(r: &Repo, rev: &str) -> IdeResult<Vec<ChangedFi
Ok(parse_name_status(&out.stdout_str()))
}
/// Files that differ between two revisions (Update Project's "N files updated").
pub(crate) fn diff_revisions(r: &Repo, from: &str, to: &str) -> IdeResult<Vec<ChangedFile>> {
check_commits(&[from.to_string(), to.to_string()])?;
let out = run(r.root(), &["diff", "--name-status", "-z", "-M", "--no-ext-diff", from, to, "--"], None)?;
Ok(parse_name_status(&out.stdout_str()))
}
/// Parses `git diff --name-status -z` output.
pub(crate) fn parse_name_status(text: &str) -> Vec<ChangedFile> {
let mut parts = text.split('\0').filter(|s| !s.is_empty());
+244 -3
View File
@@ -2,7 +2,9 @@
use crate::cli::{self, run};
use crate::repo::{gx, resolve};
use crate::{BlameLine, CommitOptions, Repo};
use crate::{
BlameLine, CheckoutMode, CheckoutOptions, CheckoutOutcome, CommitOptions, DeletedBranch, DivergedLocal, Repo,
};
use gix::bstr::{BStr, ByteSlice};
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::path::{Path, PathBuf};
@@ -247,11 +249,85 @@ pub(crate) fn commit(r: &Repo, message: &str, options: &CommitOptions) -> IdeRes
args.extend(["-F", "-"]);
Some(message.as_bytes())
};
run(r.root(), &args, stdin)?;
if options.partial.is_empty() {
run(r.root(), &args, stdin)?;
} else {
commit_partial(r, &args, stdin, &options.partial)?;
}
let repo = r.gix()?;
Ok(repo.head_id().map_err(gx)?.detach().to_string())
}
/// Removes the temporary index however the partial commit ends.
struct TempIndex(PathBuf);
impl Drop for TempIndex {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
let _ = std::fs::remove_file(self.0.with_extension("lock"));
}
}
/// Commits the index with only the chosen hunks of `partial` files, through a copy of the
/// index (`GIT_INDEX_FILE`), as IDEA's partial commit does: the working tree and the
/// user's index are left alone except that the partial files' entries end up matching
/// the new commit.
fn commit_partial(
r: &Repo,
commit_args: &[&str],
stdin: Option<&[u8]>,
partial: &[crate::PartialCommitFile],
) -> IdeResult<()> {
let tmp = TempIndex(r.git_dir.join("openidea-partial-index"));
let index = r.git_dir.join("index");
if index.exists() {
std::fs::copy(&index, &tmp.0)?;
}
let env = [("GIT_INDEX_FILE", tmp.0.as_os_str())];
let mut rels = Vec::new();
for file in partial {
let rel = r.rel_path(&file.path.0)?;
let head = {
let repo = r.gix()?;
crate::repo::blob_at(&repo, &rel, "HEAD")?
}
.ok_or_else(|| {
IdeError::invalid(format!("{rel}: only changes of a file in HEAD can be committed partially"))
})?;
let disk = std::fs::read(r.abs_path(&rel))?;
if head.contains(&0) || disk.contains(&0) {
return Err(IdeError::invalid(format!("{rel}: binary files cannot be committed partially")));
}
let (head, disk) = (String::from_utf8_lossy(&head), String::from_utf8_lossy(&disk));
let hunks = crate::diff::line_hunks(&head, &disk);
let mut selected = Vec::new();
for want in &file.hunks {
let found = hunks.iter().position(|h| {
(h.start, h.lines, h.old_start, h.old_lines) == (want.start, want.lines, want.old_start, want.old_lines)
});
match found {
Some(i) => selected.push(i),
None => return Err(IdeError::invalid(format!("{rel} changed on disk; review its changes again"))),
}
}
cli::run_env(r.root(), &["--literal-pathspecs", "reset", "-q", "HEAD", "--", &rel], None, &env)?;
let patch = crate::diff::build_partial_patch(&rel, &head, &disk, &selected);
if !patch.is_empty() {
cli::run_env(
r.root(),
&["apply", "--cached", "--unidiff-zero", "--whitespace=nowarn", "--recount", "-"],
Some(patch.as_bytes()),
&env,
)?;
}
rels.push(rel);
}
cli::run_env(r.root(), commit_args, stdin, &env)?;
// The user's index still holds the partial files' previous entries: align them with
// the commit so the remaining changes show as unstaged, nothing as staged.
run_paths(r, &["reset", "-q", "HEAD"], &rels)
}
pub(crate) fn checkout(r: &Repo, branch: &str, create: bool) -> IdeResult<()> {
let branch = branch.trim();
if branch.is_empty() {
@@ -280,6 +356,128 @@ pub(crate) fn checkout(r: &Repo, branch: &str, create: bool) -> IdeResult<()> {
run(r.root(), &["switch", "--detach", branch], None).map(|_| ())
}
/// What a checkout of `branch` runs.
enum SwitchPlan {
Switch(Vec<String>),
/// `git rebase <remote> <local>`.
Rebase {
local: String,
remote: String,
},
Ahead {
local: String,
remote: String,
},
}
fn switch_plan(r: &Repo, branch: &str, create: bool, diverged: Option<DivergedLocal>) -> IdeResult<SwitchPlan> {
let args = |a: &[&str]| SwitchPlan::Switch(a.iter().map(|s| s.to_string()).collect());
if create {
return Ok(args(&["switch", "-c", branch]));
}
let repo = r.gix()?;
let exists = |name: &str| repo.try_find_reference(name).map_err(gx).map(|r| r.is_some());
if exists(&format!("refs/heads/{branch}"))? {
return Ok(args(&["switch", branch]));
}
if exists(&format!("refs/remotes/{branch}"))?
&& let Some((_, name)) = branch.split_once('/')
{
if !exists(&format!("refs/heads/{name}"))? {
return Ok(args(&["switch", "-c", name, "--track", branch]));
}
// IDEA: a local namesake that is merely behind is fast-forwarded; one with commits
// of its own needs the user's choice.
let range = format!("{branch}..refs/heads/{name}");
let ahead = run(r.root(), &["rev-list", "--count", &range], None)?.stdout_str().trim() != "0";
return Ok(match (ahead, diverged) {
(true, None) => SwitchPlan::Ahead { local: name.to_string(), remote: branch.to_string() },
(true, Some(DivergedLocal::Rebase)) => {
SwitchPlan::Rebase { local: name.to_string(), remote: branch.to_string() }
}
_ => args(&["switch", "-C", name, "--track", branch]),
});
}
// A tag or commit: detached HEAD.
resolve(&repo, branch)?;
Ok(args(&["switch", "--detach", branch]))
}
/// Files git refuses to overwrite with local changes, from its error message.
fn overwritten_files(stderr: &str) -> Option<Vec<String>> {
let mut lines =
stderr.lines().skip_while(|l| !l.contains("local changes to the following files would be overwritten"));
lines.next()?;
Some(lines.take_while(|l| l.starts_with('\t')).map(|l| l.trim().to_string()).collect())
}
pub(crate) fn checkout_with(
r: &Repo,
branch: &str,
create: bool,
options: &CheckoutOptions,
) -> IdeResult<CheckoutOutcome> {
let branch = branch.trim();
if branch.is_empty() {
return Err(IdeError::invalid("branch name is empty"));
}
if branch.starts_with('-') {
return Err(IdeError::invalid(format!("invalid revision '{branch}'")));
}
let mut args = match switch_plan(r, branch, create, options.diverged)? {
SwitchPlan::Ahead { local, remote } => return Ok(CheckoutOutcome::LocalBranchAhead { local, remote }),
SwitchPlan::Rebase { local, remote } => {
let out = cli::run_raw(r.root(), &["rebase", "--autostash", &remote, &local], None)?;
if out.code == Some(0) {
return Ok(CheckoutOutcome::Done);
}
let files = unmerged_paths(r)?;
if files.is_empty() {
return Err(cli::failure(&["rebase"], &out.stderr, &out.stdout, out.code));
}
return Ok(CheckoutOutcome::Conflicts { files });
}
SwitchPlan::Switch(args) => args,
};
if options.mode == CheckoutMode::Force {
args.push("--discard-changes".into());
}
let argv: Vec<&str> = args.iter().map(String::as_str).collect();
let out = cli::run_raw(r.root(), &argv, None)?;
if out.code == Some(0) {
return Ok(CheckoutOutcome::Done);
}
let Some(files) = overwritten_files(&String::from_utf8_lossy(&out.stderr)) else {
return Err(cli::failure(&argv, &out.stderr, &out.stdout, out.code));
};
if options.mode != CheckoutMode::Smart {
return Ok(CheckoutOutcome::LocalChanges { files });
}
// Smart Checkout: stash → switch → unstash, like IDEA.
let before = stash_count(r)?;
run(r.root(), &["stash", "push", "-q", "-m", &format!("Uncommitted changes before checkout of {branch}")], None)?;
if stash_count(r)? == before {
return Err(IdeError::invalid("could not save the local changes"));
}
if let Err(e) = run(r.root(), &argv, None) {
run(r.root(), &["stash", "pop", "-q", "--index"], None)?;
return Err(e);
}
let pop = cli::run_raw(r.root(), &["stash", "pop", "-q"], None)?;
if pop.code == Some(0) {
return Ok(CheckoutOutcome::Done);
}
let files = unmerged_paths(r)?;
if files.is_empty() {
return Err(cli::failure(&["stash", "pop"], &pop.stderr, &pop.stdout, pop.code));
}
Ok(CheckoutOutcome::Conflicts { files })
}
fn stash_count(r: &Repo) -> IdeResult<usize> {
Ok(run(r.root(), &["stash", "list"], None)?.stdout_str().lines().count())
}
/// Creates `name` at `start` (default HEAD); a remote-tracking start point is tracked.
pub(crate) fn create_branch(r: &Repo, name: &str, start: Option<&str>, checkout: bool) -> IdeResult<()> {
let name = name.trim();
@@ -342,6 +540,39 @@ pub(crate) fn delete_branch(r: &Repo, branch: &str, force: bool) -> IdeResult<()
run(r.root(), &["branch", if force { "-D" } else { "-d" }, "--", branch], None).map(|_| ())
}
pub(crate) fn delete_branch_restorable(r: &Repo, branch: &str) -> IdeResult<DeletedBranch> {
check_local_branch(r, branch)?;
let tip = run(r.root(), &["rev-parse", "--verify", "-q", &format!("refs/heads/{branch}^{{commit}}")], None)?
.stdout_str()
.trim()
.to_string();
let upstream =
cli::run_raw(r.root(), &["for-each-ref", "--format=%(upstream:short)", &format!("refs/heads/{branch}")], None)?
.stdout_str()
.trim()
.to_string();
let out = cli::run_raw(r.root(), &["branch", "-d", "--", branch], None)?;
let unmerged = match out.code {
Some(0) => false,
_ if String::from_utf8_lossy(&out.stderr).contains("not fully merged") => {
run(r.root(), &["branch", "-D", "--", branch], None)?;
true
}
code => return Err(cli::failure(&["branch"], &out.stderr, &out.stdout, code)),
};
Ok(DeletedBranch { name: branch.to_string(), tip, upstream: (!upstream.is_empty()).then_some(upstream), unmerged })
}
pub(crate) fn restore_branch(r: &Repo, deleted: &DeletedBranch) -> IdeResult<()> {
let tip = check_rev(&deleted.tip)?;
run(r.root(), &["branch", "--", &deleted.name, tip], None)?;
if let Some(upstream) = &deleted.upstream {
let target = format!("--set-upstream-to={upstream}");
run(r.root(), &["branch", &target, "--", &deleted.name], None)?;
}
Ok(())
}
pub(crate) fn rename_branch(r: &Repo, from: &str, to: &str) -> IdeResult<()> {
let to = to.trim();
if to.is_empty() {
@@ -479,15 +710,25 @@ pub(crate) fn push_branch(
r: &Repo,
branch: &str,
revision: Option<&str>,
target: Option<&str>,
force_with_lease: bool,
cancel: &CancellationToken,
on_output: &dyn Fn(String),
) -> IdeResult<()> {
check_local_branch(r, branch)?;
let (remote, target, set_upstream) = match branch_upstream(r, branch) {
let (remote, mut name, set_upstream) = match branch_upstream(r, branch) {
Some((remote, name)) => (remote, name, false),
None => (push_remote(r)?, branch.to_string(), revision.is_none()),
};
if let Some(t) = target.map(str::trim).filter(|t| !t.is_empty()) {
let valid =
!t.starts_with('-') && cli::run_raw(r.root(), &["check-ref-format", "--branch", t], None)?.code == Some(0);
if !valid {
return Err(IdeError::invalid(format!("'{t}' is not a valid branch name")));
}
name = t.to_string();
}
let target = name;
let source = match revision {
Some(rev) => check_rev(rev)?.to_string(),
None => format!("refs/heads/{branch}"),
+52
View File
@@ -106,6 +106,47 @@ fn rebase_conflict_then_continue() {
assert_eq!(fx.git(&["rev-parse", "HEAD~1"]), fx.git(&["rev-parse", "main"]));
}
#[test]
fn merge_versions_put_the_users_work_on_the_left() {
// Rebase: git's "ours" is main; the user's commit (topic) must be `ours` here.
let fx = diverged();
let repo = fx.repo();
let topic = fx.git(&["rev-parse", "topic"]);
assert!(repo.rebase("main").is_err());
let v = repo.merge_versions(&fx.path("f.txt")).unwrap();
assert_eq!(v.ours.as_deref(), Some("topic\n"));
assert_eq!(v.theirs.as_deref(), Some("main\n"));
assert_eq!(v.base.as_deref(), Some("base\n"));
assert!(v.sides.reversed);
assert_eq!(v.sides.operation.as_deref(), Some("rebase"));
assert_eq!(v.sides.branch.as_deref(), Some("topic"));
assert_eq!(v.sides.other_branch.as_deref(), Some("main"));
assert_eq!(v.sides.commit.as_deref(), Some(&topic[..8]));
let (cancel, out) = quiet();
repo.abort_operation(&cancel, &out).unwrap();
// Merge: not swapped; the merged branch is named.
assert!(repo.merge("main").is_err());
let v = repo.merge_versions(&fx.path("f.txt")).unwrap();
assert_eq!(v.ours.as_deref(), Some("topic\n"));
assert_eq!(v.theirs.as_deref(), Some("main\n"));
assert!(!v.sides.reversed);
assert_eq!(v.sides.branch.as_deref(), Some("topic"));
assert_eq!(v.sides.other_branch.as_deref(), Some("main"));
repo.abort_operation(&cancel, &out).unwrap();
// Cherry-pick: not swapped; the picked commit is named.
let main = fx.git(&["rev-parse", "main"]);
assert!(repo.cherry_pick(std::slice::from_ref(&main), &cancel, &out).is_err());
let v = repo.merge_versions(&fx.path("f.txt")).unwrap();
assert_eq!(v.ours.as_deref(), Some("topic\n"));
assert!(!v.sides.reversed);
assert_eq!(v.sides.operation.as_deref(), Some("cherry-pick"));
assert_eq!(v.sides.commit.as_deref(), Some(&main[..8]));
repo.abort_operation(&cancel, &out).unwrap();
assert_eq!(repo.merge_sides().unwrap(), MergeSides { branch: Some("topic".into()), ..Default::default() });
}
#[test]
fn rebase_conflict_then_abort_and_skip() {
let fx = diverged();
@@ -262,6 +303,11 @@ fn compare_with_local_lists_differences() {
got,
vec![("a.txt", ChangeKind::Modified), ("b.txt", ChangeKind::Modified), ("c.txt", ChangeKind::Added)]
);
// Between two revisions the working tree does not count.
let files = fx.repo().diff_revisions(&one, "HEAD").unwrap();
let got: Vec<(&str, ChangeKind)> = files.iter().map(|f| (f.path.as_str(), f.kind)).collect();
assert_eq!(got, vec![("a.txt", ChangeKind::Modified), ("c.txt", ChangeKind::Added)]);
assert!(fx.repo().diff_revisions("-x", "HEAD").is_err());
}
#[test]
@@ -448,6 +494,12 @@ fn push_branch_and_up_to_revision() {
assert_eq!(git_in(&bare, &["rev-parse", "side"]), c2);
assert_eq!(fx.git(&["rev-parse", "--abbrev-ref", "side@{upstream}"]), "origin/side");
assert!(repo.push_branch("nope", None, false, &cancel, &out).is_err());
// An edited target: same remote, another branch name; the upstream stays as it was.
repo.push_branch_to("main", None, "review/main", false, &cancel, &out).unwrap();
assert_eq!(git_in(&bare, &["rev-parse", "review/main"]), c3);
assert_eq!(fx.git(&["rev-parse", "--abbrev-ref", "main@{upstream}"]), "origin/main");
assert!(repo.push_branch_to("main", None, "bad..name", false, &cancel, &out).is_err());
}
#[test]
+207
View File
@@ -770,6 +770,213 @@ fn checkout_create_detached_delete_rebase() {
assert!(repo.checkout("does-not-exist", false).is_err());
}
#[test]
fn smart_and_force_checkout_with_local_changes_in_the_way() {
let fx = Fixture::new();
fx.write("f.txt", "1\n2\n3\n4\n5\n");
fx.commit_all("base");
fx.git(&["checkout", "-q", "-b", "other"]);
fx.write("f.txt", "1\nother\n3\n4\n5\n");
fx.commit_all("other edit");
fx.git(&["checkout", "-q", "main"]);
let repo = fx.repo();
let opts = |mode| CheckoutOptions { mode, diverged: None };
// A non-conflicting local edit to a file the switch rewrites.
fx.write("f.txt", "1\n2\n3\n4\nlocal\n");
let outcome = repo.checkout_with("other", false, &opts(CheckoutMode::Normal)).unwrap();
assert_eq!(outcome, CheckoutOutcome::LocalChanges { files: vec!["f.txt".into()] });
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("main"), "nothing changed");
assert_eq!(fx.read("f.txt"), "1\n2\n3\n4\nlocal\n");
let outcome = repo.checkout_with("other", false, &opts(CheckoutMode::Smart)).unwrap();
assert_eq!(outcome, CheckoutOutcome::Done);
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("other"));
assert_eq!(fx.read("f.txt"), "1\nother\n3\n4\nlocal\n", "local change carried over");
assert!(repo.stashes().unwrap().is_empty(), "stash restored and dropped");
// A local edit that conflicts with the target: smart checkout leaves a conflict.
fx.git(&["checkout", "-q", "-f", "main"]);
fx.write("f.txt", "1\nmine\n3\n4\n5\n");
let outcome = repo.checkout_with("other", false, &opts(CheckoutMode::Smart)).unwrap();
assert_eq!(outcome, CheckoutOutcome::Conflicts { files: vec!["f.txt".into()] });
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("other"));
assert_eq!(repo.stashes().unwrap().len(), 1, "git keeps the stash after a conflicting pop");
// Force discards the change in the way.
fx.git(&["reset", "-q", "--hard"]);
fx.git(&["stash", "drop", "-q"]);
fx.write("f.txt", "1\nmine\n3\n4\n5\n");
fx.write("untouched.txt", "new\n");
let outcome = repo.checkout_with("main", false, &opts(CheckoutMode::Force)).unwrap();
assert_eq!(outcome, CheckoutOutcome::Done);
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("main"));
assert_eq!(fx.read("f.txt"), "1\n2\n3\n4\n5\n");
assert_eq!(fx.read("untouched.txt"), "new\n", "untracked files survive");
}
#[test]
fn checkout_remote_branch_with_existing_local_namesake() {
let (fx, bare) = with_remote();
let repo = fx.repo();
// Another clone advances origin/main.
let other = fx._tmp.path().join("other");
git_in(fx._tmp.path(), &["clone", "-q", bare.to_str().unwrap(), other.to_str().unwrap()]);
configure(&other);
std::fs::write(other.join("b.txt"), "remote\n").unwrap();
git_in(&other, &["add", "-A"]);
git_in(&other, &["commit", "-q", "-m", "remote work"]);
git_in(&other, &["push", "-q", "origin", "main"]);
fx.git(&["fetch", "-q"]);
fx.git(&["checkout", "-q", "-b", "topic"]);
let none = CheckoutOptions::default();
// Local main is only behind: it is fast-forwarded and checked out.
assert_eq!(repo.checkout_with("origin/main", false, &none).unwrap(), CheckoutOutcome::Done);
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("main"));
assert_eq!(fx.git(&["rev-parse", "main"]), fx.git(&["rev-parse", "origin/main"]));
// Local main has its own commit: the user must choose; nothing changes meanwhile.
fx.write("c.txt", "local\n");
let local_tip = fx.commit_all("local work");
fx.git(&["checkout", "-q", "topic"]);
let outcome = repo.checkout_with("origin/main", false, &none).unwrap();
assert_eq!(outcome, CheckoutOutcome::LocalBranchAhead { local: "main".into(), remote: "origin/main".into() });
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("topic"));
// Rebase onto Remote keeps the local commit on top of origin/main.
let rebase = CheckoutOptions { diverged: Some(DivergedLocal::Rebase), ..Default::default() };
assert_eq!(repo.checkout_with("origin/main", false, &rebase).unwrap(), CheckoutOutcome::Done);
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("main"));
assert_eq!(fx.git(&["rev-parse", "main~1"]), fx.git(&["rev-parse", "origin/main"]));
assert_eq!(fx.git(&["log", "-1", "--format=%s"]), "local work");
// Drop Local Commits resets main to origin/main.
fx.git(&["checkout", "-q", "topic"]);
let reset = CheckoutOptions { diverged: Some(DivergedLocal::Reset), ..Default::default() };
assert_eq!(repo.checkout_with("origin/main", false, &reset).unwrap(), CheckoutOutcome::Done);
assert_eq!(fx.git(&["rev-parse", "main"]), fx.git(&["rev-parse", "origin/main"]));
assert_ne!(fx.git(&["rev-parse", "main"]), local_tip);
assert_eq!(repo.branch_info().unwrap().upstream.as_deref(), Some("origin/main"));
}
#[test]
fn delete_branch_without_asking_and_restore_it() {
let (fx, _bare) = with_remote();
let repo = fx.repo();
fx.git(&["checkout", "-q", "-b", "merged", "--track", "origin/main"]);
fx.git(&["checkout", "-q", "-b", "work"]);
fx.write("w.txt", "work\n");
let work_tip = fx.commit_all("work");
fx.git(&["checkout", "-q", "main"]);
let merged = repo.delete_branch_restorable("merged").unwrap();
assert!(!merged.unmerged);
assert_eq!(merged.upstream.as_deref(), Some("origin/main"));
let work = repo.delete_branch_restorable("work").unwrap();
assert!(work.unmerged, "not fully merged branches are force-deleted and reported");
assert_eq!(work.tip, work_tip);
assert!(!repo.branches().unwrap().iter().any(|b| b.name == "work"));
assert!(repo.delete_branch_restorable("main").is_err(), "the current branch cannot be deleted");
repo.restore_branch(&work).unwrap();
repo.restore_branch(&merged).unwrap();
let branches = repo.branches().unwrap();
let find = |n: &str| branches.iter().find(|b| !b.remote && b.name == n).unwrap().clone();
assert_eq!(find("work").commit, work_tip);
assert_eq!(find("merged").upstream.as_deref(), Some("origin/main"));
}
fn range(h: &LineHunk) -> HunkRange {
HunkRange { start: h.start, lines: h.lines, old_start: h.old_start, old_lines: h.old_lines }
}
#[test]
fn partial_commit_of_chosen_hunks_leaves_worktree_and_index_alone() {
let fx = Fixture::new();
let base = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
fx.write("f.txt", base);
fx.write("other.txt", "o\n");
fx.write("staged.txt", "s\n");
fx.commit_all("base");
let repo = fx.repo();
let edited = "A\nb\nc\nd\ne\nf\ng\nh\ni\nJ\nk\n";
fx.write("f.txt", edited);
fx.write("other.txt", "o2\n");
fx.write("staged.txt", "s2\n");
repo.stage(&[fx.path("other.txt")]).unwrap();
let hunks = diff_hunks(base, edited);
assert_eq!(hunks.len(), 2);
// Commit the first hunk of f.txt plus the staged other.txt; staged.txt is not staged.
let options = CommitOptions {
partial: vec![PartialCommitFile { path: fx.path("f.txt").into(), hunks: vec![range(&hunks[0])] }],
..Default::default()
};
repo.commit_with("partial", &options).unwrap();
assert_eq!(fx.git(&["show", "HEAD:f.txt"]), "A\nb\nc\nd\ne\nf\ng\nh\ni\nj");
assert_eq!(fx.git(&["show", "HEAD:other.txt"]), "o2");
assert_eq!(fx.git(&["show", "HEAD:staged.txt"]), "s");
// The working tree is untouched.
assert_eq!(fx.read("f.txt"), edited);
assert_eq!(fx.read("staged.txt"), "s2\n");
// The index matches the commit; the rest of f.txt is an unstaged change.
assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "");
let st = repo.status().unwrap();
assert_eq!(status_of(&st, "f.txt").unstaged, Some(ChangeKind::Modified));
assert_eq!(status_of(&st, "f.txt").staged, None);
assert!(!fx.dir.join(".git/openidea-partial-index").exists());
// A stale hunk (the file changed since the UI computed it) refuses to commit anything.
let before = fx.git(&["rev-parse", "HEAD"]);
let stale = CommitOptions {
partial: vec![PartialCommitFile { path: fx.path("f.txt").into(), hunks: vec![range(&hunks[0])] }],
..Default::default()
};
let err = repo.commit_with("stale", &stale).unwrap_err();
assert!(err.message.contains("changed"), "{}", err.message);
assert_eq!(fx.git(&["rev-parse", "HEAD"]), before);
assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "");
assert!(!fx.dir.join(".git/openidea-partial-index").exists());
// The remaining hunk commits on its own; then nothing is left.
let rest = diff_hunks(&repo.file_at(&fx.path("f.txt"), None).unwrap().unwrap(), edited);
assert_eq!(rest.len(), 1);
let options = CommitOptions {
partial: vec![PartialCommitFile { path: fx.path("f.txt").into(), hunks: vec![range(&rest[0])] }],
..Default::default()
};
repo.commit_with("rest", &options).unwrap();
assert_eq!(repo.file_at(&fx.path("f.txt"), None).unwrap().as_deref(), Some(edited));
assert!(repo.status().unwrap().iter().all(|f| f.rel_path != "f.txt"));
assert_eq!(fx.read("staged.txt"), "s2\n");
// A failing pre-commit hook leaves everything as it was.
fx.write("f.txt", "Z\nb\nc\nd\ne\nf\ng\nh\ni\nJ\nk\nl\n");
let hook = fx.dir.join(".git/hooks/pre-commit");
std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
std::fs::write(&hook, "#!/bin/sh\nexit 1\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let head = fx.git(&["rev-parse", "HEAD"]);
let hunks = diff_hunks(edited, &fx.read("f.txt"));
let options = CommitOptions {
run_hooks: true,
partial: vec![PartialCommitFile { path: fx.path("f.txt").into(), hunks: vec![range(&hunks[0])] }],
..Default::default()
};
assert!(repo.commit_with("hooked", &options).is_err());
assert_eq!(fx.git(&["rev-parse", "HEAD"]), head);
assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "");
assert_eq!(fx.read("f.txt"), "Z\nb\nc\nd\ne\nf\ng\nh\ni\nJ\nk\nl\n");
}
#[test]
fn partial_staging_with_built_patch() {
let fx = Fixture::new();
+4
View File
@@ -103,6 +103,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
nav::find_symbols,
nav::mark_recent,
nav::recent_files,
nav::remove_recent,
nav::web_goto,
nav::fs_resolve_reference,
nav::path_completions,
@@ -153,6 +154,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
vcs::vcs_commit,
vcs::vcs_checkout,
vcs::vcs_delete_branch,
vcs::vcs_restore_branch,
vcs::vcs_create_branch,
vcs::vcs_undo_commit,
vcs::vcs_rename_branch,
@@ -174,6 +176,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
vcs::vcs_create_tag,
vcs::vcs_tags,
vcs::vcs_diff_with_worktree,
vcs::vcs_diff_revisions,
vcs::vcs_create_patch,
vcs::vcs_patch_preview,
vcs::vcs_apply_patch,
@@ -190,6 +193,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
term::term_write,
term::term_resize,
term::term_kill,
term::term_is_busy,
term::term_default_shell,
term::term_available_shells,
run::run_configs,
+7
View File
@@ -40,6 +40,13 @@ pub fn mark_recent(window: Window, state: State<'_, AppState>, path: PathBuf) ->
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn remove_recent(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> {
state.project(window.label())?.index.remove_recent(&path);
Ok(())
}
/// Recently opened files, most recent first (⌘E).
#[tauri::command]
#[specta::specta]
+8
View File
@@ -48,6 +48,14 @@ pub fn term_kill(state: State<'_, AppState>, id: TerminalId) -> IdeResult<()> {
state.terminals.kill(id)
}
/// Whether the session is running a command (closing its tab asks first). A
/// `tcgetpgrp` or one process snapshot: async only to stay off the main thread.
#[tauri::command]
#[specta::specta]
pub async fn term_is_busy(state: State<'_, AppState>, id: TerminalId) -> IdeResult<bool> {
state.terminals.is_busy(id)
}
/// Shell used when Settings › Terminal › Shell path is empty.
#[tauri::command]
#[specta::specta]
+49 -23
View File
@@ -185,10 +185,15 @@ pub async fn vcs_checkout(
repo: Option<PathBuf>,
branch: String,
create: bool,
) -> IdeResult<()> {
options: Option<ide_vcs::CheckoutOptions>,
) -> IdeResult<ide_vcs::CheckoutOutcome> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git checkout"), |r| r.checkout(&branch, create)))
.await
let options = options.unwrap_or_default();
blocking(move || {
project
.with_repo_recorded(repo.as_deref(), Some("Git checkout"), |r| r.checkout_with(&branch, create, &options))
})
.await
}
/// New Branch: creates `name` at `start` (HEAD when absent), optionally checking it out.
@@ -231,10 +236,22 @@ pub async fn vcs_delete_branch(
state: State<'_, AppState>,
repo: Option<PathBuf>,
branch: String,
force: bool,
) -> IdeResult<ide_vcs::DeletedBranch> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_branch_restorable(&branch))).await
}
/// "Restore" of the branch-deleted notification.
#[tauri::command]
#[specta::specta]
pub async fn vcs_restore_branch(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
deleted: ide_vcs::DeletedBranch,
) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_branch(&branch, force))).await
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_branch(&deleted))).await
}
#[tauri::command]
@@ -336,11 +353,14 @@ pub enum RemoteOp {
DeleteBranch {
branch: String,
},
/// Pushes local `branch` (only up to `revision` when set) to its upstream.
/// Pushes local `branch` (only up to `revision` when set) to its upstream, or to
/// remote branch `target` of the same remote.
PushBranch {
branch: String,
revision: Option<String>,
force_with_lease: bool,
#[serde(default)]
target: Option<String>,
},
/// Fast-forwards local `branch` (not checked out) to its upstream.
UpdateBranch {
@@ -373,9 +393,12 @@ pub async fn vcs_remote(
r.push_with_tags(force_with_lease, set_upstream, tags, &cancel, &out)
}
RemoteOp::DeleteBranch { branch } => r.delete_remote_branch(&branch, &cancel, &out),
RemoteOp::PushBranch { branch, revision, force_with_lease } => {
r.push_branch(&branch, revision.as_deref(), force_with_lease, &cancel, &out)
}
RemoteOp::PushBranch { branch, revision, force_with_lease, target } => match target {
Some(target) => {
r.push_branch_to(&branch, revision.as_deref(), &target, force_with_lease, &cancel, &out)
}
None => r.push_branch(&branch, revision.as_deref(), force_with_lease, &cancel, &out),
},
RemoteOp::UpdateBranch { branch } => r.update_branch(&branch, &cancel, &out),
})
})
@@ -385,27 +408,16 @@ pub async fn vcs_remote(
result
}
#[derive(Serialize, Deserialize, Type)]
pub struct ConflictVersions {
pub base: Option<String>,
pub ours: Option<String>,
pub theirs: Option<String>,
}
/// The user's side is `ours` (sides swapped during a rebase, as in IDEA).
#[tauri::command]
#[specta::specta]
pub async fn vcs_conflict_versions(
window: Window,
state: State<'_, AppState>,
path: PathBuf,
) -> IdeResult<ConflictVersions> {
) -> IdeResult<ide_vcs::MergeVersions> {
let project = state.project(window.label())?;
blocking(move || {
project.with_repo_of(&path, |r| {
r.conflict_versions(&path).map(|(base, ours, theirs)| ConflictVersions { base, ours, theirs })
})
})
.await
blocking(move || project.with_repo_of(&path, |r| r.merge_versions(&path))).await
}
#[tauri::command]
@@ -622,6 +634,20 @@ pub async fn vcs_diff_with_worktree(
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
}
/// Files that differ between two revisions (the "N files updated" of Update Project).
#[tauri::command]
#[specta::specta]
pub async fn vcs_diff_revisions(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
from: String,
to: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = state.project(window.label())?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
}
/// Runs `f` once per repository with the `paths` that belong to it.
fn per_repo(
project: &Project,
+180 -21
View File
@@ -186,6 +186,7 @@ export const commands = {
markRecent: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("mark_recent", { path })),
/** Recently opened files, most recent first (⌘E). */
recentFiles: () => typedError<IpcPath[], IdeError>(__TAURI_INVOKE("recent_files")),
removeRecent: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remove_recent", { path })),
/** The web reference at `pos` of `doc` and its targets; `None` when there is no reference. */
webGoto: (doc: DocId, pos: Pos) => typedError<{
reference: WebReference,
@@ -290,8 +291,14 @@ export const commands = {
vcsStagePatch: (patch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stage_patch", { patch })),
vcsDiscard: (paths: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_discard", { paths })),
vcsCommit: (repo: string | null, message: string, options: CommitOptions) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_commit", { repo, message, options })),
vcsCheckout: (repo: string | null, branch: string, create: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_checkout", { repo, branch, create })),
vcsDeleteBranch: (repo: string | null, branch: string, force: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_delete_branch", { repo, branch, force })),
vcsCheckout: (repo: string | null, branch: string, create: boolean, options: {
mode?: CheckoutMode,
/** Required when checking out a remote branch whose local namesake is ahead of it. */
diverged?: DivergedLocal | null,
} | null) => typedError<CheckoutOutcome, IdeError>(__TAURI_INVOKE("vcs_checkout", { repo, branch, create, options })),
vcsDeleteBranch: (repo: string | null, branch: string) => typedError<DeletedBranch, IdeError>(__TAURI_INVOKE("vcs_delete_branch", { repo, branch })),
/** "Restore" of the branch-deleted notification. */
vcsRestoreBranch: (repo: string | null, deleted: DeletedBranch) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_restore_branch", { repo, deleted })),
/** New Branch: creates `name` at `start` (HEAD when absent), optionally checking it out. */
vcsCreateBranch: (repo: string | null, name: string, start: string | null, checkout: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_create_branch", { repo, name, start, checkout })),
/**
@@ -307,7 +314,8 @@ export const commands = {
vcsStashDrop: (repo: string | null, index: number) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_stash_drop", { repo, index })),
/** Network operation; progress lines stream through `on_output`. Cancellable. */
vcsRemote: (repo: string | null, request: RequestId, op: RemoteOp, onOutput: Channel<string>) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_remote", { repo, request, op, onOutput })),
vcsConflictVersions: (path: string) => typedError<ConflictVersions, IdeError>(__TAURI_INVOKE("vcs_conflict_versions", { path })),
/** The user's side is `ours` (sides swapped during a rebase, as in IDEA). */
vcsConflictVersions: (path: string) => typedError<MergeVersions, IdeError>(__TAURI_INVOKE("vcs_conflict_versions", { path })),
vcsDiffText: (path: string, staged: boolean) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_diff_text", { path, staged })),
/** Line hunks between two arbitrary texts (diff viewer, commit dialog). */
vcsDiffHunks: (oldText: string, newText: string) => __TAURI_INVOKE<LineHunk[]>("vcs_diff_hunks", { oldText, newText }),
@@ -336,6 +344,8 @@ export const commands = {
vcsTags: (repo: string | null) => typedError<string[], IdeError>(__TAURI_INVOKE("vcs_tags", { repo })),
/** Compare with Local: tracked files that differ between `rev` and the working tree. */
vcsDiffWithWorktree: (repo: string | null, rev: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_diff_with_worktree", { repo, rev })),
/** Files that differ between two revisions (the "N files updated" of Update Project). */
vcsDiffRevisions: (repo: string | null, from: string, to: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_diff_revisions", { repo, from, to })),
vcsCreatePatch: (source: PatchSource) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_create_patch", { source })),
vcsPatchPreview: (patch: string) => typedError<PatchFile[], IdeError>(__TAURI_INVOKE("vcs_patch_preview", { patch })),
vcsApplyPatch: (patch: string) => typedError<ApplyOutcome, IdeError>(__TAURI_INVOKE("vcs_apply_patch", { patch })),
@@ -358,14 +368,19 @@ export const commands = {
termWrite: (id: TerminalId, data: string) => typedError<null, IdeError>(__TAURI_INVOKE("term_write", { id, data })),
termResize: (id: TerminalId, cols: number, rows: number) => typedError<null, IdeError>(__TAURI_INVOKE("term_resize", { id, cols, rows })),
termKill: (id: TerminalId) => typedError<null, IdeError>(__TAURI_INVOKE("term_kill", { id })),
/**
* Whether the session is running a command (closing its tab asks first). A
* `tcgetpgrp` or one process snapshot: async only to stay off the main thread.
*/
termIsBusy: (id: TerminalId) => typedError<boolean, IdeError>(__TAURI_INVOKE("term_is_busy", { id })),
/** Shell used when Settings › Terminal › Shell path is empty. */
termDefaultShell: () => __TAURI_INVOKE<string>("term_default_shell"),
/** Installed shells for the Terminal's "New Predefined Session" menu. */
termAvailableShells: () => __TAURI_INVOKE<string[]>("term_available_shells"),
/** Detected configs followed by user-defined ones (user ids win on conflict). */
runConfigs: () => typedError<RunConfig[], IdeError>(__TAURI_INVOKE("run_configs")),
runSaveConfigs: (configs: RunConfig[]) => typedError<null, IdeError>(__TAURI_INVOKE("run_save_configs", { configs })),
runStart: (config: RunConfig, onOutput: Channel<OutputChunk>) => typedError<RunInfo, IdeError>(__TAURI_INVOKE("run_start", { config, onOutput })),
runConfigs: () => typedError<RunConfig_Serialize[], IdeError>(__TAURI_INVOKE("run_configs")),
runSaveConfigs: (configs: RunConfig_Deserialize[]) => typedError<null, IdeError>(__TAURI_INVOKE("run_save_configs", { configs })),
runStart: (config: RunConfig_Deserialize, onOutput: Channel<OutputChunk>) => typedError<RunInfo, IdeError>(__TAURI_INVOKE("run_start", { config, onOutput })),
runStop: (id: RunId) => typedError<null, IdeError>(__TAURI_INVOKE("run_stop", { id })),
runStdin: (id: RunId, data: string) => typedError<null, IdeError>(__TAURI_INVOKE("run_stdin", { id, data })),
runList: () => __TAURI_INVOKE<RunInfo[]>("run_list"),
@@ -521,7 +536,7 @@ export const commands = {
* How to run `config` with coverage: the command to launch and where its report will be,
* or which tool is missing.
*/
coveragePrepare: (config: RunConfig) => typedError<CoveragePrepared, IdeError>(__TAURI_INVOKE("coverage_prepare", { config })),
coveragePrepare: (config: RunConfig_Deserialize) => typedError<CoveragePrepared_Serialize, IdeError>(__TAURI_INVOKE("coverage_prepare", { config })),
/** Reads the report a coverage run left behind. */
coverageCollect: (report: CoverageReportSpec) => typedError<CoverageSuite, IdeError>(__TAURI_INVOKE("coverage_collect", { report })),
/** The browsers installed on this machine. */
@@ -531,7 +546,7 @@ export const commands = {
/** Opens `url` in `browser` (an id from `web_browsers`), or the default browser. */
webOpen: (url: string, browser: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("web_open", { url, browser })),
/** How to run `config` under a sampling profiler, or which profiler is missing. */
profilerPrepare: (config: RunConfig) => typedError<ProfilePrepared, IdeError>(__TAURI_INVOKE("profiler_prepare", { config })),
profilerPrepare: (config: RunConfig_Deserialize) => typedError<ProfilePrepared_Serialize, IdeError>(__TAURI_INVOKE("profiler_prepare", { config })),
/** Reads the profile a profiled run left behind. */
profilerCollect: (spec: ProfileSpec) => typedError<Profile, IdeError>(__TAURI_INVOKE("profiler_collect", { spec })),
/** Routes this window's plugin events to `on_event` (replaces an earlier subscription). */
@@ -975,6 +990,39 @@ export type ChangelistState = {
export type CheckStatus = "pending" | "success" | "failure" | "cancelled" | "skipped" | "neutral";
/**
* How Checkout treats local changes that the switch would overwrite (IDEA's Smart
* Checkout dialog).
*/
export type CheckoutMode =
/** Fail with [`CheckoutOutcome::LocalChanges`] and change nothing. */
"normal" |
/** Stash local changes, switch, and restore them (conflicts are left to resolve). */
"smart" |
/** Discard the local changes in the way (`git switch --discard-changes`). */
"force";
export type CheckoutOptions = {
mode?: CheckoutMode,
/** Required when checking out a remote branch whose local namesake is ahead of it. */
diverged?: DivergedLocal | null,
};
/**
* Result of [`Repo::checkout_with`]; the non-`Done` variants other than `Conflicts`
* left the repository untouched.
*/
export type CheckoutOutcome = { kind: "done" } |
/** These local changes (repository-relative paths) would be overwritten. */
{ kind: "localChanges"; files: string[] } |
/** Local branch `local` has commits that remote branch `remote` does not. */
{ kind: "localBranchAhead"; local: string; remote: string } |
/**
* Switched, but restoring the local changes (Smart Checkout) or the rebase stopped
* on conflicts in these files.
*/
{ kind: "conflicts"; files: string[] };
export type ClearCachesResult = {
/** Syntax trees dropped (they are rebuilt on demand). */
syntaxTrees: number,
@@ -1092,6 +1140,12 @@ export type CommitOptions = {
author: string | null,
/** False skips the pre-commit and commit-msg hooks (`--no-verify`). */
runHooks: boolean,
/**
* Files committed with only some of their changes (IDEA's partial commit). They are
* committed through a temporary index, so neither the working tree nor the other
* entries of the index change; afterwards their index entries match the new commit.
*/
partial?: PartialCommitFile[],
};
/** Version Control › Commit: commit checks and commit-message inspections. */
@@ -1163,12 +1217,6 @@ export type ComposeOp = "start" | "stop" | "restart" |
/** Stops and removes the project's containers and networks. */
"down";
export type ConflictVersions = {
base: string | null,
ours: string | null,
theirs: string | null,
};
/** Build, Execution, Deployment › Console. */
export type ConsoleSettings = {
softWrap?: boolean,
@@ -1182,9 +1230,15 @@ export type ConsoleSettings = {
export type ContainerOp = "start" | "stop" | "restart" | "pause" | "unpause" | "kill";
export type CoveragePrepared = { status: "ready"; runner: string;
export type CoveragePrepared = CoveragePrepared_Serialize | CoveragePrepared_Deserialize;
export type CoveragePrepared_Deserialize = ({ status: "ready"; runner: string;
/** What to launch instead of the configuration. */
launch: RunConfig; report: CoverageReportSpec } | { status: "missingTool"; tool: string; message: string; install: InstallCommand | null } | { status: "unsupported"; reason: string };
launch: RunConfig_Deserialize; report: CoverageReportSpec }) & { install?: never; message?: never; reason?: never; tool?: never } | ({ status: "missingTool"; tool: string; message: string; install: InstallCommand | null }) & { launch?: never; reason?: never; report?: never; runner?: never } | ({ status: "unsupported"; reason: string }) & { install?: never; launch?: never; message?: never; report?: never; runner?: never; tool?: never };
export type CoveragePrepared_Serialize = ({ status: "ready"; runner: string;
/** What to launch instead of the configuration. */
launch: RunConfig_Serialize; report: CoverageReportSpec }) & { install?: never; message?: never; reason?: never; tool?: never } | ({ status: "missingTool"; tool: string; message: string; install: InstallCommand | null }) & { launch?: never; reason?: never; report?: never; runner?: never } | ({ status: "unsupported"; reason: string }) & { install?: never; launch?: never; message?: never; report?: never; runner?: never; tool?: never };
/** Where the report of a prepared run will be, and how to read it. */
export type CoverageReportSpec =
@@ -1458,6 +1512,17 @@ export type DebuggerSettings = {
python?: PythonDebuggerSettings,
};
/** A deleted local branch, enough to restore it (IDEA's "Restore" notification action). */
export type DeletedBranch = {
name: string,
/** The commit the branch pointed at. */
tip: string,
/** Its upstream (`origin/x`), restored with it. */
upstream: string | null,
/** It was not fully merged: its own commits are now unreachable from any branch. */
unmerged: boolean,
};
export type Diagnostic = {
range: Range,
severity: Severity,
@@ -1477,6 +1542,13 @@ export type DirectoryHistory = {
deleted: IpcPath[],
};
/** Checking out remote `origin/x` while local `x` has commits `origin/x` lacks. */
export type DivergedLocal =
/** Rebase local `x` onto `origin/x` (IDEA "Rebase onto Remote"). */
"rebase" |
/** Reset local `x` to `origin/x` (IDEA "Drop Local Commits"). */
"reset";
/** An open document buffer. */
export type DocId = number;
@@ -2229,6 +2301,14 @@ export type HttpResponse = {
export type HunkKind = "added" | "modified" | "deleted";
/** A hunk identified by its line ranges (0-based), see [`LineHunk`]. */
export type HunkRange = {
start: number,
lines: number,
oldStart: number,
oldLines: number,
};
/**
* The single error type that crosses the IPC boundary.
*
@@ -2448,6 +2528,10 @@ export type LanguageCodeStyle_Deserialize = {
tabSize?: number | null,
indent?: number | null,
hardWrapAt?: number | null,
/** Comment Code › Line comment at first column. */
lineCommentAtFirstColumn?: boolean | null,
/** Comment Code › Add a space at line comment start. */
lineCommentAddSpace?: boolean | null,
};
/** Editor › Code Style › <Language> › Tabs and Indents (unset fields inherit). */
@@ -2456,6 +2540,10 @@ export type LanguageCodeStyle_Serialize = {
tabSize?: number | null,
indent?: number | null,
hardWrapAt?: number | null,
/** Comment Code › Line comment at first column. */
lineCommentAtFirstColumn?: boolean | null,
/** Comment Code › Add a space at line comment start. */
lineCommentAddSpace?: boolean | null,
};
/** Language identifier, e.g. `rust`, `typescript`, `kotlin`. Matches LSP `languageId`. */
@@ -2769,6 +2857,39 @@ export type MenuNode = { kind: "submenu"; id: string; label: string; children: M
/** A disabled placeholder such as "No Recent Projects". */
{ kind: "placeholder"; id: string; label: string } | { kind: "separator" };
/**
* Who stands on each side of the conflicts of the operation in progress (merge editor
* titles, IDEA's `GitDefaultMergeDialogCustomizer`).
*/
export type MergeSides = {
/** `merge`, `rebase`, `cherry-pick`, `revert`…; `None` outside an operation. */
operation: string | null,
/**
* Git's "ours" is not the user's work: during a rebase stage 2 holds the commits
* rebased onto and stage 3 the user's commit being replayed. [`MergeVersions`] is
* already swapped; conflict markers in the file are not.
*/
reversed: boolean,
/** The current branch (for a rebase: the branch being rebased). */
branch: string | null,
/**
* Short id of the commit being applied (REBASE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD)
* or merged (MERGE_HEAD).
*/
commit: string | null,
/** The other branch: the one being merged, or the one rebased onto. */
otherBranch: string | null,
};
/** The versions of a conflicted file as the merge editor shows them. */
export type MergeVersions = {
base: string | null,
/** The user's side ("Accept Yours"): git's stage 2, or stage 3 when `sides.reversed`. */
ours: string | null,
theirs: string | null,
sides: MergeSides,
};
/** Per-function totals (a recursive function's samples count once in `total`). */
export type MethodStat = {
frame: number,
@@ -2856,6 +2977,13 @@ export type OutputChunk = { kind: "stdout"; text: string } | { kind: "stderr"; t
/** System message from the IDE (command line echo, exit code). */
{ kind: "system"; text: string };
/** A file of a partial commit: which of its changes against HEAD to commit. */
export type PartialCommitFile = {
path: IpcPath,
/** The hunks to commit, as [`diff_hunks`] of the HEAD and on-disk texts reports them. */
hunks: HunkRange[],
};
/** One file of a patch (Apply Patch preview). */
export type PatchFile = {
/** Path relative to the repository root (after the change). */
@@ -3094,7 +3222,11 @@ export type ProfileNode = {
children: number[],
};
export type ProfilePrepared = { status: "ready"; profiler: string; launch: RunConfig; spec: ProfileSpec } | { status: "missingTool"; tool: string; message: string; install: ProfilerInstall | null; homepage: string | null } | { status: "unsupported"; reason: string };
export type ProfilePrepared = ProfilePrepared_Serialize | ProfilePrepared_Deserialize;
export type ProfilePrepared_Deserialize = ({ status: "ready"; profiler: string; launch: RunConfig_Deserialize; spec: ProfileSpec }) & { homepage?: never; install?: never; message?: never; reason?: never; tool?: never } | ({ status: "missingTool"; tool: string; message: string; install: ProfilerInstall | null; homepage: string | null }) & { launch?: never; profiler?: never; reason?: never; spec?: never } | ({ status: "unsupported"; reason: string }) & { homepage?: never; install?: never; launch?: never; message?: never; profiler?: never; spec?: never; tool?: never };
export type ProfilePrepared_Serialize = ({ status: "ready"; profiler: string; launch: RunConfig_Serialize; spec: ProfileSpec }) & { homepage?: never; install?: never; message?: never; reason?: never; tool?: never } | ({ status: "missingTool"; tool: string; message: string; install: ProfilerInstall | null; homepage: string | null }) & { launch?: never; profiler?: never; reason?: never; spec?: never } | ({ status: "unsupported"; reason: string }) & { homepage?: never; install?: never; launch?: never; message?: never; profiler?: never; spec?: never; tool?: never };
/** Where a prepared run's profile will be, and how to read it. */
export type ProfileSpec =
@@ -3266,8 +3398,11 @@ export type RemoteOp = { op: "fetch" } | { op: "pull"; rebase: boolean } | { op:
tags?: PushTags | null } |
/** Deletes a remote branch; `branch` is the remote-tracking name (`origin/feature`). */
{ op: "deleteBranch"; branch: string } |
/** Pushes local `branch` (only up to `revision` when set) to its upstream. */
{ op: "pushBranch"; branch: string; revision: string | null; force_with_lease: boolean } |
/**
* Pushes local `branch` (only up to `revision` when set) to its upstream, or to
* remote branch `target` of the same remote.
*/
{ op: "pushBranch"; branch: string; revision: string | null; force_with_lease: boolean; target?: string | null } |
/** Fast-forwards local `branch` (not checked out) to its upstream. */
{ op: "updateBranch"; branch: string };
@@ -3312,7 +3447,9 @@ export type ReviewEvent = "approve" | "requestChanges" | "comment";
export type RootKind = "source" | "test" | "resources" | "testResources" | "excluded";
export type RunConfig = {
export type RunConfig = RunConfig_Serialize | RunConfig_Deserialize;
export type RunConfig_Deserialize = {
/** Stable id, e.g. `npm:dev`, `cargo:run:app`, or a uuid for user-defined. */
id: string,
name: string,
@@ -3328,6 +3465,28 @@ export type RunConfig = {
isTest: boolean,
/** DAP adapter that can debug this config, if any. */
debugAdapter: string | null,
/** "Allow multiple instances": Run on a running config starts another one instead of restarting it. */
allowParallel?: boolean,
};
export type RunConfig_Serialize = {
/** Stable id, e.g. `npm:dev`, `cargo:run:app`, or a uuid for user-defined. */
id: string,
name: string,
/** `npm` | `cargo` | `gradle` | `maven` | `go` | `python` | `pytest` | `make` | `shell` … */
kind: string,
command: string,
args: string[],
cwd: IpcPath | null,
env: { [key in string]: string },
/** Detected configs are regenerated; user configs persist in `.openidea/run.toml`. */
detected: boolean,
/** Test runner configs render results in the test tree. */
isTest: boolean,
/** DAP adapter that can debug this config, if any. */
debugAdapter: string | null,
/** "Allow multiple instances": Run on a running config starts another one instead of restarting it. */
allowParallel?: boolean,
};
/** A process started from a run configuration. */
@@ -3595,7 +3754,7 @@ export type SmartKeysSettings = {
/** Reformat on paste: `none` | `indentBlock` | `indentEachLine`. */
reformatOnPaste?: string,
smartIndent?: boolean,
/** Backspace › Unindent: `nearest` indent position | `none`. */
/** Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`. */
backspaceUnindent?: string,
};
+52 -31
View File
@@ -1,8 +1,9 @@
/** Editor actions (ids from keymaps/intellij-macos.ts). */
import { EditorSelection, type EditorState } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { deleteLine, moveLineDown, moveLineUp, toggleBlockComment } from "@codemirror/commands";
import { addCursorAbove, addCursorBelow, deleteLine, moveLineDown, moveLineUp, toggleBlockComment } from "@codemirror/commands";
import { foldAll, syntaxTree, toggleFold, unfoldAll } from "@codemirror/language";
import { acceptCompletion, completionStatus } from "@codemirror/autocomplete";
import { executeAction, registerActions, type Action } from "@/platform/actions";
import { getContext } from "@/platform/context";
import { editorController, useGotoLine, useTabs } from "./controller";
@@ -26,6 +27,7 @@ import type { EditorSlot } from "./model/slot";
import { findAgain, openFindBar } from "./view/FindBar";
import { foldAtCaret, foldSelection, unfoldAtCaret } from "./cm/folding";
import { reverseLines, sortLines } from "./cm/lines";
import { cursorCodeBlockEnd, cursorCodeBlockStart, scrollToCenter, splitLine } from "./cm/caretCommands";
import { isColumnMode, toggleColumnMode } from "./cm/columnMode";
import { pasteAsPlainText, pasteFromHistory, unwrapAtCaret } from "./extras";
import { cyclicExpandWord } from "./wordCompletion";
@@ -67,7 +69,8 @@ function withView(run: (view: EditorView, slot: EditorSlot) => unknown) {
// --- extend / shrink selection ---------------------------------------------------
const stacks = new WeakMap<EditorSlot, SelectionStack>();
/** One stack per selection range (by index): ⌥↑ / ⌥↓ work on every caret. */
const stacks = new WeakMap<EditorSlot, SelectionStack[]>();
/** Syntax-tree candidates when the backend has nothing (or the language isn't parsed there). */
export function syntaxCandidates(state: EditorState, sel: SimpleRange): SimpleRange[] {
@@ -115,35 +118,40 @@ export function reusableChain(chain: BackendChain | undefined, counter: number,
const chains = new WeakMap<EditorSlot, BackendChain>();
/** Backend candidates around `current` (the cached chain for repeated extends of one range). */
async function backendCandidates(slot: EditorSlot, current: SimpleRange, cache: boolean): Promise<SimpleRange[]> {
const model = slot.model;
const cached = cache ? reusableChain(chains.get(slot), model.changeCounter, current) : null;
if (cached) return cached;
try {
await model.sync.whenIdle();
const counter = model.changeCounter;
const ranges = await model.manager.backend.selectionRanges(model.doc, current.from);
if (counter !== model.changeCounter || !slot.view) return [];
const candidates = rangesFromBackend(slot.view.state, ranges);
if (cache) chains.set(slot, { counter, pos: current.from, ranges: candidates });
return candidates;
} catch {
return []; // fall back to the syntax tree
}
}
async function extend(slot: EditorSlot) {
const view = slot.view;
if (!view) return;
const main = view.state.selection.main;
const current = { from: main.from, to: main.to };
let candidates: SimpleRange[] = [];
const model = slot.model;
const cached = reusableChain(chains.get(slot), model.changeCounter, current);
if (cached) {
candidates = cached;
} else {
try {
await model.sync.whenIdle();
const counter = model.changeCounter;
const ranges = await model.manager.backend.selectionRanges(model.doc, current.from);
if (counter === model.changeCounter && slot.view) {
candidates = rangesFromBackend(slot.view.state, ranges);
chains.set(slot, { counter, pos: current.from, ranges: candidates });
}
} catch {
// fall back to the syntax tree
}
}
const before = view.state.selection;
const currents = before.ranges.map((r) => ({ from: r.from, to: r.to }));
const candidates = await Promise.all(currents.map((c, i) => backendCandidates(slot, c, i === before.mainIndex)));
const v = slot.view;
if (!v || !v.state.selection.main.eq(main)) return;
const all = [...candidates, ...syntaxCandidates(v.state, current)];
const r = extendSelection(stacks.get(slot) ?? emptyStack(), current, all);
stacks.set(slot, r.state);
if (r.next) v.dispatch({ selection: EditorSelection.single(r.next.from, r.next.to), scrollIntoView: true, userEvent: "select.extend" });
if (!v || !v.state.selection.eq(before)) return;
const prev = stacks.get(slot) ?? [];
const results = currents.map((c, i) =>
extendSelection(prev[i] ?? emptyStack(), c, [...candidates[i]!, ...syntaxCandidates(v.state, c)]),
);
stacks.set(slot, results.map((r) => r.state));
if (!results.some((r) => r.next)) return;
const ranges = results.map((r, i) => (r.next ? EditorSelection.range(r.next.from, r.next.to) : before.ranges[i]!));
v.dispatch({ selection: EditorSelection.create(ranges, before.mainIndex), scrollIntoView: true, userEvent: "select.extend" });
}
/** Extend / shrink run in order: a quick ⌥↑⌥↑ extends twice although extending waits for the backend. */
@@ -158,10 +166,13 @@ function queueSelection(slot: EditorSlot, step: (slot: EditorSlot) => void | Pro
function shrink(slot: EditorSlot) {
const view = slot.view;
if (!view) return;
const main = view.state.selection.main;
const r = shrinkSelection(stacks.get(slot) ?? emptyStack(), { from: main.from, to: main.to });
stacks.set(slot, r.state);
if (r.next) view.dispatch({ selection: EditorSelection.single(r.next.from, r.next.to), scrollIntoView: true, userEvent: "select.shrink" });
const sel = view.state.selection;
const prev = stacks.get(slot) ?? [];
const results = sel.ranges.map((r, i) => shrinkSelection(prev[i] ?? emptyStack(), { from: r.from, to: r.to }));
stacks.set(slot, results.map((r) => r.state));
if (!results.some((r) => r.next)) return;
const ranges = results.map((r, i) => (r.next ? EditorSelection.range(r.next.from, r.next.to) : sel.ranges[i]!));
view.dispatch({ selection: EditorSelection.create(ranges, sel.mainIndex), scrollIntoView: true, userEvent: "select.shrink" });
}
// --- structural editing without a Lezer tree ------------------------------------------
@@ -221,8 +232,10 @@ function moveStatement(view: EditorView, slot: EditorSlot, up: boolean) {
* Complete Current Statement: tree-sitter knows what is missing where (and
* which multi-line statement the caret is in) for stream-mode languages, and
* places Java's `;` better than the line heuristics; the heuristics finish the line.
* With the completion lookup open, its selected item is inserted first (IDEA).
*/
function completeCurrentStatement(view: EditorView, slot: EditorSlot) {
if (completionStatus(view.state) === "active") acceptCompletion(view);
const language = slot.model.getMeta().language;
const sel = view.state.selection;
if ((hasLezerTree(view.state) && language !== "java") || sel.ranges.length > 1) return completeStatement(language)(view);
@@ -335,6 +348,12 @@ export function registerEditorActions(): () => void {
text("code.completeStatement", "Complete Current Statement", (v, slot) => completeCurrentStatement(v, slot)),
text("code.startNewLine", "Start New Line", (v) => startNewLine(v)),
text("code.startNewLineBefore", "Start New Line Before Current", (v) => startNewLineBefore(v)),
text("code.splitLine", "Split Line", (v) => splitLine(v)),
text("editor.scrollToCenter", "Scroll to Center", (v) => scrollToCenter(v), { category: "Editor Actions" }),
text("editor.codeBlockStart", "Move Caret to Code Block Start", (v) => cursorCodeBlockStart(v), { category: "Editor Actions" }),
text("editor.codeBlockEnd", "Move Caret to Code Block End", (v) => cursorCodeBlockEnd(v), { category: "Editor Actions" }),
text("editor.cloneCaretAbove", "Clone Caret Above", (v) => addCursorAbove(v), { category: "Editor Actions" }),
text("editor.cloneCaretBelow", "Clone Caret Below", (v) => addCursorBelow(v), { category: "Editor Actions" }),
text("code.joinLines", "Join Lines", (v) => joinLines(v)),
text("code.toggleCase", "Toggle Case", (v) => toggleCase(v), { keywords: ["upper", "lower"] }),
text("code.addCaretsToLineEnds", "Add Carets to Ends of Selected Lines", (v) => addCaretsToLineEnds(v), { category: "Edit" }),
@@ -543,6 +562,8 @@ export function registerEditorActions(): () => void {
category: "Edit",
icon: "copy",
when: "textEditorActive",
// ⇧⌘C / Ctrl+Shift+C stays with the terminal (copy) and other tool windows.
keyWhen: "editorFocus || !toolWindowFocus",
run: () => {
const p = ctrl.activePath();
if (p) void navigator.clipboard?.writeText(p);
+36
View File
@@ -0,0 +1,36 @@
/**
* CodeMirror's default keymap minus the bindings that clash with IntelliJ IDEA.
* Those keys belong to IDE actions instead (keymaps in `platform/keymaps/`):
* ⌘/Ctrl+Enter is Split Line, Ctrl+M / ⌃L Scroll to Center, Ctrl+[ / ] code
* block navigation, ⌥⌘↑↓ / Ctrl+Alt+↑↓ Previous / Next Occurrence.
*/
import type { KeyBinding } from "@codemirror/view";
import {
addCursorAbove,
addCursorBelow,
defaultKeymap,
historyKeymap,
indentLess,
indentMore,
insertBlankLine,
redo,
selectLine,
toggleTabFocusMode,
} from "@codemirror/commands";
const DROPPED = new Set<unknown>([
insertBlankLine, // Mod-Enter
toggleTabFocusMode, // Ctrl-m / Shift-Alt-m
selectLine, // Alt-l / Ctrl-l
addCursorAbove, // Mod-Alt-ArrowUp
addCursorBelow, // Mod-Alt-ArrowDown
indentLess, // Mod-[
indentMore, // Mod-]
]);
export const editorBaseKeymap: readonly KeyBinding[] = [
...defaultKeymap.filter((b) => !DROPPED.has(b.run)),
...historyKeymap,
// IDEA's Windows Redo is Ctrl+Shift+Z (Ctrl+Y is Delete Line); CodeMirror only binds it on macOS and Linux.
{ win: "Ctrl-Shift-z", run: redo, preventDefault: true },
];
+103
View File
@@ -0,0 +1,103 @@
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { indentService, type LanguageSupport } from "@codemirror/language";
import { languages } from "@codemirror/language-data";
import { addCursorAbove, insertBlankLine, selectLine, toggleTabFocusMode } from "@codemirror/commands";
import { codeBlockEnd, codeBlockStart, splitLine } from "./caretCommands";
import { editorBaseKeymap } from "./baseKeymap";
const views: EditorView[] = [];
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
/** One unit deeper after a line ending in `{`. */
const braceIndent = indentService.of((cx, pos) => {
const line = cx.lineAt(pos, -1);
const base = /^ */.exec(line.text)![0].length;
return line.text.slice(0, pos - line.from).trimEnd().endsWith("{") ? base + 2 : base;
});
/** `|` marks carets. */
function make(text: string) {
const carets: number[] = [];
let doc = "";
for (const ch of text) {
if (ch === "|") carets.push(doc.length);
else doc += ch;
}
const view = new EditorView({
state: EditorState.create({
doc,
selection: EditorSelection.create(carets.map((c) => EditorSelection.cursor(c))),
extensions: [EditorState.allowMultipleSelections.of(true), braceIndent],
}),
});
views.push(view);
return view;
}
function show(view: EditorView) {
const doc = view.state.doc.toString();
const heads = view.state.selection.ranges.map((r) => r.head).sort((a, b) => b - a);
return heads.reduce((s, h) => s.slice(0, h) + "|" + s.slice(h), doc);
}
describe("Split Line", () => {
it("breaks the line at each caret and leaves the caret before the break", () => {
const view = make("if (a) {|return 1; }");
splitLine(view);
expect(show(view)).toBe("if (a) {|\n return 1; }");
const multi = make("a|b\nc|d");
splitLine(multi);
expect(show(multi)).toBe("a|\nb\nc|\nd");
});
});
describe("code block navigation", () => {
let js: LanguageSupport;
beforeAll(async () => {
js = await languages.find((l) => l.name === "JavaScript")!.load();
});
const at = (text: string) => {
const pos = text.indexOf("|");
return { state: EditorState.create({ doc: text.replace("|", ""), extensions: js }), pos };
};
it("goes to just inside the enclosing braces", () => {
const { state, pos } = at("f() { if (x) { a|(); } b(); }");
expect(codeBlockStart(state, pos)).toBe("f() { if (x) {".length);
expect(codeBlockEnd(state, pos)).toBe("f() { if (x) { a(); ".length);
});
it("jumps across a brace at the caret to its match, and outward from inside one", () => {
const text = "f() { if (x) { a(); } b(); }";
const { state } = at(text);
const open = text.indexOf("{", 6);
const close = text.indexOf("}");
expect(codeBlockEnd(state, open)).toBe(close + 1); // |{ … } → { … }|
expect(codeBlockStart(state, close + 1)).toBe(open); // { … }| → |{ … }
expect(codeBlockStart(state, open + 1)).toBe(text.indexOf("{") + 1); // {| → outer block
expect(codeBlockEnd(state, close)).toBe(text.lastIndexOf("}")); // |} → outer block
});
it("skips braces in strings and comments", () => {
const { state, pos } = at('f() { const s = "{"; /* } */ a|(); }');
expect(codeBlockStart(state, pos)).toBe("f() {".length);
expect(codeBlockEnd(state, pos)).toBe('f() { const s = "{"; /* } */ a(); '.length);
});
it("has nowhere to go outside any block", () => {
const { state, pos } = at("a|();");
expect(codeBlockStart(state, pos)).toBeNull();
expect(codeBlockEnd(state, pos)).toBeNull();
});
});
describe("editor base keymap", () => {
it("drops CodeMirror defaults that IDEA uses for other actions", () => {
const runs = editorBaseKeymap.map((b) => b.run);
for (const cmd of [insertBlankLine, toggleTabFocusMode, selectLine, addCursorAbove]) expect(runs).not.toContain(cmd);
expect(editorBaseKeymap.some((b) => b.key === "Mod-Enter" || b.key === "Ctrl-m")).toBe(false);
expect(editorBaseKeymap.some((b) => b.win === "Ctrl-Shift-z")).toBe(true);
});
});
+92
View File
@@ -0,0 +1,92 @@
/** IDEA caret commands that CodeMirror has no equivalent for: Split Line, Scroll to Center, code block navigation. */
import { EditorSelection, type EditorState, type Transaction } from "@codemirror/state";
import { EditorView, type Command } from "@codemirror/view";
import { insertNewlineAndIndent } from "@codemirror/commands";
import { syntaxTree } from "@codemirror/language";
/** Split Line: break the line at each caret, indented, leaving the caret before the break. */
export const splitLine: Command = (view) => {
if (view.state.readOnly) return false;
const before = view.state.selection;
let tr: Transaction | null = null;
insertNewlineAndIndent({ state: view.state, dispatch: (t) => (tr = t) });
if (!tr) return false;
const changes = (tr as Transaction).changes;
const selection = EditorSelection.create(
before.ranges.map((r) => EditorSelection.cursor(changes.mapPos(r.from, -1))),
before.mainIndex,
);
view.dispatch({ changes, selection, scrollIntoView: true, userEvent: "input" });
return true;
};
/** Scroll to Center: put the caret line in the middle of the viewport. */
export const scrollToCenter: Command = (view) => {
view.dispatch({ effects: EditorView.scrollIntoView(view.state.selection.main.head, { y: "center" }) });
return true;
};
const NOT_CODE = /String|Comment|Regexp|Char|Template/;
/** `{` / `}` at `pos` when it is code (not inside a string or comment, as far as the syntax tree knows). */
function braceAt(state: EditorState, pos: number): "{" | "}" | null {
const ch = state.sliceDoc(pos, pos + 1);
if (ch !== "{" && ch !== "}") return null;
const tree = syntaxTree(state);
if (tree.length && NOT_CODE.test(tree.resolveInner(pos, 1).name)) return null;
return ch;
}
/**
* Where Move Caret to Code Block Start goes from `pos` (as IDEA):
* just after the enclosing `{`, or before the `{` matching a `}` the caret follows.
*/
export function codeBlockStart(state: EditorState, pos: number): number | null {
const from = pos - 1;
if (from < 0) return null;
const afterClose = braceAt(state, from) === "}";
let depth = afterClose ? -1 : 0;
for (let p = from; p >= 0; p--) {
const b = braceAt(state, p);
if (b === "{" && p !== from) {
if (depth === 0) return afterClose ? p : p + 1;
depth--;
} else if (b === "}") {
depth++;
}
}
return null;
}
/**
* Where Move Caret to Code Block End goes from `pos`: before the enclosing `}`,
* or after the `}` matching a `{` the caret precedes.
*/
export function codeBlockEnd(state: EditorState, pos: number): number | null {
const len = state.doc.length;
const beforeOpen = braceAt(state, pos) === "{";
let depth = beforeOpen ? -1 : 0;
for (let p = pos; p < len; p++) {
const b = braceAt(state, p);
if (b === "}" && p !== pos) {
if (depth === 0) return beforeOpen ? p + 1 : p;
depth--;
} else if (b === "{") {
depth++;
}
}
return null;
}
function moveToBlock(find: (state: EditorState, pos: number) => number | null): Command {
return (view) => {
const { state } = view;
const target = find(state, state.selection.main.head);
if (target == null) return false;
view.dispatch({ selection: EditorSelection.cursor(target), scrollIntoView: true, userEvent: "select.navigate" });
return true;
};
}
export const cursorCodeBlockStart = moveToBlock(codeBlockStart);
export const cursorCodeBlockEnd = moveToBlock(codeBlockEnd);
+263
View File
@@ -0,0 +1,263 @@
/**
* Comment with Line Comment and Enter inside a line comment, as in IDEA.
*/
import { EditorSelection, Facet, countColumn, type ChangeSpec, type EditorState, type SelectionRange } from "@codemirror/state";
import type { Command, EditorView } from "@codemirror/view";
import { indentString, syntaxTree } from "@codemirror/language";
import { toggleComment } from "@codemirror/commands";
/** Code Style › <Language> › Code Generation › Comment Code. */
export interface LineCommentStyle {
atFirstColumn: boolean;
addSpace: boolean;
}
export const lineCommentStyle = Facet.define<LineCommentStyle, LineCommentStyle>({
combine: (values) => values[0] ?? { atFirstColumn: true, addSpace: false },
});
function lineToken(state: EditorState, pos: number): string | undefined {
return state.languageDataAt<{ line?: string }>("commentTokens", pos)[0]?.line;
}
const leadingWs = (text: string) => /^[ \t]*/.exec(text)![0].length;
interface Block {
startLine: number;
endLine: number;
ranges: number[];
caretUpdate: "commentStart" | "down" | "restoreSelection" | null;
}
/** The view state right after the last ⌘/, to tell a repeated ⌘/ (IDEA's previous performed action). */
const afterComment = new WeakMap<EditorView, EditorState>();
/**
* ⌘/: comments the caret / selected lines (or uncomments when every line is
* commented). Without a selection the caret moves down — on an empty line it
* stays after the inserted prefix; with whole lines selected the selection
* then starts at its first line's start.
*/
export const commentLine: Command = (view) => {
const { state } = view;
if (state.readOnly) return false;
const doc = state.doc;
const style = state.facet(lineCommentStyle);
const repeated = afterComment.get(view) === state;
const blocks: Block[] = [];
const order = state.selection.ranges.map((r, i) => ({ r, i })).sort((a, b) => a.r.from - b.r.from);
for (const { r, i } of order) {
const startLine = doc.lineAt(r.from).number;
let endLine = doc.lineAt(r.to).number;
if (!r.empty && endLine > startLine && doc.line(endLine).from === r.to) endLine--;
const last = blocks[blocks.length - 1];
const block = last && startLine <= last.endLine + 1 ? last : null;
const target = block ?? { startLine, endLine, ranges: [], caretUpdate: null };
if (!block) blocks.push(target);
target.ranges.push(i);
target.endLine = Math.max(target.endLine, endLine);
const wholeLines = r.empty || (r.from === doc.lineAt(r.from).from && r.to === doc.lineAt(r.to).from && r.to > r.from);
const emptyLine = !doc.lineAt(r.from).text.trim();
target.caretUpdate = r.empty && emptyLine && !repeated ? "commentStart" : r.empty ? "down" : wholeLines ? "restoreSelection" : null;
}
const tokens = blocks.map((b) => lineToken(state, doc.line(b.startLine).from + leadingWs(doc.line(b.startLine).text)));
// Languages without line comments (CSS, HTML…): block comments.
if (tokens.some((t) => !t)) return toggleComment(view);
let allCommented = true;
let commentedNonEmpty = false;
blocks.forEach((b, bi) => {
const token = tokens[bi]!;
const single = b.startLine === b.endLine;
for (let n = b.startLine; n <= b.endLine; n++) {
const text = doc.line(n).text;
const commented = text.slice(leadingWs(text)).startsWith(token);
const empty = !text.trim();
if (commented && !empty) commentedNonEmpty = true;
if (!commented && (single || !empty)) allCommented = false;
}
if (allCommented && !single && !commentedNonEmpty) allCommented = false;
});
const changes: ChangeSpec[] = [];
blocks.forEach((b, bi) => {
const token = tokens[bi]!;
if (allCommented) {
for (let n = b.startLine; n <= b.endLine; n++) {
const line = doc.line(n);
const start = line.from + leadingWs(line.text);
if (!doc.sliceString(start, line.to).startsWith(token) || !line.text.trim()) continue;
let end = start + token.length;
if (style.addSpace && doc.sliceString(end, end + 1) === " ") end++;
// Nothing but whitespace left: the line is emptied.
if (!doc.sliceString(end, line.to).trim()) changes.push({ from: line.from, to: line.to });
else changes.push({ from: start, to: end });
}
return;
}
const commentStart = b.caretUpdate === "commentStart";
const prefixFor = (shifted: number, line: { to: number }) =>
token + (style.addSpace && (shifted < line.to || (commentStart && shifted < doc.length)) ? " " : "");
if (style.atFirstColumn) {
for (let n = b.startLine; n <= b.endLine; n++) {
const line = doc.line(n);
changes.push({ from: line.from, insert: prefixFor(line.from + leadingWs(line.text), line) });
}
return;
}
const min = minIndent(state, b.startLine, b.endLine, token);
for (let n = b.startLine; n <= b.endLine; n++) {
const line = doc.line(n);
let cols = 0;
let i = 0;
for (; i < line.length && cols < min; i++) {
const ch = line.text[i];
if (ch === " ") cols++;
else if (ch === "\t") cols += state.tabSize - (cols % state.tabSize);
else break;
}
const prefix = prefixFor(line.from + leadingWs(line.text), line);
if (cols >= min) changes.push({ from: line.from + i, insert: prefix });
else changes.push({ from: line.from, to: line.from + i, insert: indentString(state, min) + prefix });
}
});
const set = state.changes(changes);
const next = set.apply(doc);
const moveCarets = !blocks.some((b) => b.ranges.length > 1 && b.startLine !== b.endLine);
const ranges: SelectionRange[] = state.selection.ranges.map((r) => r.map(set));
for (const [bi, b] of blocks.entries()) {
if (!moveCarets || !b.caretUpdate) continue;
for (const i of b.ranges) {
const r = ranges[i]!;
if (b.caretUpdate === "commentStart") {
const line = next.line(b.startLine);
let pos = line.from + leadingWs(line.text) + tokens[bi]!.length;
pos += leadingWs(next.sliceString(pos, line.to));
ranges[i] = EditorSelection.cursor(Math.min(pos, next.length));
} else if (b.caretUpdate === "down") {
const line = next.lineAt(r.head);
if (line.number >= next.lines) continue;
const col = countColumn(next.sliceString(line.from, r.head), state.tabSize);
const below = next.line(line.number + 1);
ranges[i] = EditorSelection.cursor(below.from + offsetAtColumn(below.text, col, state.tabSize));
} else {
const from = next.lineAt(r.from).from;
ranges[i] = r.head >= r.anchor ? EditorSelection.range(from, r.to) : EditorSelection.range(r.to, from);
}
}
}
view.dispatch({
changes: set,
selection: EditorSelection.create(ranges, state.selection.mainIndex),
scrollIntoView: true,
userEvent: allCommented ? "delete.uncomment" : "input.comment",
});
afterComment.set(view, view.state);
return true;
};
function offsetAtColumn(text: string, col: number, tabSize: number): number {
let cols = 0;
for (let i = 0; i < text.length; i++) {
if (cols >= col) return i;
cols += text[i] === "\t" ? tabSize - (cols % tabSize) : 1;
}
return text.length;
}
/** Smallest indent of the non-blank lines, also no deeper than a line comment right above. */
function minIndent(state: EditorState, startLine: number, endLine: number, token: string): number {
const doc = state.doc;
const indentOf = (text: string) => countColumn(text.slice(0, leadingWs(text)), state.tabSize);
let min: number | null = null;
const scan = (from: number, to: number) => {
for (let n = from; n <= to; n++) {
const line = doc.line(n);
if (!line.text.trim() && n < doc.lines) continue;
const indent = indentOf(line.text);
min = min === null ? indent : Math.min(min, indent);
}
};
scan(startLine, endLine);
if (min === null && startLine === endLine && startLine < doc.lines) scan(startLine + 1, startLine + 1);
if (startLine > 1) {
const above = doc.line(startLine - 1).text;
if (above.slice(leadingWs(above)).startsWith(token)) min = Math.min(min ?? Infinity, indentOf(above));
}
return min ?? 0;
}
// --- Enter in a line comment ------------------------------------------------------
/** The start of the line comment the caret is in (after its prefix), else -1. */
function lineCommentStart(state: EditorState, pos: number, token: string): number {
if (pos < 1) return -1;
const tree = syntaxTree(state);
if (!tree.length) return -1;
const node = tree.resolveInner(pos, -1);
if (!/comment/i.test(node.type.name) || node.from >= pos) return -1;
const line = state.doc.lineAt(pos);
if (node.from < line.from || !state.sliceDoc(node.from, pos).startsWith(token)) return -1;
return node.from + token.length <= pos ? node.from : -1;
}
/**
* Enter inside a `//` comment with text after the caret: the rest moves to a
* new line that continues the comment (with the comment's own spacing).
*/
export const enterInLineComment: Command = (view) => {
const { state } = view;
if (state.readOnly || state.selection.ranges.some((r) => !r.empty)) return false;
const doc = state.doc;
const plans: Array<{ changes: ChangeSpec[]; caret: number }> = [];
for (const r of state.selection.ranges) {
const pos = r.head;
const token = lineToken(state, pos);
if (!token) return false;
const start = lineCommentStart(state, pos, token);
if (start < 0) return false;
const line = doc.lineAt(pos);
const offset = pos + leadingWs(doc.sliceString(pos, line.to));
if (offset >= line.to) return false;
const indent = line.text.slice(0, leadingWs(line.text));
const onlyComment = start === line.from + indent.length;
const br = state.lineBreak;
if (doc.sliceString(offset, offset + token.length) === token) {
// The rest is a comment already: break before it.
const after = offset + token.length;
const changes: ChangeSpec[] = [{ from: offset, insert: br + indent }];
if (after < line.to && doc.sliceString(after, after + 1) !== " ") changes.push({ from: after, insert: " " });
plans.push({ changes, caret: onlyComment ? token.length + 1 : 0 });
continue;
}
let spacing = " ";
let to = pos;
if (onlyComment) {
const own = /^[ \t]*/.exec(doc.sliceString(start + token.length, line.to))![0];
if (own) spacing = own;
to = offset;
} else if (doc.sliceString(pos, pos + 1) === " ") spacing = "";
plans.push({
changes: [{ from: pos, to, insert: br + indent + token + spacing }],
caret: onlyComment ? token.length + spacing.length : 0,
});
}
const tr = state.changeByRange((r) => {
const plan = plans[state.selection.ranges.indexOf(r)]!;
const set = state.changes(plan.changes);
// The caret lands on the new line, after its indent (and the prefix when the comment owns the line).
let lineStart = -1;
set.iterChanges((_fa, _ta, fromB, _tb, inserted) => {
const text = inserted.toString();
const at = text.indexOf(state.lineBreak);
if (lineStart < 0 && at >= 0) lineStart = fromB + at + state.lineBreak.length;
});
const indent = leadingWs(doc.lineAt(r.head).text);
return { changes: set, range: EditorSelection.cursor(lineStart + indent + plan.caret) };
});
view.dispatch(state.update(tr, { scrollIntoView: true, userEvent: "input" }));
return true;
};
+71 -12
View File
@@ -12,6 +12,8 @@ import {
unselectOccurrence,
} from "./editing";
import { toggleCaret } from "./multiCaret";
import { enterInLineComment, lineCommentStyle } from "./comments";
import { languages } from "@codemirror/language-data";
/** Brace languages in miniature: one unit deeper after a line ending in `{`. */
const javascript = () =>
@@ -108,18 +110,24 @@ describe("join lines", () => {
});
describe("toggle case", () => {
it("upper-cases mixed text and lower-cases upper-case text", () => {
it("lower-cases text with any upper-case letter, upper-cases the rest", () => {
const v = make("[fooBar]");
toggleCase(v);
expect(v.state.doc.toString()).toBe("FOOBAR");
toggleCase(v);
expect(v.state.doc.toString()).toBe("foobar");
toggleCase(v);
expect(v.state.doc.toString()).toBe("FOOBAR");
});
it("uses the word at the caret without a selection", () => {
it("selects the word at the caret without a selection", () => {
const v = make("one tw|o three");
toggleCase(v);
expect(show(v)).toBe("one TW|O three");
expect(show(v)).toBe("one [TWO] three");
});
it("decides once for all carets", () => {
const v = make("[abc] [Def]");
toggleCase(v);
expect(v.state.doc.toString()).toBe("abc def");
});
});
@@ -157,17 +165,68 @@ describe("multiple carets", () => {
describe("comment with line comment", () => {
const comments = EditorState.languageData.of(() => [{ commentTokens: { line: "//" } }]);
const indented = [comments, lineCommentStyle.of({ atFirstColumn: false, addSpace: true })];
it("moves the caret to the next line without a selection", () => {
const v = make("a|b\ncd\n", comments);
it("comments at the first column without a space and moves the caret down", () => {
const v = make(" a|b\n cd\n", comments);
commentLine(v);
expect(show(v)).toBe("// ab\nc|d\n");
expect(show(v)).toBe("// ab\n cd|\n");
});
it("keeps a selection where it is", () => {
const v = make("[ab\ncd]\n", comments);
it("moves every caret down", () => {
const v = make("a|\nb\nc|\nd\n", comments);
commentLine(v);
expect(v.state.doc.toString()).toBe("// ab\n// cd\n");
expect(v.state.doc.lineAt(v.state.selection.main.head).number).toBe(2);
expect(show(v)).toBe("//a\nb|\n//c\nd|\n");
});
it("keeps the caret after the prefix on an empty line", () => {
const v = make("f {\n |\n b", indented);
commentLine(v);
expect(show(v)).toBe("f {\n // |\n b");
const w = make("a\n|\nb", comments);
commentLine(w);
expect(show(w)).toBe("a\n//|\nb");
});
it("uncomments with or without the space", () => {
const v = make("[//a\n // b\n]c", comments);
commentLine(v);
expect(v.state.doc.toString()).toBe("a\n b\nc");
const w = make("[//a\n // b\n]c", indented);
commentLine(w);
expect(w.state.doc.toString()).toBe("a\n b\nc");
});
it("comments at the smallest indent of the lines when not at the first column", () => {
const v = make("[ a\n b\n\n c]", indented);
commentLine(v);
expect(v.state.doc.toString()).toBe(" // a\n // b\n //\n // c");
});
it("restores a whole-line selection from its first line's start", () => {
const v = make("x\n[ab\ncd\n]", comments);
commentLine(v);
expect(show(v)).toBe("x\n[//ab\n//cd\n]");
});
});
describe("enter in a line comment", async () => {
const ext = await languages.find((l) => l.name === "JavaScript")!.load();
it("continues the comment with its own spacing", () => {
const v = make(" // one |two", ext);
expect(enterInLineComment(v)).toBe(true);
expect(show(v)).toBe(" // one \n // |two");
});
it("splits a trailing comment onto a commented line", () => {
const v = make("f(); // one |two", ext);
enterInLineComment(v);
expect(show(v)).toBe("f(); // one \n|// two");
});
it("does nothing at the end of the comment or outside one", () => {
expect(enterInLineComment(make("// one|", ext))).toBe(false);
expect(enterInLineComment(make("f(|x)", ext))).toBe(false);
});
});
+35 -24
View File
@@ -9,7 +9,6 @@ import {
insertNewlineAndIndent,
moveLineDown,
moveLineUp,
toggleComment,
} from "@codemirror/commands";
import { selectNextOccurrence, selectSelectionMatches } from "@codemirror/search";
import { humpLeft, humpRight } from "../model/camelHumps";
@@ -286,22 +285,7 @@ export function selectRange(view: EditorView, range: SelectionRange | { from: nu
// --- comments ------------------------------------------------------------------------
/** ⌘/: toggles line comments; without a selection the caret then moves to the next line, as in IDEA. */
export const commentLine: Command = (view) => {
const before = view.state.selection;
const column = before.main.head - view.state.doc.lineAt(before.main.head).from;
if (!toggleComment(view)) return false;
const sel = view.state.selection;
if (before.ranges.length === 1 && before.main.empty && sel.ranges.length === 1) {
const line = view.state.doc.lineAt(sel.main.head);
if (line.number < view.state.doc.lines) {
const next = view.state.doc.line(line.number + 1);
const col = Math.min(column, next.length);
view.dispatch({ selection: EditorSelection.cursor(next.from + col), scrollIntoView: true, userEvent: "select" });
}
}
return true;
};
export { commentLine } from "./comments";
// --- smart keys ------------------------------------------------------------------
@@ -381,17 +365,44 @@ export const joinLines: Command = (view) => {
return true;
};
/** ⌘⇧U: Toggle Case of the selection (or the word at the caret). */
/** Escape sequences keep their case: `\n` must not become `\N`. */
function caseOf(state: EditorState, from: number, to: number, lower: boolean): string {
const text = state.sliceDoc(from, to);
const conv = (t: string) => (lower ? t.toLowerCase() : t.toUpperCase());
const keep: Array<[number, number]> = [];
syntaxTree(state).iterate({
from,
to,
enter: (n) => {
if (/Escape/.test(n.type.name)) keep.push([Math.max(n.from, from) - from, Math.min(n.to, to) - from]);
},
});
if (!keep.length) return conv(text);
let out = "";
let pos = 0;
for (const [a, b] of keep) {
if (a < pos) continue;
out += conv(text.slice(pos, a)) + text.slice(a, b);
pos = b;
}
return out + conv(text.slice(pos));
}
/**
* ⌘⇧U: Toggle Case of the selections (the word at a caret, which stays
* selected): lower case when any of them has an upper-case letter, else upper.
*/
export const toggleCase: Command = (view) => {
const { state } = view;
if (state.readOnly) return false;
const targets = state.selection.ranges.map((r) => (r.empty ? state.wordAt(r.head) : r));
const lower = targets.some((t) => t && state.sliceDoc(t.from, t.to) !== state.sliceDoc(t.from, t.to).toLowerCase());
const tr = state.changeByRange((r) => {
const range = r.empty ? state.wordAt(r.head) : r;
if (!range) return { range: r };
const text = state.sliceDoc(range.from, range.to);
// Mixed or lower case → upper; all upper → lower (as IDEA).
const insert = text === text.toUpperCase() ? text.toLowerCase() : text.toUpperCase();
return { changes: { from: range.from, to: range.to, insert }, range: r.empty ? r : EditorSelection.range(r.anchor, r.head) };
const t = targets[state.selection.ranges.indexOf(r)];
if (!t) return { range: r };
const insert = caseOf(state, t.from, t.to, lower);
const range = r.empty ? EditorSelection.range(t.from, t.from + insert.length) : EditorSelection.range(r.anchor, r.head);
return { changes: { from: t.from, to: t.to, insert }, range };
});
view.dispatch(state.update(tr, { userEvent: "input.case" }));
return true;
+10 -18
View File
@@ -26,6 +26,8 @@ import {
import type { EditorExtras } from "@/platform/editorSettings";
import { openideaHighlightStyle } from "./highlight";
import { cursorHumpLeft, cursorHumpRight, deleteHumpBackward, deleteHumpForward, selectHumpLeft, selectHumpRight } from "./editing";
import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./comments";
import { smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys";
// --- soft wraps ---------------------------------------------------------------
@@ -335,23 +337,6 @@ const centerCaret = EditorState.transactionExtender.of((tr) => {
// --- smart keys ----------------------------------------------------------------
const CLOSERS = new Set([")", "]", "}", '"', "'", "`"]);
const tabOut = keymap.of([
{
key: "Tab",
run: (view) => {
const sel = view.state.selection;
if (sel.ranges.some((r) => !r.empty)) return false;
const next = (r: { head: number }) => view.state.sliceDoc(r.head, r.head + 1);
if (!sel.ranges.every((r) => CLOSERS.has(next(r)) && !/^\s*$/.test(view.state.doc.lineAt(r.head).text.slice(0, r.head - view.state.doc.lineAt(r.head).from))))
return false;
view.dispatch({ selection: EditorSelection.create(sel.ranges.map((r) => EditorSelection.cursor(r.head + 1)), sel.mainIndex), userEvent: "select" });
return true;
},
},
]);
const OPENERS: Record<string, true> = { "(": true, "[": true, "{": true, '"': true, "'": true, "`": true };
/** Typing a bracket / quote over a selection replaces it instead of wrapping it. */
@@ -379,6 +364,10 @@ export interface PrefsOptions {
readOnly: boolean;
/** Distraction Free Mode: no right margin / visual guides beside the centred column. */
distractionFree?: boolean;
/** Language id of the document (per-language smart-key behaviour). */
language?: string;
/** Code Style › Comment Code of the document's language. */
commentStyle?: LineCommentStyle;
}
export function caretExtension(prefs: EditorExtras): Extension {
@@ -414,7 +403,8 @@ export function prefsExtension(o: PrefsOptions): Extension {
const brackets = [...(k.pairBrackets ? ["(", "[", "{"] : []), ...(k.pairQuotes ? ["'", '"'] : [])];
out.push(Prec.highest(EditorState.languageData.of(() => [{ closeBrackets: { brackets } }])));
if (!k.surroundSelection) out.push(Prec.highest(noSurround));
if (k.tabOutOfPair) out.push(Prec.highest(tabOut));
if (k.tabOutOfPair) out.push(tabOut);
if (o.commentStyle) out.push(lineCommentStyle.of(o.commentStyle));
const keys = [];
if (k.camelHumps)
@@ -429,8 +419,10 @@ export function prefsExtension(o: PrefsOptions): Extension {
{ key: "Home", run: cursorLineStart, shift: selectLineStart, preventDefault: true },
{ mac: "Mod-ArrowLeft", run: cursorLineStart, shift: selectLineStart, preventDefault: true },
);
if (!o.readOnly) keys.push({ key: "Enter", run: enterInLineComment });
if (!k.smartIndent) keys.push({ key: "Enter", run: insertNewlineKeepIndent });
if (k.backspaceUnindent === "none") keys.push({ key: "Backspace", run: deleteCharBackwardStrict });
else if (smartBackspaceApplies(k.backspaceUnindent, o.language)) keys.push({ key: "Backspace", run: smartBackspace });
if (keys.length) out.push(Prec.high(keymap.of(keys)));
return out;
}
+8 -3
View File
@@ -20,12 +20,12 @@ import {
type LanguageSupport,
type MatchResult,
} from "@codemirror/language";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { history, indentWithTab } from "@codemirror/commands";
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
import { highlightSelectionMatches } from "@codemirror/search";
import type { EditorConfigProps } from "@/bindings";
import type { EditorSettings } from "@/platform/ipc";
import { editorPrefs, type EditorExtras } from "@/platform/editorSettings";
import { editorPrefs, lineCommentStyleFor, type EditorExtras } from "@/platform/editorSettings";
import { editorContributions, type EditorDocContext } from "@/editor/api";
import { distractionFreeWidth, isDistractionFree } from "@/workbench/distractionFree";
import { openideaHighlightStyle } from "./highlight";
@@ -42,6 +42,8 @@ import { caretExtension, fontZoom, methodLinesField, prefsExtension, softWrapAct
import { commentTokens } from "./languages";
import { multiCaretMouse } from "./multiCaret";
import { columnModeExtension } from "./columnMode";
import { tabKeymap } from "./smartKeys";
import { editorBaseKeymap } from "./baseKeymap";
import { clipboardHistoryExtension } from "../clipboardHistory";
import type { LanguageInfo } from "../backend";
@@ -241,6 +243,8 @@ export function prefsFor(o: {
hardWrapAt: hardWrapAt(o.settings, o.info),
readOnly: o.readOnly,
distractionFree: isDistractionFree(),
language: o.info?.id,
commentStyle: lineCommentStyleFor(prefsOf(o.settings), o.info?.id),
});
}
@@ -298,7 +302,8 @@ export function baseExtensions(o: SetupOptions): Extension {
overviewLane,
codeLensExtension,
EditorView.contentAttributes.of({ spellcheck: "false", autocorrect: "off", autocapitalize: "off", "aria-label": `Editor for ${o.ctx.path}` }),
keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...historyKeymap, indentWithTab]),
tabKeymap,
keymap.of([...closeBracketsKeymap, ...editorBaseKeymap, indentWithTab]),
compartments.language.of(o.language ?? []),
compartments.comments.of(commentTokens(o.info)),
compartments.indent.of(indentExtension(o.indent ?? effectiveIndent(o.settings, o.info))),
+152
View File
@@ -0,0 +1,152 @@
import { afterEach, describe, expect, it } from "vitest";
import { EditorSelection, EditorState, type Extension } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { indentService, indentUnit } from "@codemirror/language";
import { closeBrackets, insertBracket } from "@codemirror/autocomplete";
import { autoIndentBackspace, insertTab, smartBackspace, smartBackspaceApplies, tabOutOfScope, tabOutScopes } from "./smartKeys";
/** One unit (2) deeper after a (non-blank) line ending in `{`, one less on a line starting with `}`. */
const braces = indentService.of((cx, pos) => {
let prev = cx.lineAt(pos, -1);
do {
if (prev.from === 0) return 0;
prev = cx.lineAt(prev.from - 1, -1);
} while (!prev.text.trim());
const base = /^ */.exec(prev.text)![0].length;
const own = cx.state.doc.lineAt(pos).text;
return Math.max(0, base + (prev.text.trimEnd().endsWith("{") ? 2 : 0) - (/^\s*}/.test(own) ? 2 : 0));
});
const views: EditorView[] = [];
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
/** `|` marks carets, `[`/`]` a selection. */
function make(text: string, extensions: Extension = [], unit = " ") {
const ranges: ReturnType<typeof EditorSelection.cursor>[] = [];
let doc = "";
let anchor = -1;
for (const ch of text) {
if (ch === "|") ranges.push(EditorSelection.cursor(doc.length));
else if (ch === "[") anchor = doc.length;
else if (ch === "]") ranges.push(EditorSelection.range(anchor, doc.length));
else doc += ch;
}
const view = new EditorView({
state: EditorState.create({
doc,
selection: EditorSelection.create(ranges.length ? ranges : [EditorSelection.cursor(0)]),
extensions: [EditorState.allowMultipleSelections.of(true), EditorState.tabSize.of(4), indentUnit.of(unit), extensions],
}),
parent: document.body,
});
views.push(view);
return view;
}
function show(view: EditorView): string {
const { doc, selection } = view.state;
let out = doc.toString();
for (const r of [...selection.ranges].sort((a, b) => b.from - a.from))
out = r.empty ? out.slice(0, r.head) + "|" + out.slice(r.head) : out.slice(0, r.from) + "[" + out.slice(r.from, r.to) + "]" + out.slice(r.to);
return out;
}
describe("tab", () => {
it("inserts whitespace up to the next indent stop at each caret", () => {
const v = make("ab|c\n|x");
insertTab(v);
expect(show(v)).toBe("ab |c\n |x");
});
it("inserts a tab character when the code style uses tabs", () => {
const v = make("a|b", [], "\t");
insertTab(v);
expect(show(v)).toBe("a\t|b");
});
it("indents the lines of a selection", () => {
const v = make("[a\nb]");
insertTab(v);
expect(v.state.doc.toString()).toBe(" a\n b");
});
it("replaces a whitespace-only selection like a caret", () => {
const v = make("a[ ]b");
insertTab(v);
expect(show(v)).toBe("a |b");
});
});
describe("smart backspace", () => {
it("applies in 'proper indent' mode except in indentation-based languages", () => {
expect(smartBackspaceApplies("autoindent", "java")).toBe(true);
expect(smartBackspaceApplies("autoindent", "python")).toBe(false);
expect(smartBackspaceApplies("nearest", "java")).toBe(false);
});
it("goes back to the proper indent from deeper whitespace", () => {
const v = make("f {\n |x\n}", braces);
smartBackspace(v);
expect(show(v)).toBe("f {\n |x\n}");
});
it("joins with the line above at the proper indent", () => {
const v = make("f {\n a\n |b\n}", braces);
smartBackspace(v);
expect(show(v)).toBe("f {\n a |b\n}");
});
it("moves text up over a blank line at its proper indent", () => {
const v = make("f {\n a\n\n |b\n}", braces);
smartBackspace(v);
expect(show(v)).toBe("f {\n a\n |b\n}");
});
it("joins an empty line without leaving a space", () => {
const v = make("f {\n a\n |\n}", braces);
smartBackspace(v);
expect(show(v)).toBe("f {\n a|\n}");
});
it("leaves Backspace after text to the default", () => {
const v = make("f {\n a|b\n}", braces);
expect(autoIndentBackspace(v.state, v.state.selection.main.head)).toBeNull();
expect(smartBackspace(v)).toBe(false);
});
});
describe("tab out of pairs", () => {
const ext = [closeBrackets(), tabOutScopes];
const type = (v: EditorView, ch: string) => {
const tr = insertBracket(v.state, ch);
if (tr) v.dispatch(tr);
else v.dispatch(v.state.update(v.state.replaceSelection(ch), { userEvent: "input.type" }));
};
it("steps over a closing bracket the editor inserted", () => {
const v = make("f|", ext);
type(v, "(");
type(v, "x");
expect(show(v)).toBe("f(x|)");
expect(insertTab(v)).toBe(true);
expect(show(v)).toBe("f(x)|");
// Once out, Tab inserts whitespace again.
insertTab(v);
expect(show(v)).toBe("f(x) |");
});
it("ignores closers the user typed", () => {
const v = make("f(x|)", ext);
expect(tabOutOfScope(v)).toBe(false);
});
it("keeps the pair through edits elsewhere while the caret is inside, else forgets it", () => {
const v = make("a f|", ext);
type(v, "(");
v.dispatch({ changes: { from: 0, to: 1, insert: "bb" } });
expect(show(v)).toBe("bb f(|)");
v.dispatch({ selection: { anchor: 0 } });
v.dispatch({ changes: { from: 0, insert: "c" }, selection: { anchor: 6 } });
expect(tabOutOfScope(v)).toBe(false);
});
});
+144
View File
@@ -0,0 +1,144 @@
/**
* IDEA-style Tab, smart Backspace and Tab out of auto-inserted brackets and
* quotes.
*/
import { EditorSelection, StateEffect, countColumn, StateField, type EditorState, type Extension, type Transaction } from "@codemirror/state";
import { keymap, type Command } from "@codemirror/view";
import { getIndentUnit, getIndentation, indentString, indentUnit } from "@codemirror/language";
import { indentMore } from "@codemirror/commands";
// --- Tab -------------------------------------------------------------------------------
/**
* Tab: steps out of an auto-inserted pair (when enabled); with a selection
* holding more than whitespace, indents the selected lines; otherwise inserts
* whitespace at each caret up to the next indent stop (a tab character when
* the code style uses tabs). Live templates / Emmet get Tab first.
*/
export const insertTab: Command = (view) => {
const { state } = view;
if (state.readOnly) return false;
if (tabOutOfScope(view)) return true;
if (state.selection.ranges.some((r) => !r.empty && state.sliceDoc(r.from, r.to).trim())) return indentMore(view);
const useTab = state.facet(indentUnit) === "\t";
const size = Math.max(1, getIndentUnit(state));
const tr = state.changeByRange((r) => {
const line = state.doc.lineAt(r.from);
const col = countColumn(line.text.slice(0, r.from - line.from), state.tabSize);
const insert = useTab ? "\t" : " ".repeat(size - (col % size));
return { changes: { from: r.from, to: r.to, insert }, range: EditorSelection.cursor(r.from + insert.length) };
});
view.dispatch(state.update(tr, { scrollIntoView: true, userEvent: "input" }));
return true;
};
/** Tab ahead of the default `indentWithTab` (⇧Tab stays Unindent). */
export const tabKeymap: Extension = keymap.of([{ key: "Tab", run: insertTab }]);
// --- Backspace -------------------------------------------------------------------------------
/** Languages whose layout is indentation: IDEA keeps "to nearest indent position" there. */
const NEAREST_INDENT_LANGS = new Set(["python", "yaml", "shellscript"]);
export function smartBackspaceApplies(mode: string, language: string | undefined): boolean {
return mode === "autoindent" && !NEAREST_INDENT_LANGS.has(language ?? "");
}
/** The change one Backspace makes at `pos` in "To proper indent position" mode, or null to delete normally. */
export function autoIndentBackspace(state: EditorState, pos: number): { from: number; to: number; insert: string } | null {
const doc = state.doc;
const line = doc.lineAt(pos);
if (/[^ \t]/.test(doc.sliceString(line.from, pos))) return null;
const target = getIndentation(state, line.from);
if (target === null) return null;
const end = pos + /^[ \t]*/.exec(doc.sliceString(pos, line.to))![0].length;
const current = countColumn(doc.sliceString(line.from, end), state.tabSize);
if (current > target) return { from: line.from, to: end, insert: indentString(state, target) };
if (line.number === 1) return end > 0 ? { from: 0, to: end, insert: "" } : null;
const prev = doc.line(line.number - 1);
const prevEnd = prev.from + prev.text.trimEnd().length;
if (prevEnd === prev.from && prevEnd !== 0) {
// A blank line above: the text moves up, at its proper indent.
return { from: prevEnd, to: end, insert: indentString(state, target) };
}
const before = doc.sliceString(prev.from, prevEnd);
const after = doc.sliceString(end, line.to);
const space = !after || !before || /[([{]$/.test(before) || /^[)\]}.,;]/.test(after) ? "" : " ";
return { from: prevEnd, to: end, insert: space };
}
/** Backspace in leading whitespace: to the proper indent, or joined with the line above when already there. */
export const smartBackspace: Command = (view) => {
const { state } = view;
if (state.readOnly || state.selection.ranges.some((r) => !r.empty)) return false;
const plans = state.selection.ranges.map((r) => autoIndentBackspace(state, r.head));
if (plans.some((p) => !p)) return false;
const tr = state.changeByRange((r) => {
const p = plans[state.selection.ranges.indexOf(r)]!;
return { changes: p, range: EditorSelection.cursor(p.from + p.insert.length) };
});
view.dispatch(state.update(tr, { scrollIntoView: true, userEvent: "delete.backward" }));
return true;
};
// --- Tab out of brackets / quotes ------------------------------------------------------------
const PAIRS: Record<string, string> = { "(": ")", "[": "]", "{": "}", '"': '"', "'": "'", "`": "`" };
interface Scope {
from: number;
to: number;
}
const leaveScopes = StateEffect.define<readonly number[]>();
/** Inside each pair the editor inserted itself; Tab at a scope's end steps over the closing character. */
export const tabOutScopes = StateField.define<readonly Scope[]>({
create: () => [],
update(scopes, tr) {
for (const e of tr.effects) if (e.is(leaveScopes)) scopes = scopes.filter((s) => !e.value.includes(s.to));
if (!tr.docChanged) return scopes;
const caret = tr.startState.selection.main.head;
let next: Scope[] = scopes.filter((s) => {
let keep = true;
tr.changes.iterChangedRanges((fromA, toA) => {
const outside = fromA < s.from || toA > s.to;
if (outside && (caret < s.from || caret > s.to || (toA >= s.from && fromA <= s.to))) keep = false;
});
return keep;
});
next = next.map((s) => ({ from: tr.changes.mapPos(s.from, -1), to: tr.changes.mapPos(s.to, 1) }));
return [...next, ...insertedPairs(tr)];
},
});
/** Empty scopes between the bracket / quote pairs a typing transaction auto-inserted. */
function insertedPairs(tr: Transaction): Scope[] {
if (!tr.isUserEvent("input.type")) return [];
const out: Scope[] = [];
tr.changes.iterChanges((_fa, _ta, fromB, toB, inserted) => {
const text = inserted.toString();
if (toB - fromB !== 2 || PAIRS[text[0]!] !== text[1]) return;
const inside = fromB + 1;
if (tr.newSelection.ranges.some((r) => r.empty && r.head === inside)) out.push({ from: inside, to: inside });
});
return out;
}
export const tabOutOfScope: Command = (view) => {
const { state } = view;
const scopes = state.field(tabOutScopes, false);
if (!scopes?.length || state.selection.ranges.some((r) => !r.empty || !scopes.some((s) => s.to === r.head))) return false;
view.dispatch({
selection: EditorSelection.create(
state.selection.ranges.map((r) => EditorSelection.cursor(Math.min(r.head + 1, state.doc.length))),
state.selection.mainIndex,
),
effects: leaveScopes.of(state.selection.ranges.map((r) => r.head)),
userEvent: "select",
});
return true;
};
/** Settings › Smart Keys › "Jump outside closing bracket/quote with Tab" (Tab itself is `insertTab`). */
export const tabOut: Extension = tabOutScopes;
+32 -5
View File
@@ -404,7 +404,7 @@ export class EditorController implements SlotHost {
path: slot?.model.path ?? nonTextTab?.input.path ?? null,
doc: slot?.model.doc ?? null,
});
if (slot) this.recordNav(slot);
if (slot) this.recordNav(slot, true);
}
this.scheduleStatus();
}
@@ -431,8 +431,13 @@ export class EditorController implements SlotHost {
if (this.autoSave()) void slot.model.save("auto").catch(() => {});
}
onSelection(slot: EditorSlot) {
onSelection(slot: EditorSlot, navigatedFrom?: { line: number; col: number }) {
if (slot !== this.activeSlot) return;
if (navigatedFrom && this.navSuppress === 0 && !slot.disposed) {
// The place left behind is where the caret was right before the navigation.
this.nav.update({ path: slot.model.path, ...navigatedFrom });
this.recordNav(slot, true);
}
// The status bar reflects the view state synchronously (programmatic and
// synthetic selection changes included); events / history are throttled.
this.publishStatus(slot);
@@ -661,10 +666,11 @@ export class EditorController implements SlotHost {
// --- navigation history --------------------------------------------------------------
private recordNav(slot: EditorSlot) {
/** `navigation`: the caret got here by a navigation (see NavigationHistory.update). */
private recordNav(slot: EditorSlot, navigation = false) {
if (this.navSuppress > 0 || slot.disposed) return;
const c = slot.caret();
this.nav.update({ path: slot.model.path, line: c.line, col: c.col });
this.nav.update({ path: slot.model.path, line: c.line, col: c.col }, navigation);
}
async navigateTo(loc: NavigationLocation | null) {
@@ -804,7 +810,7 @@ export class EditorController implements SlotHost {
}
} finally {
this.navSuppress--;
if (this.navSuppress === 0 && this.activeSlot) this.recordNav(this.activeSlot);
if (this.navSuppress === 0 && this.activeSlot) this.recordNav(this.activeSlot, true);
}
}
@@ -872,6 +878,10 @@ export class EditorController implements SlotHost {
togglePin(paneId: string, tabId: string) {
this.apply((s) => togglePin(s, paneId, tabId));
}
/** Turns a preview tab into a regular one (double-click on the tab). */
keepTab(paneId: string, tabId: string) {
this.apply((s) => (s.panes[paneId] ? promoteTab(s, paneId, tabId) : s));
}
/** Drag & drop; the tab keeps its id (and slot) when moved within a pane. */
moveTab(fromPane: string, tabId: string, toPane: string, index: number) {
this.apply((s) => moveTab(s, fromPane, tabId, toPane, index));
@@ -976,6 +986,23 @@ export class EditorController implements SlotHost {
pane?.querySelector<HTMLElement>("[data-editor-focus], .cm-content")?.focus({ preventScroll: true });
}
openPaths(): string[] {
const s = useTabs.getState();
const out = new Set<string>();
for (const id of paneIds(s)) for (const t of s.panes[id]?.tabs ?? []) if (t.input.path) out.add(t.input.path);
return [...out];
}
closePath(path: string) {
this.apply((s) => {
const keys = new Set<string>();
for (const pane of Object.values(s.panes)) for (const t of pane.tabs) if (t.input.path === path) keys.add(t.input.key);
let next = s;
for (const key of keys) next = closeKey(next, key);
return next;
});
}
activePath(): string | null {
return this.activeSlot?.model.path ?? this.activeTabOf()?.input.path ?? null;
}
+2
View File
@@ -37,6 +37,8 @@ export function activate(): void | (() => void) {
activeCursor: () => ctrl.activeCursor(),
saveAll: () => ctrl.saveAll(),
focus: () => ctrl.focus(),
openPaths: () => ctrl.openPaths(),
closePath: (path) => ctrl.closePath(path),
});
_provideDocumentAttacher((path) => attachDocument(ctrl.manager, path));
const disposers = [
+62 -8
View File
@@ -16,6 +16,7 @@ import {
promoteTab,
reopenClosed,
splitPane,
tabOptions,
togglePin,
type TabsState,
} from "./tabs";
@@ -94,14 +95,46 @@ describe("tab model", () => {
expect(labels(s)).toEqual(["a^", "d"]);
});
it("activates the most recently used tab when closing the active one", () => {
it("activates the left neighbour when closing the active tab (IDEA default)", () => {
let s = initialTabsState();
for (const p of ["a", "b", "c"]) s = open(s, p);
s = openTab(s, s.activePane, file("a")).state;
s = openTab(s, s.activePane, file("c")).state;
const c = findTab(s, "file:c")!.tab;
s = closeTab(s, s.activePane, c.id);
expect(active(s)).toBe("a");
s = closeTab(s, s.activePane, findTab(s, "file:c")!.tab.id);
expect(active(s)).toBe("b");
// The first tab has no left neighbour: the right one.
s = openTab(s, s.activePane, file("a")).state;
s = closeTab(s, s.activePane, findTab(s, "file:a")!.tab.id);
expect(active(s)).toBe("b");
});
it("activates the most recently used tab when closing the active one if set", () => {
tabOptions.activateOnClose = "mru";
try {
let s = initialTabsState();
for (const p of ["a", "b", "c"]) s = open(s, p);
s = openTab(s, s.activePane, file("a")).state;
s = openTab(s, s.activePane, file("c")).state;
const c = findTab(s, "file:c")!.tab;
s = closeTab(s, s.activePane, c.id);
expect(active(s)).toBe("a");
} finally {
tabOptions.activateOnClose = "left";
}
});
it("closes the preview tab when a regular tab opens", () => {
let s = initialTabsState();
s = open(s, "a");
s = open(s, "p", { preview: true });
s = open(s, "b");
expect(labels(s)).toEqual(["a", "b"]);
expect(active(s)).toBe("b");
expect(s.panes[s.activePane]!.mru).toHaveLength(2);
// Reopening an existing regular tab keeps the preview.
s = open(s, "q", { preview: true });
s = open(s, "a");
expect(labels(s)).toEqual(["a", "b", "q*"]);
});
it("reopens closed tabs at their position, most recent first", () => {
@@ -151,17 +184,19 @@ describe("tab model", () => {
});
describe("navigation history", () => {
it("records file switches and big jumps, merges small moves", () => {
it("records file switches and navigations, not plain caret moves", () => {
let t = 0;
const h = new NavigationHistory(() => ++t);
h.update({ path: "a", line: 1, col: 0 });
h.update({ path: "a", line: 5, col: 2 });
// Arrows / paging / typing move the current place, however far.
h.update({ path: "a", line: 300, col: 0 });
expect(h.entries).toHaveLength(1);
h.update({ path: "a", line: 100, col: 0 });
h.update({ path: "a", line: 100, col: 0 }, true);
h.update({ path: "b", line: 0, col: 0 });
expect(h.entries.map((e) => `${e.path}:${e.line}`)).toEqual(["a:5", "a:100", "b:0"]);
expect(h.entries.map((e) => `${e.path}:${e.line}`)).toEqual(["a:300", "a:100", "b:0"]);
expect(h.back()).toMatchObject({ path: "a", line: 100 });
expect(h.back()).toMatchObject({ path: "a", line: 5 });
expect(h.back()).toMatchObject({ path: "a", line: 300 });
expect(h.back()).toBeNull();
expect(h.forward()).toMatchObject({ path: "a", line: 100 });
// Navigating somewhere new from the middle drops the forward stack.
@@ -172,6 +207,25 @@ describe("navigation history", () => {
expect(h.snapshot()).toMatchObject({ index: 0, entries: [{ path: "c" }] });
});
it("a navigation on the same line records nothing", () => {
const h = new NavigationHistory();
h.update({ path: "a", line: 7, col: 0 });
h.update({ path: "a", line: 7, col: 9 }, true);
expect(h.entries).toHaveLength(1);
});
it("merges back places less than four lines apart", () => {
const h = new NavigationHistory();
h.update({ path: "a", line: 10, col: 0 });
h.update({ path: "a", line: 50, col: 0 }, true);
h.update({ path: "a", line: 12, col: 0 });
h.update({ path: "a", line: 80, col: 0 }, true);
// a:10 and a:12 are one place: the newer one stays.
expect(h.entries.map((e) => e.line)).toEqual([12, 80]);
h.update({ path: "a", line: 20, col: 0 }, true);
expect(h.entries.map((e) => e.line)).toEqual([12, 80, 20]);
});
it("arriving at a back target updates it in place", () => {
const h = new NavigationHistory();
h.update({ path: "a", line: 0, col: 0 });
+38 -11
View File
@@ -1,14 +1,31 @@
/**
* Back / Forward navigation history (⌘[ / ⌘]), like IDEA's: the current
* place is continuously updated while the caret moves locally; a file switch
* or a jump of more than `JUMP_LINES` lines starts a new entry and drops the
* forward stack.
* Back / Forward navigation history (⌘[ / ⌘]), like IDEA's:
* `entries[index]` is the current place, kept up to
* date while the caret moves. Only a navigation (a file switch, a mouse click,
* a jump to a declaration / usage / search result / line …) that changes the
* caret line leaves the previous place behind as a back entry and drops the
* forward stack; typing, arrows and paging don't. A back entry within
* `MERGE_LINES` of the one before it replaces it.
*/
import type { Transaction } from "@codemirror/state";
import type { NavigationLocation } from "@/editor/api";
export const JUMP_LINES = 10;
export const MERGE_LINES = 4;
/** userEvent of in-file navigation commands (method up / down, code block start / end…). */
export const NAVIGATE_EVENT = "select.navigate";
/** Caret moves that count as navigation: mouse clicks, find, reveals, stripe / sticky-line clicks, navigate commands. */
export function isNavigation(tr: Transaction): boolean {
return ["select.pointer", "select.reveal", "select.search", "select.overview", "select.sticky", NAVIGATE_EVENT].some((e) =>
tr.isUserEvent(e),
);
}
const MAX_ENTRIES = 100;
const samePlace = (a: Omit<NavigationLocation, "time">, b: Omit<NavigationLocation, "time">) =>
a.path === b.path && Math.abs(a.line - b.line) < MERGE_LINES;
export class NavigationHistory {
entries: NavigationLocation[] = [];
index = -1;
@@ -28,18 +45,28 @@ export class NavigationHistory {
return this.index < this.entries.length - 1;
}
/** Records the caret position after a selection change / tab switch. */
update(loc: Omit<NavigationLocation, "time">) {
/**
* Records the caret position. `navigation`: the move came from a navigation
* command; a file switch always counts as one.
*/
update(loc: Omit<NavigationLocation, "time">, navigation = false) {
const entry = { ...loc, time: this.now() };
const cur = this.current;
if (cur && cur.path === loc.path && Math.abs(cur.line - loc.line) <= JUMP_LINES) {
if (!cur) {
this.entries = [entry];
this.index = 0;
this.emit();
return;
}
const switched = cur.path !== loc.path;
if (!switched && (!navigation || cur.line === loc.line)) {
this.entries[this.index] = entry;
return;
}
// The place we leave becomes a back entry, merged with a close one before it.
this.entries = this.entries.slice(0, this.index + 1);
// Revisiting the place we came from shouldn't stutter the history.
const prev = this.entries[this.entries.length - 1];
if (prev && prev.path === loc.path && prev.line === loc.line) this.entries.pop();
const prev = this.entries[this.index - 1];
if (prev && samePlace(prev, cur)) this.entries.splice(this.index - 1, 1);
this.entries.push(entry);
if (this.entries.length > MAX_ENTRIES) this.entries.splice(0, this.entries.length - MAX_ENTRIES);
this.index = this.entries.length - 1;
+2 -1
View File
@@ -13,8 +13,9 @@ describe("editor session", () => {
s = open(s, "/p/a.ts");
s = open(s, "/p/b.ts");
s = togglePin(s, s.activePane, s.panes[s.activePane]!.tabs[1]!.id);
s = open(s, "/p/c.ts", { preview: true });
s = openTab(s, s.activePane, { kind: "diff", key: "diff:x", data: { big: true } }).state;
// A regular tab opened later would close the preview: open it in the background last.
s = open(s, "/p/c.ts", { preview: true, activate: false });
const first = s.activePane;
s = splitPane(s, first, "column").state;
s = open(s, "/p/d.ts");
+10 -2
View File
@@ -23,11 +23,13 @@ import { showContextMenu } from "@/ui";
import { baseExtensions, compartments, contributionExtensions } from "../cm/setup";
import { gutterMenuItems, inGutterMenuArea } from "../gutterMenu";
import type { DocModel } from "./document";
import { isNavigation } from "./navHistory";
export interface SlotHost {
onFocus(slot: EditorSlot): void;
onBlur(slot: EditorSlot): void;
onSelection(slot: EditorSlot): void;
/** `navigatedFrom`: the caret before a navigation transaction (mouse click, find, jump…). */
onSelection(slot: EditorSlot, navigatedFrom?: { line: number; col: number }): void;
onUserEdit(slot: EditorSlot, tr: Transaction): void;
onContextMenu(slot: EditorSlot, e: MouseEvent): void;
/** Escape in the editor (close the find bar…); return true when handled. */
@@ -175,7 +177,13 @@ export class EditorSlot {
private onTransactions(trs: readonly Transaction[], view: EditorView) {
view.update(trs);
let selection = false;
let navigatedFrom: { line: number; col: number } | undefined;
for (const tr of trs) {
if (!navigatedFrom && tr.selection && isNavigation(tr)) {
const head = tr.startState.selection.main.head;
const line = tr.startState.doc.lineAt(head);
navigatedFrom = { line: line.number - 1, col: head - line.from };
}
if (tr.docChanged && !isExternalChange(tr)) {
this.model.onLocal(tr.changes, this);
if (tr.isUserEvent("input") || tr.isUserEvent("delete") || tr.isUserEvent("move") || tr.isUserEvent("undo") || tr.isUserEvent("redo"))
@@ -183,7 +191,7 @@ export class EditorSlot {
}
if (tr.selection || tr.docChanged) selection = true;
}
if (selection) this.host.onSelection(this);
if (selection) this.host.onSelection(this, navigatedFrom);
}
/** Applies a transaction as if the user made it (synced, undoable). */
+14 -5
View File
@@ -1,11 +1,12 @@
/**
* Editor tabs and splits as pure state transitions (IDEA semantics):
* - one preview tab per pane (italic), replaced by the next preview, promoted
* to a regular tab when edited or opened explicitly;
* to a regular tab when edited or opened explicitly, closed when another
* regular tab opens;
* - pinned tabs stay at the front and survive Close Others / Close Tabs to the
* Right / Close All but Pinned;
* - new tabs open right of the active tab; closing activates the most
* recently used tab of the pane;
* - new tabs open right of the active tab; closing activates the left
* neighbour (or the right one / the most recently used, per settings);
* - splits form a tree of rows / columns; an emptied pane collapses away
* unless it is the last one.
*/
@@ -48,7 +49,7 @@ export interface TabsState {
const MAX_CLOSED = 50;
/** Editor Tabs › "When the current tab is closed, activate": set from settings by the controller. */
export const tabOptions: { activateOnClose: "mru" | "left" | "right" } = { activateOnClose: "mru" };
export const tabOptions: { activateOnClose: "mru" | "left" | "right" } = { activateOnClose: "left" };
let seq = 0;
const nextId = (p: string) => `${p}${++seq}`;
@@ -150,7 +151,15 @@ export function openTab(
}
tabs.splice(index, 0, tab);
}
pane = { ...pane, tabs, mru: activate ? mru : [...mru, tab.id] };
let active = pane.active;
if (!tab.preview && tabs.some((t) => t.preview)) {
// A new regular editor closes the preview tab (IDEA trims previews on every open).
const previews = new Set(tabs.filter((t) => t.preview).map((t) => t.id));
tabs.splice(0, tabs.length, ...tabs.filter((t) => !previews.has(t.id)));
mru = mru.filter((id) => !previews.has(id));
if (active && previews.has(active)) active = null;
}
pane = { ...pane, tabs, active, mru: activate ? mru : [...mru, tab.id] };
pane = activate ? touch(pane, tab.id) : { ...pane, active: pane.active ?? tab.id };
return { state: { ...withPane(s, pane), activePane: activate ? paneId : s.activePane }, tabId: tab.id };
}
+9 -1
View File
@@ -1,11 +1,13 @@
/**
* The editor area: split layout of panes, each with IDEA-style tabs (preview
* tab in italics, pinned tabs, modified dot, file icons coloured by file
* decorations, middle-click close, context menu) and the active editor.
* decorations, middle-click close, Alt+click close others, double-click to
* keep a preview / maximize the editor, context menu) and the active editor.
*/
import { Fragment, useMemo, useRef, useState, type DragEvent, type FocusEvent, type ReactNode } from "react";
import clsx from "clsx";
import { useShallow } from "zustand/react/shallow";
import { executeAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { useEditorPrefs } from "@/platform/editorSettings";
import { basename, dirname } from "@/platform/project";
@@ -252,6 +254,12 @@ function PaneView({ paneId }: { paneId: string }) {
dnd={dnd}
onSelect={(id) => ctrl.activate(paneId, id)}
onClose={(id) => ctrl.closeTab(paneId, id)}
onCloseOthers={(id) => ctrl.closeOthers(paneId, id)}
onDoubleClick={(id) => {
// IDEA: a preview tab becomes a regular one; any other tab maximizes the editor.
if (pane.tabs.find((t) => t.id === id)?.preview) ctrl.keepTab(paneId, id);
else void executeAction("toolwindow.hideAll");
}}
onContextMenu={(id, e) => {
const tab = pane.tabs.find((t) => t.id === id);
if (!tab) return;
+24 -7
View File
@@ -1,7 +1,7 @@
/**
* IDEA-style Find / Replace bar at the top of the editor (⌘F / ⌘R): search
* field with Match Case / Words / Regex toggles (⌥C / ⌥W / ⌥X), In Selection
* (on by default when opened over a multi-line selection), n/N counter,
* (on by default when Replace opens over a multi-line selection), n/N counter,
* ↑/↓ (⇧↵ / ↵), Replace / Replace All, Esc closes.
*/
import { useEffect, useLayoutEffect, useRef, type KeyboardEvent } from "react";
@@ -52,24 +52,41 @@ export function closeFindBar(paneId: string) {
useFindBars.setState({ [paneId]: undefined });
}
/** Opens (or refocuses) the bar; a single-line selection becomes the query. */
/** Longest selection that becomes the query. */
const MAX_QUERY = 10_000;
/**
* Opens (or refocuses) the bar, as in IDEA: the
* selection becomes the query — multi-line too — and the search covers the
* file, except that Replace over a multi-line selection searches in it. A
* multi-line query isn't kept for a later search without a selection.
*/
export function openFindBar(paneId: string, replace: boolean) {
const view = activeView(paneId);
const sel = view?.state.selection.main;
if (view && sel && !sel.empty) {
const text = view.state.sliceDoc(sel.from, Math.min(sel.to, sel.from + 500));
const text = view.state.sliceDoc(sel.from, sel.to);
const multiline = text.includes("\n");
openSelections.set(paneId, { from: sel.from, to: sel.to });
if (text.includes("\n") || sel.to - sel.from >= 500) {
// A multi-line selection becomes the search scope, like IDEA.
if (multiline && replace) {
view.dispatch({ effects: setFindScope.of({ from: sel.from, to: sel.to }) });
useFindOptions.setState({ search: "" });
} else {
view.dispatch({ effects: setFindScope.of(null) });
useFindOptions.setState((o) => ({ search: o.regexp ? text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : text }));
if (text.length < MAX_QUERY)
useFindOptions.setState((o) => ({ search: o.regexp ? text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : text }));
}
} else if (useFindOptions.getState().search.includes("\n")) {
useFindOptions.setState({ search: "" });
}
useFindBars.setState((s) => ({ [paneId]: { replace, seq: (s[paneId]?.seq ?? 0) + 1 } }));
}
/** Show / Hide Replace on the open bar, keeping its query and scope. */
function setReplaceShown(paneId: string, replace: boolean) {
useFindBars.setState((s) => ({ [paneId]: { replace, seq: s[paneId]?.seq ?? 0 } }));
}
/** ⌘G / ⇧⌘G: next / previous occurrence of the last search, with or without the bar. */
export function findAgain(paneId: string, forward: boolean) {
const view = activeView(paneId);
@@ -290,7 +307,7 @@ function FindBarInner({ paneId, replace, seq }: { paneId: string; replace: boole
title={replace ? "Hide Replace" : "Show Replace"}
size="small"
pressed={replace}
onClick={() => openFindBar(paneId, !replace)}
onClick={() => setReplaceShown(paneId, !replace)}
/>
</div>
<IconButton icon="close" title="Close" shortcut="⎋" size="small" className={styles.findClose} onClick={close} />
@@ -77,3 +77,24 @@
padding: 4px 6px;
color: var(--fg-muted);
}
.childList {
min-width: 200px;
max-height: 360px;
overflow-y: auto;
padding: 4px;
outline: none;
}
.childHeader {
display: flex;
align-items: center;
gap: 6px;
height: var(--h-list-row);
padding: 0 6px;
margin-bottom: 2px;
border-bottom: 1px solid var(--border-popup);
color: var(--fg-muted);
}
.rowChevron {
margin-left: auto;
color: var(--fg-faint);
}
+108
View File
@@ -210,3 +210,111 @@ function NavBar({ path, line, col }: { path: string; line: number; col: number }
</Popup>
);
}
/**
* The status bar navigation bar's popup (as IDEA's navigation bar): clicking
* a segment lists its children; a folder lists its own (← goes back), a file
* or symbol opens.
*/
export function NavChildrenPopup({
segment,
anchor,
onClose,
}: {
segment: NavSegment;
anchor: HTMLElement;
onClose: () => void;
}) {
const [stack, setStack] = useState<NavSegment[]>([segment]);
const top = stack[stack.length - 1]!;
const outline = useOutline((s) => s[top.path]);
const [loaded, setLoaded] = useState<{ top: NavSegment; items: NavChild[] } | null>(null);
const [active, setActive] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const items = loaded?.top === top ? loaded.items : null;
useEffect(() => {
let alive = true;
void childrenOf(top, outline)
.catch((): NavChild[] => [])
.then((list) => {
if (!alive) return;
setLoaded({ top, items: list });
setActive(0);
});
return () => {
alive = false;
};
}, [top, outline]);
useEffect(() => {
listRef.current?.querySelector<HTMLElement>('[aria-selected="true"]')?.scrollIntoView?.({ block: "nearest" });
}, [active, items]);
const choose = (child: NavChild) => {
if (child.kind === "dir") return setStack((s) => [...s, { kind: "dir", path: child.path, label: child.label }]);
onClose();
if (child.kind === "file") void editorController().openFile(child.path, { focus: true });
else {
const p = child.item.selection_range.start;
void editorController().openFile(child.path, { selection: { line: p.line, col: p.col }, focus: true });
}
};
const onKeyDown = (e: KeyboardEvent) => {
const n = items?.length ?? 0;
let handled = true;
if (e.key === "ArrowDown" && n) setActive((a) => (a + 1) % n);
else if (e.key === "ArrowUp" && n) setActive((a) => (a - 1 + n) % n);
else if (e.key === "Enter" || (e.key === "ArrowRight" && items?.[active]?.kind === "dir")) {
const child = items?.[active];
if (child) choose(child);
} else if (e.key === "ArrowLeft" && stack.length > 1) setStack((s) => s.slice(0, -1));
else handled = false;
if (handled) {
e.preventDefault();
e.stopPropagation();
}
};
return (
<Popup open anchor={anchor} placement="top-start" onClose={onClose} aria-label={top.label} className={styles.popup}>
<div
ref={listRef}
className={styles.childList}
role="listbox"
aria-label={top.label}
tabIndex={0}
data-autofocus
aria-activedescendant={items?.length ? `navbar-child-${active}` : undefined}
onKeyDown={onKeyDown}
>
{stack.length > 1 && (
<div className={styles.childHeader}>
<Icon name={iconOf(top)} size={14} />
<span className={styles.rowLabel}>{top.label}</span>
</div>
)}
{items?.length === 0 && (
<div className={styles.empty}>
<T>Nothing here</T>
</div>
)}
{items?.map((c, i) => (
<div
key={`${c.path}:${c.label}:${i}`}
id={`navbar-child-${i}`}
role="option"
aria-selected={i === active}
className={clsx(styles.row, i === active && styles.rowActive)}
onMouseEnter={() => setActive(i)}
onClick={() => choose(c)}
>
<Icon name={iconOf(c)} size={14} />
<span className={styles.rowLabel}>{c.label}</span>
{c.kind === "dir" && <Icon name="chevron-right" size={12} className={styles.rowChevron} />}
</div>
))}
</div>
</Popup>
);
}
+26 -10
View File
@@ -12,6 +12,8 @@ import { isVirtualUri } from "@/platform/virtualDocs";
import { editorController, useEditorStatus, useGotoLine } from "../controller";
import { useDocMeta, useOutline } from "../model/document";
import { symbolChain } from "./outline";
import { NavChildrenPopup } from "./NavBarPopup";
import type { NavSegment } from "./navBar";
import styles from "./EditorArea.module.css";
function useActiveMeta() {
@@ -24,10 +26,15 @@ interface Crumb {
label: string;
icon?: string;
title: string;
run: () => void;
/** Clicking lists the segment's children (IDEA navigation bar). */
nav?: NavSegment;
run?: () => void;
}
/** IDEA new UI navigation bar in the status bar: project › dirs › file › symbols, with icons. */
/**
* IDEA new UI navigation bar in the status bar: project › dirs › file ›
* symbols, with icons; clicking a segment opens a popup of its children.
*/
export function Breadcrumbs() {
const status = useEditorStatus((s) => s.status);
const project = useProject((s) => s.project);
@@ -35,6 +42,7 @@ export function Breadcrumbs() {
const line = status?.line ?? 0;
const col = status?.col ?? 0;
const chain = useMemo(() => (outline ? symbolChain(outline, line, col) : []), [outline, line, col]);
const [popup, setPopup] = useState<{ nav: NavSegment; anchor: HTMLElement } | null>(null);
if (!status) return null;
const path = status.path;
const parent = dirname(path);
@@ -44,7 +52,7 @@ export function Breadcrumbs() {
label: project.name,
icon: "module",
title: project.root,
run: () => void executeAction("navigate.selectIn", { path: project.root }),
nav: { kind: "dir", path: project.root, label: project.name, root: true },
});
if (project && parent !== project.root && parent.startsWith(project.root + "/")) {
// Every directory from the root to the file, eliding the middle of deep paths.
@@ -67,7 +75,7 @@ export function Breadcrumbs() {
label,
icon: "folder",
title: relativePath(project.root, dir),
run: () => void executeAction("navigate.selectIn", { path: dir }),
nav: { kind: "dir", path: dir, label },
});
}
}
@@ -77,29 +85,37 @@ export function Breadcrumbs() {
label: file,
icon: fileIconName(file),
title: project ? relativePath(project.root, path) : path,
run: () => void executeAction("navigate.selectIn", { path }),
nav: { kind: "file", path, label: file },
});
for (const item of chain.filter((it, i) => i > 0 || it.name !== stem))
segments.push({
label: item.name,
icon: `symbol-${badgeKind(item.kind)}`,
title: item.detail ?? item.name,
run: () =>
void editorController().openFile(path, {
selection: { line: item.selection_range.start.line, col: item.selection_range.start.col },
}),
nav: { kind: "symbol", path, label: item.name, item },
});
return (
<nav className={styles.status} aria-label="Breadcrumbs">
{segments.map((s, i) => (
<Fragment key={i}>
{i > 0 && <Icon name="chevron-right" size={12} className={styles.statusSep} />}
<BarButton className={styles.crumbButton} tooltip={s.title} onClick={s.run}>
<BarButton
className={styles.crumbButton}
tooltip={s.title}
aria-haspopup={s.nav ? "listbox" : undefined}
onClick={(e) => {
const nav = s.nav;
const anchor = e.currentTarget;
if (nav) setPopup((p) => (p?.anchor === anchor ? null : { nav, anchor }));
else s.run?.();
}}
>
{s.icon && <Icon name={s.icon} size={14} className={styles.crumbIcon} />}
<span className={styles.crumbLabel}>{s.label}</span>
</BarButton>
</Fragment>
))}
{popup && <NavChildrenPopup segment={popup.nav} anchor={popup.anchor} onClose={() => setPopup(null)} />}
</nav>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { OutlineItem } from "@/platform/ipc";
const openFile = vi.fn(async () => {});
vi.mock("../controller", () => ({
editorController: () => ({ openFile }),
useEditorStatus: () => null,
}));
const { useOutline } = await import("../model/document");
const { NavChildrenPopup } = await import("./NavBarPopup");
const range = (line: number) => ({ start: { line, col: 2 }, end: { line, col: 5 } });
const item = (name: string, line: number, children: OutlineItem[] = []): OutlineItem =>
({ name, detail: null, kind: "function", range: range(line), selection_range: range(line), children }) as OutlineItem;
describe("status bar navigation bar popup", () => {
it("lists a file's symbols and navigates to the chosen one", async () => {
useOutline.setState({ "/p/a.ts": [item("first", 1), item("second", 7)] });
const anchor = document.createElement("button");
document.body.appendChild(anchor);
const onClose = vi.fn();
render(<NavChildrenPopup segment={{ kind: "file", path: "/p/a.ts", label: "a.ts" }} anchor={anchor} onClose={onClose} />);
await act(async () => {});
const list = screen.getByRole("listbox", { name: "a.ts" });
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual(["first", "second"]);
fireEvent.keyDown(list, { key: "ArrowDown" });
fireEvent.keyDown(list, { key: "Enter" });
expect(onClose).toHaveBeenCalled();
expect(openFile).toHaveBeenCalledWith("/p/a.ts", { selection: { line: 7, col: 2 }, focus: true });
});
});
+7
View File
@@ -276,3 +276,10 @@
color: var(--fg-search-match, inherit);
border-radius: 2px;
}
.statusMessage {
color: var(--fg-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
+19 -1
View File
@@ -19,6 +19,7 @@ import { editorService } from "@/workbench/contributions";
import { closePopup, useLspPopup, type ListPopupItem, type LspPopup } from "./popupStore";
import { renderCode, renderMarkup } from "./markdown";
import { describeSummary } from "./workspaceEdit";
import { useUsagesStatus } from "./navigation";
import styles from "./Popups.module.css";
export function BulbIcon({ fix }: { fix?: boolean }) {
@@ -209,9 +210,15 @@ function DomContent({ build, className }: { build: () => HTMLElement; className?
}
function DocPopup({ popup }: { popup: Extract<LspPopup, { kind: "doc" }> }) {
const focusSeq = useLspPopup((s) => s.focusSeq);
const scope = useRef<HTMLDivElement>(null);
const opened = useRef(focusSeq);
useEffect(() => {
if (focusSeq !== opened.current) scope.current?.focus({ preventScroll: true });
}, [focusSeq]);
return (
<Popup open anchor={popup.anchor} onClose={closePopup} aria-label="Documentation" className={styles.docPopup}>
<div className={clsx(popup.themeClasses, styles.docScope)}>
<div ref={scope} tabIndex={-1} className={clsx(popup.themeClasses, styles.docScope)}>
<DomContent className={styles.docBody} build={() => renderMarkup(popup.markup, popup.language)} />
</div>
</Popup>
@@ -319,6 +326,17 @@ function PreviewDialog({ popup }: { popup: Extract<LspPopup, { kind: "preview" }
);
}
/** Status bar text while ⌘⇧F7 usages are highlighted ("3 usages of 'x' found…"). */
export function UsagesStatus() {
const text = useUsagesStatus((s) => s.text);
if (!text) return null;
return (
<span className={styles.statusMessage} role="status" title={text}>
{text}
</span>
);
}
/** Overlay host: renders the current LSP popup, if any. */
export function LspPopups() {
const popup = useLspPopup((s) => s.popup);
+2 -2
View File
@@ -4,7 +4,7 @@ import { registerActions, type Action } from "@/platform/actions";
import { activeEditor, editorsFor, registerSaveParticipant, type TextEditorHandle } from "@/editor/api";
import { maybeEditorService } from "@/workbench/contributions";
import { useProject } from "@/platform/project";
import { startCompletion } from "./completion";
import { explicitCompletion } from "./completion";
import { triggerSignatureHelp } from "./signature";
import { quickDefinition, quickDocumentation } from "./hover";
import { expressionTypeInfo } from "./typeInfo";
@@ -99,7 +99,7 @@ export function registerActionsOnSave(): () => void {
export function registerLspActions(): () => void {
return registerActions([
editorAction("code.completion", "Basic", "Code Completion", (v) => startCompletion(v), {
editorAction("code.completion", "Basic", "Code Completion", (v) => explicitCompletion(v), {
icon: "list",
keywords: ["code completion", "basic completion", "autocomplete"],
}),
+55 -2
View File
@@ -1,9 +1,10 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { CompletionItem } from "@/bindings";
import { completionSymbolKind, identifierEnd, toCmCompletion, triggerCharacter } from "./completionItems";
import { applyLspCompletion, makeCompletion } from "./completion";
import { applyLspCompletion, commitsLookup, completionExtension, explicitCompletion, makeCompletion } from "./completion";
import type { CompletionSource } from "@codemirror/autocomplete";
function item(over: Partial<CompletionItem> = {}): CompletionItem {
return {
@@ -161,3 +162,55 @@ describe("applying completions", () => {
expect(view.state.sliceDoc(sel.from, sel.to)).toBe("arg");
});
});
describe("lookup commit characters", () => {
it("finishes only focused lookups unless the setting asks for it (IDEA)", () => {
expect(commitsLookup(".", true, false)).toBe(true);
expect(commitsLookup(".", false, false)).toBe(false);
expect(commitsLookup(".", false, true)).toBe(true);
expect(commitsLookup("-", true, false)).toBe(false);
expect(commitsLookup("[", true, false, ["["])).toBe(true);
});
it("leaves server commit characters to the lookup", () => {
const req = { doc: 1, path: "/a.ts", language: "typescript", pos: 0, text: EditorState.create().doc };
expect(makeCompletion(item({ commitCharacters: ["."] }), req).commitCharacters).toBeUndefined();
});
});
describe("explicit completion", () => {
// jsdom has no layout; CodeMirror measures text when the tooltip is positioned.
Range.prototype.getClientRects ??= () => [] as unknown as DOMRectList;
Range.prototype.getBoundingClientRect ??= () => new DOMRect();
function editor(labels: string[]) {
const source: CompletionSource = (ctx) => {
const word = ctx.matchBefore(/\w*/);
return { from: word?.from ?? ctx.pos, options: labels.map((label) => ({ label })) };
};
const view = new EditorView({
state: EditorState.create({
doc: "pri",
selection: { anchor: 3 },
extensions: [completionExtension(), EditorState.languageData.of(() => [{ openideaCompletion: source }])],
}),
parent: document.body,
});
return view;
}
it("inserts a sole candidate right away (IDEA)", async () => {
const view = editor(["println"]);
explicitCompletion(view);
await vi.waitFor(() => expect(view.state.doc.toString()).toBe("println"), { timeout: 2000 });
view.destroy();
});
it("shows the list when there are several candidates", async () => {
const view = editor(["println", "print"]);
explicitCompletion(view);
await new Promise((r) => setTimeout(r, 300));
expect(view.state.doc.toString()).toBe("pri");
view.destroy();
});
});
+155 -6
View File
@@ -11,7 +11,9 @@ import {
autocompletion,
completeFromList,
completionStatus,
currentCompletions,
pickedCompletion,
selectedCompletion,
snippet,
startCompletion,
type Completion,
@@ -22,7 +24,7 @@ import {
} from "@codemirror/autocomplete";
import { EditorSelection, Prec, type Text } from "@codemirror/state";
import { EditorView, ViewPlugin, keymap, type ViewUpdate } from "@codemirror/view";
import { commands, isTauri, nextRequestId, type CompletionItem, type RequestId } from "@/platform/ipc";
import { commands, isTauri, nextRequestId, type CompletionItem, type Markup, type RequestId } from "@/platform/ipc";
import { completionSymbolKind, identifierEnd, toCmCompletion, triggerCharacter } from "./completionItems";
import { lspSnippetToCm, snippetVariables } from "./snippet";
import { docOf, isUnavailable, lspDoc, lspRequest, reportLspError } from "./lsp";
@@ -33,6 +35,8 @@ import { triggerSignatureHelp } from "./signature";
import { serverBackoff } from "./availability";
import { pathCompletionSource } from "./fileReferences";
import { editorPrefs } from "@/platform/editorSettings";
import { showHint } from "./hint";
import { inplaceRenameActive } from "./inplaceRename";
interface RequestContext {
doc: number;
@@ -66,10 +70,28 @@ function resolveItem(doc: number, item: CompletionItem): Promise<CompletionItem>
return p;
}
/**
* F1 in the lookup (IDEA's documentation popup next to the lookup): docs then
* follow the selection until the lookup closes. Without the auto-popup setting
* the info panel waits for this.
*/
let docsRequested = false;
const docWaiters = new Set<() => void>();
function renderInfo(c: LspCompletion): Promise<CompletionInfo> {
const prefs = editorPrefs().completion;
if (!prefs.showDocumentation) return Promise.resolve(null);
const delay = Math.max(0, prefs.documentationDelay);
if (!prefs.showDocumentation && !docsRequested)
return new Promise((resolve) => {
const show = () => {
docWaiters.delete(show);
resolve(buildInfo(c, 0));
};
docWaiters.add(show);
});
return buildInfo(c, docsRequested ? 0 : Math.max(0, prefs.documentationDelay));
}
function buildInfo(c: LspCompletion, delay: number): Promise<CompletionInfo> {
const wait = delay ? new Promise<void>((r) => setTimeout(r, delay)) : Promise.resolve();
return Promise.all([resolveItem(c.req.doc, c.lsp), wait]).then(([item]) => {
c.resolved = item;
@@ -83,6 +105,35 @@ function renderInfo(c: LspCompletion): Promise<CompletionInfo> {
});
}
function resetLookupDocs() {
docsRequested = false;
docWaiters.clear();
}
/**
* F1 while the lookup is open: shows the selected item's docs beside it
* (`shown`), or reports that they are already showing (`showing`) so the
* caller can open them focused instead. `none` without an open lookup.
*/
export function showLookupDocs(view: EditorView): "shown" | "showing" | "none" {
if (completionStatus(view.state) !== "active" || !selectedCompletion(view.state)) return "none";
if (docsRequested || editorPrefs().completion.showDocumentation) return "showing";
docsRequested = true;
for (const show of [...docWaiters]) show();
return "shown";
}
/** Documentation of the selected lookup item as markup (detail as a code block), or null. */
export async function selectedLookupDoc(view: EditorView): Promise<{ markup: Markup; language: string | null } | null> {
const c = selectedCompletion(view.state);
if (!c || !isLsp(c)) return null;
const item = await resolveItem(c.req.doc, c.lsp);
const parts: string[] = [];
if (item.detail) parts.push("```" + (c.req.language ?? "") + "\n" + item.detail + "\n```");
if (!isEmptyMarkup(item.documentation)) parts.push(item.documentation!.value);
return parts.length ? { markup: { kind: "markdown", value: parts.join("\n\n") }, language: c.req.language } : null;
}
// ---------------------------------------------------------------------------
// Apply
@@ -205,7 +256,9 @@ async function fallback(ctx: CompletionContext): Promise<CompletionResult | null
}
export function makeCompletion(item: CompletionItem, req: RequestContext): LspCompletion {
return Object.assign(toCmCompletion(item), {
// The lookup handles commit characters itself (focused lookups only, see `lookupKeys`).
const { commitCharacters: _server, ...cm } = toCmCompletion(item);
return Object.assign(cm, {
lsp: item,
req,
apply: applyLsp,
@@ -282,7 +335,6 @@ async function lspCompletionSource(ctx: CompletionContext): Promise<CompletionRe
// "All letters" must re-check case on every keystroke.
validFor: list.isIncomplete || (prefs.matchCase && prefs.caseSensitivity === "all") ? undefined : VALID_FOR,
filter: true,
...(prefs.insertOnSpecialKeys ? { commitCharacters: [" ", ".", "(", ",", ";"] } : {}),
};
}
@@ -301,7 +353,102 @@ export function matchesCase(label: string, typed: string, mode: string): boolean
/** Show suggestions as you type: sources only answer explicit requests when it is off. */
function gated(source: CompletionSource): CompletionSource {
return (ctx) => (!ctx.explicit && !editorPrefs().completion.autoPopup ? null : source(ctx));
return (ctx) =>
!ctx.explicit && (!editorPrefs().completion.autoPopup || inplaceRenameActive(ctx.state)) ? null : source(ctx);
}
// ---------------------------------------------------------------------------
// Lookup focus, commit characters, explicit invocation
/**
* As in IDEA, these finish a *focused* lookup with the selected
* item before being typed; an unfocused auto-popup just
* closes on them unless Settings › Code Completion › "Insert selected
* suggestion by pressing space, dot…" is on.
*/
export const COMMIT_CHARS = [".", ",", ";", "=", " ", ":", "("];
/** Lookups the user engaged with: opened explicitly (⌃Space) or navigated with ↑/↓. */
const focusedLookups = new WeakSet<EditorView>();
/** Explicit invocations waiting for their first result (single candidate → insert it). */
const explicitPending = new WeakSet<EditorView>();
const NAVIGATION_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown"]);
/** Whether typing `ch` finishes the open lookup with its selected item. */
export function commitsLookup(ch: string, focused: boolean, insertOnSpecialKeys: boolean, itemChars: readonly string[] = []): boolean {
if (!focused && !insertOnSpecialKeys) return false;
return COMMIT_CHARS.includes(ch) || itemChars.includes(ch);
}
const lookupKeys = Prec.highest(
EditorView.domEventHandlers({
keydown(e, view) {
if (e.isComposing || completionStatus(view.state) !== "active") return false;
if (NAVIGATION_KEYS.has(e.key) && !e.metaKey && !e.ctrlKey && !e.altKey) {
focusedLookups.add(view);
return false;
}
if (e.key.length !== 1 || e.metaKey || e.ctrlKey || e.altKey) return false;
const selected = selectedCompletion(view.state);
if (!selected) return false;
const itemChars = isLsp(selected) ? selected.lsp.commitCharacters : (selected.commitCharacters ?? []);
if (!commitsLookup(e.key, focusedLookups.has(view), editorPrefs().completion.insertOnSpecialKeys, itemChars)) return false;
if (!acceptCompletion(view)) return false;
// A call snippet already opened the parenthesis: the typed one is consumed.
const head = view.state.selection.main.head;
if (e.key === "(" && view.state.sliceDoc(head - 1, head) === "(") {
e.preventDefault();
return true;
}
return false;
},
}),
);
/** IDEA inserts a sole candidate right away on explicit invocation. */
const AUTO_INSERT_DELAY = 80; // past CodeMirror's interactionDelay
const lookupTracker = ViewPlugin.fromClass(
class {
private prev: ReturnType<typeof completionStatus> = null;
constructor(private view: EditorView) {}
update(u: ViewUpdate) {
const status = completionStatus(u.state);
const prev = this.prev;
this.prev = status;
if (status === prev && status !== "active") return;
const view = this.view;
if (status === null) {
focusedLookups.delete(view);
resetLookupDocs();
if (explicitPending.has(view)) {
explicitPending.delete(view);
if (prev === "pending" && !u.docChanged) queueMicrotask(() => showHint(view, "No suggestions", { timeout: 2000 }));
}
} else if (status === "active" && explicitPending.has(view)) {
explicitPending.delete(view);
const options = currentCompletions(u.state);
if (options.length === 1) {
const doc = u.state.doc;
setTimeout(() => {
const now = currentCompletions(view.state);
if (view.state.doc === doc && completionStatus(view.state) === "active" && now.length === 1 && now[0] === options[0])
acceptCompletion(view);
}, AUTO_INSERT_DELAY);
}
}
}
},
);
/** ⌃Space: explicit completion (a focused lookup; a sole candidate is inserted at once). */
export function explicitCompletion(view: EditorView): boolean {
explicitPending.add(view);
const started = startCompletion(view);
if (!started) explicitPending.delete(view);
else focusedLookups.add(view);
return started;
}
// ---------------------------------------------------------------------------
@@ -359,6 +506,7 @@ const completionFooter = ViewPlugin.fromClass(
export function completionExtension() {
return [
lookupKeys,
autocompletion({
override: [gated(lspCompletionSource), gated(contributedSource), gated(pathCompletionSource)],
icons: false,
@@ -374,6 +522,7 @@ export function completionExtension() {
tooltipClass: () => "cm-lsp-completion",
}),
completionFooter,
lookupTracker,
Prec.highest(
keymap.of([
{
+1 -1
View File
@@ -214,7 +214,7 @@ export function gotoDiagnostic(view: EditorView, dir: 1 | -1): boolean {
dir === 1
? (list.find((p) => p.from > head) ?? list[0]!)
: ([...list].reverse().find((p) => p.from < head && !(p.from <= head && head <= p.to)) ?? list[list.length - 1]!);
view.dispatch({ selection: EditorSelection.cursor(target.from), scrollIntoView: true, userEvent: "select" });
view.dispatch({ selection: EditorSelection.cursor(target.from), scrollIntoView: true, userEvent: "select.navigate" });
showHint(view, target.diagnostic.message, {
pos: target.from,
tone: target.diagnostic.severity === "error" ? "error" : target.diagnostic.severity === "warning" ? "warning" : "info",
+55 -2
View File
@@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest";
import { Text } from "@codemirror/state";
import type { CodeAction } from "@/bindings";
import { semanticRanges } from "./semantic";
import { sortCodeActions } from "./intentions";
import { lineErrorsAwayFromCaret, sortCodeActions } from "./intentions";
import { splitSignature } from "./signature";
import { dedupeLocations } from "./navigation";
import { dedupeLocations, usagesStatusText } from "./navigation";
import { declarationSummary } from "./markdown";
import { linkedSpans } from "./rename";
describe("semanticRanges", () => {
const doc = Text.of(["let mut count = Foo::new();", "const MAX: u32 = 1;"]);
@@ -67,3 +69,54 @@ describe("dedupeLocations", () => {
expect(dedupeLocations([loc("/a", 1), loc("/a", 1), loc("/b", 1)])).toHaveLength(2);
});
});
describe("lineErrorsAwayFromCaret", () => {
const p = (from: number, to: number, severity = "error") => ({ from, to, severity });
it("offers errors elsewhere on the caret line when none is at the caret", () => {
expect(lineErrorsAwayFromCaret([p(30, 34), p(12, 15), p(50, 52), p(5, 6, "warning")], 0, 40, 2)).toEqual([p(12, 15), p(30, 34)]);
});
it("offers nothing extra when an error touches the caret", () => {
expect(lineErrorsAwayFromCaret([p(0, 3), p(12, 15)], 0, 40, 3)).toEqual([]);
});
});
describe("usagesStatusText", () => {
it("reads like IDEA's status bar message", () => {
expect(usagesStatusText(1, "'x'", "⇧⌘F7")).toBe(
"1 usage of 'x' found (press ⇧⌘F7 again to remove the highlighting, Escape to remove all highlighting)",
);
expect(usagesStatusText(3, "'x'", null)).toBe("3 usages of 'x' found (Escape to remove all highlighting)");
});
});
describe("declarationSummary", () => {
it("takes the first code block of hover markdown", () => {
expect(declarationSummary({ kind: "markdown", value: "```ts\nconst x: number\n```\n---\nDocs" })).toEqual({
code: "const x: number",
language: "ts",
});
});
it("falls back to the first paragraph", () => {
expect(declarationSummary({ kind: "plaintext", value: "fn main()\n\nmore" })).toEqual({ text: "fn main()" });
expect(declarationSummary({ kind: "markdown", value: " " })).toBeNull();
});
});
describe("linkedSpans", () => {
it("keeps other same-name occurrences, sorted and without the primary or overlaps", () => {
const spans = [
{ from: 20, to: 23 },
{ from: 4, to: 7 },
{ from: 10, to: 13 },
{ from: 11, to: 14 },
{ from: 30, to: 35 },
];
expect(linkedSpans(spans, { from: 4, to: 7 }, (s) => s.to - s.from === 3)).toEqual([
{ from: 10, to: 13 },
{ from: 20, to: 23 },
]);
});
});
+25 -2
View File
@@ -13,7 +13,8 @@ import { isEmptyMarkup, languageForPath, renderMarkup } from "./markdown";
import { docOf, lspRequest, reportLspError } from "./lsp";
import { offsetToPos, rangeToOffsets } from "./positions";
import { showHint } from "./hint";
import { caretAnchor, openPopup } from "./popupStore";
import { caretAnchor, focusPopup, openPopup, useLspPopup } from "./popupStore";
import { selectedLookupDoc, showLookupDocs } from "./completion";
import { readFileLines } from "./files";
import { iconNode } from "./iconMarkup";
import { editorPrefs } from "@/platform/editorSettings";
@@ -117,9 +118,31 @@ export function hoverExtension() {
);
}
/** F1: documentation for the symbol at the caret in a sticky popup. */
/**
* F1: documentation for the symbol at the caret in a sticky popup; in an open
* lookup, for its selected item. Again on an open popup: focuses it.
*/
export async function quickDocumentation(view: EditorView) {
if (useLspPopup.getState().popup?.kind === "doc") {
focusPopup();
return;
}
const pos = view.state.selection.main.head;
const lookup = showLookupDocs(view);
if (lookup === "shown") return;
if (lookup === "showing") {
const doc = await selectedLookupDoc(view).catch(() => null);
if (!doc) showHint(view, "No documentation found");
else
openPopup({
kind: "doc",
anchor: caretAnchor(view, pos),
markup: doc.markup,
language: doc.language,
themeClasses: view.themeClasses,
});
return;
}
try {
const hover = await requestHover(view, pos);
if (!hover) {
+5 -2
View File
@@ -7,10 +7,11 @@
*/
import type { Extension } from "@codemirror/state";
import { registerEditorContribution, type EditorDocContext } from "@/editor/api";
import { registerOverlay } from "@/workbench/contributions";
import { registerOverlay, registerStatusItem } from "@/workbench/contributions";
import { lspDoc } from "./lsp";
import { hintExtension } from "./hint";
import { completionExtension } from "./completion";
import { inplaceRenameExtension } from "./inplaceRename";
import { diagnosticsExtension, installDiagnosticsEvents } from "./diagnostics";
import { hoverExtension } from "./hover";
import { signatureExtension } from "./signature";
@@ -24,8 +25,8 @@ import { installAvailabilityEvents } from "./availability";
import { registerActionsOnSave, registerFormatOnSave, registerLspActions } from "./actions";
import { lspTheme } from "./theme";
import { inspectionWidgetExtension } from "./inspectionWidget";
import { LspPopups } from "./Popups";
import { unresolvedReferences } from "./fileReferences";
import { LspPopups, UsagesStatus } from "./Popups";
import { prefetchMarkdown } from "./markdown";
import { registerNavigationActions } from "@/editor/navigation";
@@ -39,6 +40,7 @@ export function lspExtensions(ctx: EditorDocContext): Extension[] {
hoverExtension(),
signatureExtension(),
completionExtension(),
inplaceRenameExtension(),
navigationExtension(),
intentionsExtension(),
inlayExtension(),
@@ -55,6 +57,7 @@ export function activate(): () => void {
installAvailabilityEvents(),
registerEditorContribution({ id: "lsp", order: 50, extensions: lspExtensions }),
registerOverlay({ id: "editor-lsp.popups", component: LspPopups }),
registerStatusItem({ id: "editor-lsp.usagesStatus", align: "left", priority: 10, component: UsagesStatus }),
registerLspActions(),
registerNavigationActions(),
registerFormatOnSave(),
+87
View File
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorSelection, EditorState, type Extension } from "@codemirror/state";
import { history, undoDepth } from "@codemirror/commands";
import { EditorView } from "@codemirror/view";
import { endInplaceRename, inplaceRenameActive, inplaceRenameExtension, startInplaceRename } from "./inplaceRename";
const views: EditorView[] = [];
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
// "let foo = 1; foo + foo;" — foo at 4, 13 and 19.
function session(commit = vi.fn(), dialog = vi.fn(), extensions: Extension[] = []) {
const view = new EditorView({
state: EditorState.create({ doc: "let foo = 1; foo + foo;", extensions: [inplaceRenameExtension(), ...extensions] }),
});
views.push(view);
startInplaceRename(view, {
primary: { from: 4, to: 7 },
others: [
{ from: 13, to: 16 },
{ from: 19, to: 22 },
],
hint: "Press ⇧F6 to show dialog with more options",
commit,
dialog,
});
return { view, commit, dialog };
}
const type = (view: EditorView, from: number, to: number, insert: string) =>
view.dispatch({ changes: { from, to, insert }, selection: EditorSelection.cursor(from + insert.length), userEvent: "input.type" });
describe("in-place rename", () => {
it("preselects the name and mirrors edits to the other occurrences", () => {
const { view } = session();
expect(view.state.selection.main).toMatchObject({ from: 4, to: 7 });
type(view, 4, 7, "bar");
expect(view.state.doc.toString()).toBe("let bar = 1; bar + bar;");
type(view, 7, 7, "Baz");
expect(view.state.doc.toString()).toBe("let barBaz = 1; barBaz + barBaz;");
expect(inplaceRenameActive(view.state)).toBe(true);
});
it("restores the old name on cancel without committing", () => {
const { view, commit } = session();
type(view, 4, 7, "x");
expect(endInplaceRename(view, "cancel")).toBe("x");
expect(view.state.doc.toString()).toBe("let foo = 1; foo + foo;");
expect(inplaceRenameActive(view.state)).toBe(false);
expect(commit).not.toHaveBeenCalled();
});
it("puts the old text back before committing the typed name", () => {
const commit = vi.fn();
const { view } = session(commit);
type(view, 4, 7, "count");
commit.mockImplementation(() => expect(view.state.doc.toString()).toBe("let foo = 1; foo + foo;"));
endInplaceRename(view, "commit");
expect(commit).toHaveBeenCalledWith("count");
});
it("keeps the session's edits out of the undo history", () => {
const { view } = session(vi.fn(), vi.fn(), [history()]);
type(view, 4, 7, "bar");
expect(undoDepth(view.state)).toBe(0);
endInplaceRename(view, "cancel");
expect(undoDepth(view.state)).toBe(0);
});
it("commits when the caret leaves the name", async () => {
const { view, commit } = session();
type(view, 4, 7, "bar");
view.dispatch({ selection: EditorSelection.cursor(0) });
await Promise.resolve();
expect(commit).toHaveBeenCalledWith("bar");
expect(inplaceRenameActive(view.state)).toBe(false);
});
});
describe("in-place rename edits elsewhere", () => {
it("finishes the rename when text outside the name changes", async () => {
const { view, commit } = session();
type(view, 4, 7, "bar");
view.dispatch({ changes: { from: 0, to: 0, insert: "// x\n" }, userEvent: "input.type" });
await Promise.resolve();
expect(commit).toHaveBeenCalledWith("bar");
});
});
+175
View File
@@ -0,0 +1,175 @@
/**
* In-place rename, as in IDEA: the
* name is edited right in the editor and its occurrences in this file follow
* as you type. ↵ / ⇥ (or leaving the name) commits through the language
* server, Esc restores the old name; ⇧F6 again opens the rename dialog
* (handled by `rename.ts`).
*
* The live edits are kept out of the undo history and the old text is put
* back before the server renames, so the project-wide rename stays a single
* undoable edit computed against the original document.
*/
import { Annotation, EditorSelection, EditorState, Prec, StateEffect, StateField, Transaction, type ChangeSet, type Extension } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, keymap, showTooltip, type Tooltip, type ViewUpdate } from "@codemirror/view";
import { isExternalChange } from "@/editor/api";
export interface Span {
from: number;
to: number;
}
export interface InplaceSession {
/** The name being edited. */
primary: Span;
/** Its other occurrences in this file, kept equal to `primary`. */
others: Span[];
oldName: string;
/** Advertisement under the name ("Press ⇧F6 to show dialog with more options"). */
hint: string;
/** Runs after the old text is back, with the typed name. */
commit: (newName: string) => void;
/** ⇧F6 again: the rename dialog, prefilled with the typed name. */
dialog: (typed: string) => void;
}
const startSession = StateEffect.define<InplaceSession>();
const endSession = StateEffect.define<null>();
/** Marks the session's own edits (mirroring, restoring). */
const inplaceEdit = Annotation.define<boolean>();
const mapSpan = (s: Span, changes: ChangeSet): Span => ({ from: changes.mapPos(s.from, -1), to: changes.mapPos(s.to, 1) });
const sessionField = StateField.define<InplaceSession | null>({
create: () => null,
update(value, tr) {
for (const e of tr.effects) {
if (e.is(startSession)) return e.value;
if (e.is(endSession)) return null;
}
if (!value || !tr.docChanged) return value;
return { ...value, primary: mapSpan(value.primary, tr.changes), others: value.others.map((o) => mapSpan(o, tr.changes)) };
},
provide: (f) => [
EditorView.decorations.from(f, (s) => {
if (!s) return Decoration.none;
const ranges = [
...s.others.filter((o) => o.from < o.to).map((o) => occurrenceMark.range(o.from, o.to)),
...(s.primary.from < s.primary.to ? [primaryMark.range(s.primary.from, s.primary.to)] : []),
];
return Decoration.set(ranges, true);
}),
showTooltip.from(f, (s): Tooltip | null =>
s
? {
pos: s.primary.from,
above: false,
create: () => {
const dom = document.createElement("div");
dom.className = "cm-lsp-hint cm-lsp-inplace-hint";
dom.setAttribute("role", "status");
dom.textContent = s.hint;
return { dom };
},
}
: null,
),
],
});
const primaryMark = Decoration.mark({ class: "cm-lsp-inplace cm-lsp-inplace-primary" });
const occurrenceMark = Decoration.mark({ class: "cm-lsp-inplace" });
/** True when every change of `changes` lies within `span` (boundaries included). */
export function changesWithin(changes: ChangeSet, span: Span): boolean {
let inside = true;
changes.iterChangedRanges((fromA, toA) => {
if (fromA < span.from || toA > span.to) inside = false;
});
return inside;
}
/** Linked editing: an edit inside the name is repeated on every other occurrence. */
const mirror = EditorState.transactionFilter.of((tr) => {
const s = tr.startState.field(sessionField, false);
if (!s || !tr.docChanged || tr.annotation(inplaceEdit) || isExternalChange(tr)) return tr;
if (!changesWithin(tr.changes, s.primary)) return tr;
const primary = mapSpan(s.primary, tr.changes);
const name = tr.newDoc.sliceString(primary.from, primary.to);
const changes = s.others.map((o) => ({ ...mapSpan(o, tr.changes), insert: name }));
return [tr, { changes, sequential: true, annotations: [inplaceEdit.of(true), Transaction.addToHistory.of(false)] }];
});
export function inplaceSession(state: EditorState): InplaceSession | null {
return state.field(sessionField, false) ?? null;
}
export function inplaceRenameActive(state: EditorState): boolean {
return !!inplaceSession(state);
}
/** Starts editing `primary` in place, its `others` linked; the name is preselected (IDEA's "Preselect old name"). */
export function startInplaceRename(view: EditorView, session: Omit<InplaceSession, "oldName">): void {
const oldName = view.state.sliceDoc(session.primary.from, session.primary.to);
view.dispatch({
effects: startSession.of({ ...session, oldName }),
selection: EditorSelection.range(session.primary.from, session.primary.to),
scrollIntoView: true,
});
}
/**
* Ends the session, putting the old name back everywhere. Returns the name
* that was typed (null without a session). `commit` then runs the rename.
*/
export function endInplaceRename(view: EditorView, how: "commit" | "cancel" | "restore"): string | null {
const s = inplaceSession(view.state);
if (!s) return null;
const typed = view.state.sliceDoc(s.primary.from, s.primary.to);
const spans = [s.primary, ...s.others].sort((a, b) => a.from - b.from);
const changes = view.state.changes(spans.map((o) => ({ ...o, insert: s.oldName })));
const head = view.state.selection.main.head;
const inName = Math.min(Math.max(0, head - s.primary.from), s.oldName.length);
const caret = changes.mapPos(s.primary.from, -1) + inName;
const insideName = head >= s.primary.from && head <= s.primary.to;
view.dispatch({
changes,
effects: endSession.of(null),
// Leaving the name keeps the caret where the user put it.
selection: insideName ? EditorSelection.cursor(caret) : undefined,
annotations: [inplaceEdit.of(true), Transaction.addToHistory.of(false)],
});
if (how === "commit") s.commit(typed);
return typed;
}
/** Leaving the name (caret or an edit elsewhere) finishes the rename, like IDEA's template. */
const watcher = ViewPlugin.fromClass(
class {
constructor(private view: EditorView) {}
update(u: ViewUpdate) {
const s = inplaceSession(u.state);
if (!s || !inplaceSession(u.startState)) return;
const head = u.state.selection.main.head;
const outside = head < s.primary.from || head > s.primary.to || u.state.selection.ranges.length > 1;
const foreignEdit = u.transactions.some((tr) => {
const before = inplaceSession(tr.startState);
return !!before && tr.docChanged && !tr.annotation(inplaceEdit) && !isExternalChange(tr) && !changesWithin(tr.changes, before.primary);
});
if (outside || foreignEdit) queueMicrotask(() => endInplaceRename(this.view, "commit"));
}
},
);
const keys = Prec.highest(
keymap.of([
{ key: "Enter", run: (v) => endInplaceRename(v, "commit") !== null },
{ key: "Tab", run: (v) => endInplaceRename(v, "commit") !== null },
{ key: "Escape", run: (v) => endInplaceRename(v, "cancel") !== null },
// Undo while renaming restores the old name instead of undoing earlier edits.
{ key: "Mod-z", run: (v) => endInplaceRename(v, "cancel") !== null },
]),
);
export function inplaceRenameExtension(): Extension {
return [sessionField, mirror, watcher, keys];
}
+47 -2
View File
@@ -37,6 +37,51 @@ async function fetchActions(view: EditorView, only: string[], whole = false): Pr
return lspRequest(() => commands.lspCodeActions(doc.doc, range, only));
}
interface PlacedProblem {
from: number;
to: number;
severity: string;
}
/**
* As in IDEA, when no error touches the caret, the fixes of an
* error elsewhere on the caret line are offered too. The errors to ask about,
* in line order; none when an error is at the caret.
*/
export function lineErrorsAwayFromCaret(problems: readonly PlacedProblem[], lineFrom: number, lineTo: number, caret: number): PlacedProblem[] {
const errors = problems.filter((p) => p.severity === "error");
if (errors.some((p) => p.from <= caret && caret <= p.to)) return [];
return errors.filter((p) => p.from >= lineFrom && p.from <= lineTo).sort((a, b) => a.from - b.from);
}
/** ⌥↵ / lightbulb actions: those at the caret plus, as in IDEA, quick fixes of another error on the line. */
async function fetchIntentions(view: EditorView): Promise<CodeAction[]> {
const actions = (await fetchActions(view, [])).filter((a) => !a.disabledReason);
const doc = docOf(view);
const state = view.state;
const sel = state.selection.main;
if (!doc || !sel.empty) return actions;
const line = state.doc.lineAt(sel.head);
const problems: PlacedProblem[] = [];
forEachDiagnostic(state, (d, from, to) => problems.push({ from, to, severity: d.severity }));
const titles = new Set(actions.map((a) => a.title));
for (const p of lineErrorsAwayFromCaret(problems, line.from, line.to, sel.head)) {
let more: CodeAction[];
try {
more = await lspRequest(() => commands.lspCodeActions(doc.doc, offsetsToRange(state.doc, p.from, p.to), ["quickfix"]));
} catch {
break;
}
if (view.state.doc !== state.doc) break;
const fixes = more.filter((a) => isQuickFix(a) && !a.disabledReason && !titles.has(a.title));
if (fixes.length) {
actions.push(...fixes);
break;
}
}
return actions;
}
export async function applyCodeAction(view: EditorView, action: CodeAction) {
const doc = docOf(view);
if (!doc) return;
@@ -91,7 +136,7 @@ function actionItems(view: EditorView, actions: CodeAction[]): ListPopupItem[] {
export async function showIntentions(view: EditorView) {
try {
// Like IDEA, only applicable intentions are listed (servers also report disabled refactorings).
const actions = (await fetchActions(view, [])).filter((a) => !a.disabledReason);
const actions = await fetchIntentions(view);
if (!actions.length) {
showHint(view, "No intentions available");
return;
@@ -205,7 +250,7 @@ const bulbPlugin = ViewPlugin.fromClass(
if (!doc || doc.readOnly || serverBackoff.active(doc.doc) || !this.view.dom.isConnected) return;
const state = this.view.state;
try {
const actions = (await fetchActions(this.view, [])).filter((a) => !a.disabledReason);
const actions = await fetchIntentions(this.view);
if (this.view.state.doc !== state.doc || !this.view.state.selection.eq(state.selection)) return;
const line = state.doc.lineAt(state.selection.main.head).number;
this.view.dispatch({
+17
View File
@@ -68,6 +68,23 @@ export function isEmptyMarkup(m: Markup | null | undefined): boolean {
return !m || !m.value.trim();
}
/**
* The declaration line(s) of hover documentation, for the brief ⌘-hover info: the first fenced code block, else the first paragraph.
*/
export function declarationSummary(m: Markup, maxLines = 6): { code: string; language: string | null } | { text: string } | null {
if (m.kind === "markdown") {
const fence = /^\s*(```|~~~)[ \t]*([\w+#.-]*)[^\n]*\n([\s\S]*?)\n\s*\1/m.exec(m.value);
if (fence && fence[3]!.trim()) {
const lines = fence[3]!.replace(/\s+$/, "").split("\n");
const code = lines.slice(0, maxLines).join("\n") + (lines.length > maxLines ? "\n…" : "");
return { code, language: fence[2] || null };
}
}
const para = m.value.trim().split(/\n\s*\n/)[0]?.trim();
if (!para) return null;
return { text: para.length > 200 ? `${para.slice(0, 199)}…` : para };
}
const langCache = new Map<string, LanguageDescription | null>();
function findLanguage(name: string): LanguageDescription | null {
+181 -42
View File
@@ -4,8 +4,17 @@
* chooser, and document highlights (occurrences of the symbol at the caret,
* plus the sticky find.highlightUsages variant).
*/
import { EditorSelection, Prec, StateEffect, StateField, type Extension } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, keymap, type DecorationSet, type ViewUpdate } from "@codemirror/view";
import { EditorSelection, Prec, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import {
Decoration,
EditorView,
ViewPlugin,
keymap,
showTooltip,
type DecorationSet,
type Tooltip,
type ViewUpdate,
} from "@codemirror/view";
import {
commands,
isCancelled,
@@ -36,9 +45,12 @@ import { caretAnchor, openPopup, type ListPopupItem } from "./popupStore";
import { readManyFileLines } from "./files";
import { documentLinks, followReference, knownLinks, linkReference, referenceAt, textReference } from "./links";
import { serverBackoff } from "./availability";
import { requestHover } from "./hover";
import { declarationSummary, renderCode } from "./markdown";
import { editorPrefs } from "@/platform/editorSettings";
import { isVirtualUri, virtualContainer } from "@/platform/virtualDocs";
import { hasPrimaryModifier, isMacHost } from "@/platform/keymap";
import { hasPrimaryModifier, isMacHost, shortcutLabel } from "@/platform/keymap";
import { create } from "zustand";
export async function navigateTo(loc: Location) {
await editorService().openFile(loc.path, {
@@ -227,49 +239,134 @@ async function gotoFallback(view: EditorView, doc: EditorDocContext, pos: Pos):
// ---------------------------------------------------------------------------
// ⌘-hover link + ⌘-click
const setLink = StateEffect.define<{ from: number; to: number } | null>();
type NavInfo = NonNullable<ReturnType<typeof declarationSummary>>;
interface LinkState {
from: number;
to: number;
/** Brief declaration info shown above the underlined element (IDEA's ⌘-hover tooltip). */
info: NavInfo | null;
}
const setLink = StateEffect.define<LinkState | null>();
const linkMark = Decoration.mark({ class: "cm-lsp-link" });
const linkField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
for (const e of tr.effects)
if (e.is(setLink)) return e.value ? Decoration.set([linkMark.range(e.value.from, e.value.to)]) : Decoration.none;
return tr.docChanged ? Decoration.none : deco;
const linkField = StateField.define<LinkState | null>({
create: () => null,
update(value, tr) {
for (const e of tr.effects) if (e.is(setLink)) return e.value;
return tr.docChanged ? null : value;
},
provide: (f) => EditorView.decorations.from(f),
provide: (f) => [
EditorView.decorations.from(f, (v) => (v ? Decoration.set([linkMark.range(v.from, v.to)]) : Decoration.none)),
showTooltip.from(f, (v): Tooltip | null => {
const info = v?.info;
if (!v || !info) return null;
return {
pos: v.from,
above: true,
create: (view) => {
const dom = document.createElement("div");
dom.className = "cm-lsp-hover cm-lsp-nav-info";
if ("code" in info) dom.appendChild(renderCode(info.code, info.language ?? docOf(view)?.uiMode ?? null, "cm-lsp-code"));
else dom.textContent = info.text;
return { dom };
},
};
}),
],
});
/** What ⌘-hover underlines: a whole link, URL or path, else the word. */
function linkAt(view: EditorView, x: number, y: number) {
/** What ⌘-hover underlines: a whole link, URL or path (always navigable), else the word. */
function linkAt(view: EditorView, x: number, y: number): { from: number; to: number; link: boolean } | null {
const pos = view.posAtCoords({ x, y }, false);
if (pos == null) return null;
const links = knownLinks(view);
const ref = (links && linkReference(view.state.doc, links, pos)) ?? textReference(view.state.doc, pos);
if (ref) return ref;
if (ref) return { from: ref.from, to: ref.to, link: true };
const word = view.state.wordAt(pos);
return word && word.from < word.to ? word : null;
return word && word.from < word.to ? { from: word.from, to: word.to, link: false } : null;
}
/**
* Whether ⌘-click at `offset` would navigate somewhere, with the declaration
* info to show; null when there is no target (no underline then, as in IDEA).
*/
async function probeNavigation(view: EditorView, offset: number): Promise<{ info: NavInfo | null } | null> {
const doc = docOf(view);
let navigable = !!contributedAt<ContributedDefinition>(view.state, CONTRIBUTED_DEFINITION, offset);
if (!navigable && doc) {
const pos = offsetToPos(view.state.doc, offset);
try {
navigable = (await lspRequest(() => commands.lspGoto(doc.doc, pos, "definition"))).length > 0;
} catch (e) {
if (isCancelled(e)) return null;
if (isUnavailable(e)) navigable = !!(await gotoFallback(view, doc, pos))?.length;
}
if (!navigable && WEB_LANGUAGES.has(doc.language) && !doc.virtual)
navigable = !!(await lspRequest(() => commands.webGoto(doc.doc, pos)).catch(() => null))?.targets.length;
}
if (!navigable) return null;
const hover = await requestHover(view, offset).catch(() => null);
return { info: hover ? declarationSummary(hover.contents) : null };
}
const linkPlugin = ViewPlugin.fromClass(
class {
last: { x: number; y: number } | null = null;
/** Probe results per word (`from:to`) for the current document. */
private probed = new Map<string, { info: NavInfo | null } | null>();
private probedDoc: Text | null = null;
private probing: string | null = null;
constructor(readonly view: EditorView) {}
show(meta: boolean) {
const doc = meta ? docOf(this.view) : null;
// Underline whole links once the server's links are in.
if (doc && !doc.virtual && !knownLinks(this.view))
void documentLinks(this.view, doc).then(() => {
if (this.view.state.field(linkField).size) this.show(true);
});
private set(value: LinkState | null) {
const current = this.view.state.field(linkField);
const target = meta && this.last ? linkAt(this.view, this.last.x, this.last.y) : null;
let same = false;
current.between(0, this.view.state.doc.length, (from, to) => {
same = !!target && target.from === from && target.to === to;
if (current === value || (current && value && current.from === value.from && current.to === value.to && current.info === value.info))
return;
if (!current && !value) return;
this.view.dispatch({ effects: setLink.of(value) });
}
show(meta: boolean) {
const view = this.view;
const doc = meta ? docOf(view) : null;
// Underline whole links once the server's links are in.
if (doc && !doc.virtual && !knownLinks(view))
void documentLinks(view, doc).then(() => {
if (view.state.field(linkField)) this.show(true);
});
const target = meta && this.last ? linkAt(view, this.last.x, this.last.y) : null;
if (!target) {
this.probing = null;
this.set(null);
return;
}
if (target.link) {
this.probing = null;
this.set({ from: target.from, to: target.to, info: null });
return;
}
if (this.probedDoc !== view.state.doc) {
this.probed.clear();
this.probedDoc = view.state.doc;
}
const key = `${target.from}:${target.to}`;
if (this.probed.has(key)) {
const hit = this.probed.get(key);
this.set(hit ? { from: target.from, to: target.to, info: hit.info } : null);
return;
}
const current = view.state.field(linkField);
if (current && (current.from !== target.from || current.to !== target.to)) this.set(null);
if (this.probing === key) return;
this.probing = key;
const docAt = view.state.doc;
void probeNavigation(view, target.from).then((result) => {
if (view.state.doc !== docAt) return;
this.probed.set(key, result);
if (this.probing !== key) return;
this.probing = null;
if (result) this.set({ from: target.from, to: target.to, info: result.info });
});
if (same || (!target && current.size === 0)) return;
this.view.dispatch({ effects: setLink.of(target) });
}
},
{
@@ -314,7 +411,9 @@ interface HighlightState {
sticky: boolean;
}
const setHighlights = StateEffect.define<{ items: Array<{ from: number; to: number; kind: Highlight["kind"] }>; sticky: boolean }>();
type HighlightItem = { from: number; to: number; kind: Highlight["kind"] };
const setHighlights = StateEffect.define<{ items: HighlightItem[]; sticky: boolean }>();
const marks = {
text: Decoration.mark({ class: "cm-lsp-occurrence" }),
@@ -334,20 +433,48 @@ const highlightField = StateField.define<HighlightState>({
),
sticky: e.value.sticky,
};
if (tr.docChanged) return { deco: Decoration.none, sticky: false };
// Kept (mapped) until recomputed; ⌘⇧F7 highlights survive edits until Escape.
if (tr.docChanged) return { deco: value.deco.map(tr.changes), sticky: value.sticky };
return value;
},
provide: (f) => EditorView.decorations.from(f, (v) => v.deco),
});
/** IDEA's status bar text while ⌘⇧F7 highlights are shown. */
export const useUsagesStatus = create<{ text: string | null; owner: EditorView | null }>(() => ({ text: null, owner: null }));
function setUsagesStatus(owner: EditorView | null, text: string | null) {
useUsagesStatus.setState({ owner, text });
}
export function usagesStatusText(count: number, name: string, toggleKeys: string | null): string {
const again = toggleKeys ? `press ${toggleKeys} again to remove the highlighting, ` : "";
return `${count} ${count === 1 ? "usage" : "usages"} of ${name} found (${again}Escape to remove all highlighting)`;
}
function clearHighlights(view: EditorView) {
view.dispatch({ effects: setHighlights.of({ items: [], sticky: false }) });
if (useUsagesStatus.getState().owner === view) setUsagesStatus(null, null);
}
async function fetchHighlights(view: EditorView, sticky: boolean) {
const doc = docOf(view);
if (!doc) return;
await whenSynced(view);
const state = view.state;
const head = state.selection.main.head;
const stale = () => view.state.doc !== state.doc || view.state.selection.main.head !== head;
const apply = (items: Array<{ from: number; to: number; kind: Highlight["kind"] }>) =>
view.dispatch({ effects: setHighlights.of({ items, sticky }) });
const apply = (items: HighlightItem[]) => {
view.dispatch({ effects: setHighlights.of({ items, sticky: sticky && items.length > 0 }) });
if (!sticky) return;
if (!items.length) {
if (useUsagesStatus.getState().owner === view) setUsagesStatus(null, null);
return;
}
const at = items.find((i) => i.from <= head && head <= i.to) ?? state.wordAt(head);
const name = at ? state.sliceDoc(at.from, at.to) : "";
setUsagesStatus(view, usagesStatusText(items.length, name ? `'${name}'` : "the element", shortcutLabel("find.highlightUsages") ?? null));
};
let lspError: unknown = null;
if (!serverBackoff.active(doc.doc)) {
try {
@@ -388,8 +515,18 @@ async function syntaxHighlights(view: EditorView, doc: EditorDocContext, head: n
}
}
/** ⌘⇧F7: highlight usages of the symbol at the caret until Escape. */
/** ⌘⇧F7: highlight usages of the symbol at the caret until Escape; again on a highlighted usage removes them. */
export function highlightUsages(view: EditorView) {
const st = view.state.field(highlightField, false);
const head = view.state.selection.main.head;
let onUsage = false;
st?.deco.between(head, head, (from, to) => {
if (from <= head && head <= to) onUsage = true;
});
if (st?.sticky && onUsage) {
clearHighlights(view);
return;
}
void fetchHighlights(view, true);
}
@@ -398,25 +535,27 @@ const highlightPlugin = ViewPlugin.fromClass(
private request = debounce(() => void fetchHighlights(this.view, false), 300);
constructor(private view: EditorView) {}
update(u: ViewUpdate) {
if (u.docChanged) this.request.cancel();
if (!u.selectionSet || u.docChanged) return;
// Recomputed after caret moves and, like IDEA's daemon, after edits.
if (!u.selectionSet && !u.docChanged) return;
const st = u.state.field(highlightField);
if (st.sticky) return;
// Code Editing › "Usages of element at caret".
if (!editorPrefs().codeEditing.highlightUsages) {
if (st.deco.size) queueMicrotask(() => this.view.dispatch({ effects: setHighlights.of({ items: [], sticky: false }) }));
if (st.sticky) {
this.request.cancel();
return;
}
const sel = u.state.selection.main;
if (!sel.empty) {
const clear = () => {
this.request.cancel();
if (st.deco.size) queueMicrotask(() => this.view.dispatch({ effects: setHighlights.of({ items: [], sticky: false }) }));
};
// Code Editing › "Usages of element at caret".
if (!editorPrefs().codeEditing.highlightUsages || !u.state.selection.main.empty) {
clear();
return;
}
this.request();
}
destroy() {
this.request.cancel();
if (useUsagesStatus.getState().owner === this.view) setUsagesStatus(null, null);
}
},
);
@@ -434,7 +573,7 @@ export function navigationExtension(): Extension {
run: (view) => {
const st = view.state.field(highlightField, false);
if (!st?.sticky) return false;
view.dispatch({ effects: setHighlights.of({ items: [], sticky: false }) });
clearHighlights(view);
return true;
},
},
+7 -1
View File
@@ -75,9 +75,15 @@ interface PopupState {
popup: LspPopup | null;
/** Bumped per open so React remounts a popup of the same kind. */
seq: number;
/** Bumped to move focus into the open popup (F1 on an open documentation popup). */
focusSeq: number;
}
export const useLspPopup = create<PopupState>(() => ({ popup: null, seq: 0 }));
export const useLspPopup = create<PopupState>(() => ({ popup: null, seq: 0, focusSeq: 0 }));
export function focusPopup() {
useLspPopup.setState((s) => ({ focusSeq: s.focusSeq + 1 }));
}
export function openPopup(popup: LspPopup) {
useLspPopup.setState((s) => ({ popup, seq: s.seq + 1 }));
+72 -9
View File
@@ -1,7 +1,8 @@
/**
* Rename (⇧F6): `lsp_prepare_rename` → inline name popup at the symbol →
* `lsp_rename` → preview dialog when the edit spans several files (or moves
* files), otherwise applied directly via `apply_workspace_edit`.
* Rename (⇧F6): `lsp_prepare_rename` → the name is edited in place with this
* file's occurrences linked (`inplaceRename.ts`; ⇧F6 again opens the name
* dialog) → `lsp_rename` → preview dialog when the edit spans several files
* (or moves files), otherwise applied directly via `apply_workspace_edit`.
*
* Dumb mode: when the server is not ready / failing, or answers nothing over
* code with syntax errors, a *local* variable is renamed in place from the
@@ -18,6 +19,8 @@ import { comparePos, offsetToPos, rangeToOffsets } from "./positions";
import { showHint } from "./hint";
import { caretAnchor, openPopup, type PreviewOccurrence } from "./popupStore";
import { needsPreview, summarizeWorkspaceEdit } from "./workspaceEdit";
import { endInplaceRename, inplaceSession, startInplaceRename, type Span } from "./inplaceRename";
import { shortcutLabel } from "@/platform/keymap";
import {
SYNTAX_NOTE,
VALID_NAME,
@@ -46,6 +49,13 @@ async function hasSyntaxErrors(doc: number): Promise<boolean> {
export async function startRename(view: EditorView) {
const doc = docOf(view);
if (!doc) return;
// ⇧F6 while renaming in place: the dialog with more options, keeping what was typed.
const session = inplaceSession(view.state);
if (session) {
const typed = endInplaceRename(view, "restore");
if (typed !== null) session.dialog(typed);
return;
}
if (doc.readOnly) {
showHint(view, "The file is read-only");
return;
@@ -69,15 +79,68 @@ export async function startRename(view: EditorView) {
}
const { from, to } = rangeToOffsets(view.state.doc, range);
const oldName = view.state.sliceDoc(from, to);
openPopup({
kind: "rename",
anchor: caretAnchor(view, from),
title: `Rename '${oldName}'`,
initial: oldName,
onSubmit: (newName) => void finishRename(view, doc, pos, oldName, newName),
const dialog = (initial: string) =>
openPopup({
kind: "rename",
anchor: caretAnchor(view, from),
title: `Rename '${oldName}'`,
initial,
onSubmit: (newName) => void finishRename(view, doc, pos, oldName, newName),
});
if (from === to) {
dialog(oldName);
return;
}
// IDEA renames in place by default: occurrences in this file follow live.
const state = view.state;
const others = await linkedOccurrences(view, doc.doc, pos, { from, to }, oldName);
if (view.state.doc !== state.doc) return;
const keys = shortcutLabel("refactor.rename");
startInplaceRename(view, {
primary: { from, to },
others,
hint: keys ? `Press ${keys} to show dialog with more options` : "Rename in place: ↵ to finish, Esc to cancel",
commit: (newName) => void commitInplace(view, doc, pos, oldName, newName),
dialog,
});
}
/** This file's other occurrences of the symbol (document highlights with the same text). */
async function linkedOccurrences(view: EditorView, doc: number, pos: Pos, primary: Span, name: string): Promise<Span[]> {
let highlights;
try {
highlights = await lspRequest(() => commands.lspDocumentHighlights(doc, pos));
} catch {
return [];
}
const text = view.state.doc;
return linkedSpans(
highlights.map((h) => rangeToOffsets(text, h.range)),
primary,
(s) => text.sliceString(s.from, s.to) === name,
);
}
/** Occurrence spans other than `primary`, sorted, without overlaps, that `accept` keeps. */
export function linkedSpans(spans: readonly Span[], primary: Span, accept: (s: Span) => boolean): Span[] {
const out: Span[] = [];
for (const s of [...spans].sort((a, b) => a.from - b.from)) {
if (s.to > primary.from && s.from < primary.to) continue;
if (s.from >= s.to || !accept(s)) continue;
const last = out[out.length - 1];
if (last && s.from < last.to) continue;
out.push(s);
}
return out;
}
async function commitInplace(view: EditorView, doc: EditorDocContext, pos: Pos, oldName: string, newName: string) {
if (!newName.trim() || newName === oldName) return;
// The server renames from the original text, which the session just restored.
await whenSynced(view);
await finishRename(view, doc, pos, oldName, newName);
}
async function finishRename(view: EditorView, doc: EditorDocContext, pos: Pos, oldName: string, newName: string) {
if (!newName || newName === oldName) return;
let edit: WorkspaceEdit;
+12
View File
@@ -255,6 +255,18 @@ export const lspTheme = EditorView.theme({
".cm-lsp-occurrence-write": {
backgroundColor: "var(--bg-identifier-under-caret-write, color-mix(in srgb, var(--danger) 12%, transparent))",
},
".cm-lsp-nav-info": { padding: "4px 8px" },
// --- in-place rename (inplaceRename.ts) ------------------------------------
".cm-lsp-inplace": {
backgroundColor: "var(--bg-identifier-under-caret, color-mix(in srgb, var(--accent) 14%, transparent))",
},
".cm-lsp-inplace-primary": {
outline: "1px solid var(--focus-ring)",
outlineOffset: "-1px",
borderRadius: "2px",
},
".cm-lsp-inplace-hint": { color: "var(--fg-secondary)", fontSize: "var(--fs-small)", padding: "3px 8px" },
// --- floating lightbulb (intentions.ts) -----------------------------------
".cm-lsp-bulb": {
+1 -1
View File
@@ -56,7 +56,7 @@ async function gotoMethod(view: EditorView, dir: 1 | -1) {
const target = methodStop(at.outline, at.caret, dir);
if (!target) return;
const pos = posToOffset(view.state.doc, target);
view.dispatch({ selection: EditorSelection.cursor(pos), scrollIntoView: true, userEvent: "select" });
view.dispatch({ selection: EditorSelection.cursor(pos), scrollIntoView: true, userEvent: "select.navigate" });
}
/** Without a type hierarchy: languages whose servers answer "super" through another request. */
+40 -1
View File
@@ -79,7 +79,7 @@
color: var(--fg);
background: var(--bg-app);
-webkit-user-select: text;
cursor: text;
cursor: text;
-webkit-user-select: text;
user-select: text;
outline: none;
@@ -115,3 +115,42 @@ cursor: text;
height: 30px;
border-radius: 6px;
}
.treeHost {
display: flex;
flex-direction: column;
}
.treeHost > * {
flex: 1;
min-height: 0;
}
.detail {
overflow: auto;
padding: 4px 12px;
font-family: var(--font-mono);
font-size: var(--fs-code);
line-height: 20px;
color: var(--fg);
background: var(--bg-app);
-webkit-user-select: text;
user-select: text;
outline: none;
}
.detailLocation {
display: block;
padding: 0;
border: none;
background: none;
font: inherit;
color: var(--link);
cursor: pointer;
text-align: left;
}
.detailLocation:hover {
color: var(--link-hover);
text-decoration: underline;
}
.detailText {
white-space: pre-wrap;
tab-size: 4;
}
+93 -41
View File
@@ -1,13 +1,13 @@
/**
* Build tool window (IDEA's Build view): the build → file → message tree on
* the left, the raw console of the build on the right. Selecting a message
* shows its source; Enter / double-click moves focus there.
* shows its full text on the right instead; Enter / double-click / F4 opens
* its source (as IDEA: selection shows, navigation jumps).
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx";
import { useVirtualizer } from "@tanstack/react-virtual";
import { basename, relativeDir, useProject } from "@/platform/project";
import { editorService } from "@/workbench/contributions";
import {
EmptyState,
Icon,
@@ -18,6 +18,7 @@ import {
Spinner,
Tree,
fileIconName,
type TreeHandle,
type TreeRowState,
T,
} from "@/ui";
@@ -25,6 +26,7 @@ import type { BuildMessage, Severity } from "@/platform/ipc";
import {
ROOT_KEY,
fileChildren,
findNode,
finishSummary,
formatDuration,
rootChildren,
@@ -32,21 +34,11 @@ import {
type BuildNode,
type BuildStatus,
} from "./model";
import { rerunBuild, useBuildRun } from "./store";
import { navigateToMessage, rerunBuild, selectBuildNode, useBuildRun } from "./store";
import styles from "./Build.module.css";
const SEVERITY_ICON: Record<Severity, string> = { error: "error", warning: "warning", info: "info", hint: "info" };
function openMessage(m: BuildMessage, focus: boolean) {
if (!m.path) return;
const start = m.range?.start;
void editorService().openFile(m.path, {
selection: start ? { line: start.line, col: start.col } : undefined,
focus,
preview: !focus,
});
}
function firstLocated(n: BuildNode): BuildMessage | undefined {
if (n.kind === "message") return n.message;
if (n.kind === "file") return n.messages[0];
@@ -79,9 +71,11 @@ export function BuildHeaderActions() {
}
function BuildTree() {
const { title, status, messages, durationMs } = useBuildRun();
const { title, status, messages, durationMs, selected } = useBuildRun();
const root = useProject((s) => s.project?.root ?? "");
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const tree = useRef<TreeHandle>(null);
const selection = useMemo(() => new Set(selected ? [selected] : []), [selected]);
const roots = useMemo(() => (status ? [rootNode(title, status, messages)] : []), [title, status, messages]);
const children = useMemo(() => rootChildren(messages), [messages]);
const expanded = useMemo(() => {
@@ -91,6 +85,27 @@ function BuildTree() {
return keys;
}, [children, collapsed]);
// A selection made by the store (the first error of a failed build) expands
// its file, then is scrolled into view.
const [shown, setShown] = useState(selected);
if (shown !== selected) {
setShown(selected);
const file = selected?.split("\u0000")[0];
if (file && file !== selected && collapsed.has(file)) {
const next = new Set(collapsed);
next.delete(file);
setCollapsed(next);
}
}
useEffect(() => {
if (selected && tree.current?.leadKey() !== selected) tree.current?.reveal(selected);
}, [selected, expanded]);
const navigate = (n: BuildNode) => {
const m = firstLocated(n);
if (m) navigateToMessage(m, true);
};
const getChildren = useCallback(
(n: BuildNode): BuildNode[] | undefined =>
n.kind === "root" ? children : n.kind === "file" ? fileChildren(n) : undefined,
@@ -143,33 +158,42 @@ function BuildTree() {
};
return (
<Tree<BuildNode>
aria-label="Build"
roots={roots}
getKey={(n) => n.key}
getLabel={(n) => (n.kind === "root" ? n.title : n.kind === "file" ? basename(n.path) : n.message.text)}
getChildren={getChildren}
isExpandable={(n) => n.kind !== "message"}
expanded={expanded}
onExpandedChange={(_next, node, isOpen) =>
setCollapsed((prev) => {
const s = new Set(prev);
if (isOpen) s.delete(node.key);
else s.add(node.key);
return s;
})
}
renderNode={renderNode}
onActivate={(n) => {
const m = firstLocated(n);
if (m) openMessage(m, true);
<div
className={styles.treeHost}
onKeyDown={(e) => {
if (e.key !== "F4" || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || !selected) return;
const n = findNode(messages, selected);
if (!n) return;
e.preventDefault();
e.stopPropagation();
navigate(n);
}}
onSelectionChange={(_sel, lead) => {
if (lead?.kind === "message") openMessage(lead.message, false);
}}
multiSelect={false}
empty={<EmptyState title="No build output" />}
/>
>
<Tree<BuildNode>
handleRef={tree}
aria-label="Build"
roots={roots}
getKey={(n) => n.key}
getLabel={(n) => (n.kind === "root" ? n.title : n.kind === "file" ? basename(n.path) : n.message.text)}
getChildren={getChildren}
isExpandable={(n) => n.kind !== "message"}
expanded={expanded}
onExpandedChange={(_next, node, isOpen) =>
setCollapsed((prev) => {
const s = new Set(prev);
if (isOpen) s.delete(node.key);
else s.add(node.key);
return s;
})
}
renderNode={renderNode}
selected={selection}
onActivate={navigate}
onSelectionChange={(_sel, lead) => selectBuildNode(lead?.key ?? null)}
multiSelect={false}
empty={<EmptyState title="No build output" />}
/>
</div>
);
}
@@ -218,6 +242,34 @@ function BuildConsole() {
);
}
/** Full text and location of the selected message (IDEA shows the node's own console). */
function MessageDetail({ message }: { message: BuildMessage }) {
const root = useProject((s) => s.project?.root ?? "");
const start = message.range?.start;
const location = message.path
? `${relativeDir(root, message.path) ? `${relativeDir(root, message.path)}/` : ""}${basename(message.path)}${
start ? `:${start.line + 1}:${start.col + 1}` : ""
}`
: null;
return (
<div className={styles.detail} tabIndex={0} role="document" aria-label="Build message">
{location && (
<button type="button" className={styles.detailLocation} onClick={() => navigateToMessage(message, true)}>
{location}
</button>
)}
<div className={clsx(styles.detailText, message.severity === "error" && styles.err)}>{message.text}</div>
</div>
);
}
function BuildOutput() {
const selected = useBuildRun((s) => s.selected);
const messages = useBuildRun((s) => s.messages);
const node = useMemo(() => (selected ? findNode(messages, selected) : undefined), [selected, messages]);
return node?.kind === "message" ? <MessageDetail message={node.message} /> : <BuildConsole />;
}
export function BuildView() {
const hasBuild = useBuildRun((s) => s.status !== null);
if (!hasBuild) {
@@ -237,7 +289,7 @@ export function BuildView() {
</SplitPanel>
<SplitHandle />
<SplitPanel id="build-console" minSize="120px" className={styles.pane}>
<BuildConsole />
<BuildOutput />
</SplitPanel>
</SplitGroup>
</div>
+70 -6
View File
@@ -1,15 +1,34 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { getAction, isActionEnabled } from "@/platform/actions";
import { getContext, setContext } from "@/platform/context";
import { bindingsFor } from "@/platform/keymap";
import { IpcError, type BuildEvent, type BuildMessage, type BuildResult } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { menuGroupItems, provideNotificationService, useContributions, type NotificationService } from "@/workbench/contributions";
import {
menuGroupItems,
provideEditorService,
provideNotificationService,
provideToolWindowService,
useContributions,
type EditorService,
type NotificationService,
type ToolWindowService,
} from "@/workbench/contributions";
import { useTasks } from "@/workbench/tasks";
import { BuildView } from "./BuildView";
import { activate } from "./index";
import { appendOutput, finishSummary, formatDuration, messageFiles, rootChildren, rootNode, MAX_CONSOLE_LINES } from "./model";
import {
appendOutput,
findNode,
finishSummary,
firstError,
formatDuration,
messageFiles,
rootChildren,
rootNode,
MAX_CONSOLE_LINES,
} from "./model";
import { BUILD_TASK_ID, setBuildBackend, startBuild, stopBuild, useBuildRun, type BuildBackend } from "./store";
const err = (path: string | null, line: number, text = "cannot find symbol"): BuildMessage => ({
@@ -30,6 +49,10 @@ describe("build model", () => {
expect(a.kind === "file" && a.messages.map((m) => m.range?.start.line)).toEqual([2, 9]);
expect(rootNode("demo", "failed", messages)).toMatchObject({ errors: 2, warnings: 2 });
expect(messageFiles(messages)).toEqual(["/p/B.java", "/p/A.java"]);
const first = firstError(messages)!;
expect(first.message.range?.start.line).toBe(9);
expect(findNode(messages, first.key)).toEqual(first);
expect(firstError([warn("/p/B.java", 3)])).toBeUndefined();
});
it("formats IDEA's finish summaries", () => {
@@ -37,12 +60,16 @@ describe("build model", () => {
expect(formatDuration(3000)).toBe("3 s");
expect(formatDuration(3120)).toBe("3 s 120 ms");
expect(formatDuration(65_000)).toBe("1 m 5 s");
expect(finishSummary({ success: true, errors: 0, warnings: 0, durationMs: 3000 })).toBe("Build completed successfully in 3 s");
expect(finishSummary({ success: true, errors: 0, warnings: 0, durationMs: 3000 })).toBe(
"Build completed successfully in 3 s",
);
expect(finishSummary({ success: true, errors: 0, warnings: 1, durationMs: 500 })).toBe(
"Build completed successfully with 1 warning in 500 ms",
);
expect(finishSummary({ success: false, errors: 2, warnings: 0, durationMs: 1 })).toBe("Build failed: 2 errors");
expect(finishSummary({ success: false, errors: 1, warnings: 2, durationMs: 1 })).toBe("Build failed: 1 error, 2 warnings");
expect(finishSummary({ success: false, errors: 1, warnings: 2, durationMs: 1 })).toBe(
"Build failed: 1 error, 2 warnings",
);
expect(finishSummary({ success: false, errors: 0, warnings: 0, durationMs: 1 })).toBe("Build failed");
});
@@ -97,9 +124,19 @@ describe("build feature", () => {
let dispose: () => void;
let notes: Note[];
let fake: FakeBackend;
let openFile: ReturnType<typeof vi.fn>;
let show: ReturnType<typeof vi.fn>;
beforeEach(() => {
notes = [];
openFile = vi.fn(() => Promise.resolve());
show = vi.fn();
provideEditorService({
openFile,
saveAll: () => Promise.resolve(),
activePath: () => null,
} as unknown as EditorService);
provideToolWindowService({ show } as unknown as ToolWindowService);
provideNotificationService({ notify: (n) => void notes.push(n) });
fake = new FakeBackend();
setBuildBackend(fake);
@@ -136,6 +173,7 @@ describe("build feature", () => {
await act(async () => {});
expect(getContext()["build.running"]).toBe(true);
expect(isActionEnabled(getAction("build.buildProject")!)).toBe(false);
expect(show).not.toHaveBeenCalled();
expect(useTasks.getState().tasks.some((t) => t.id === BUILD_TASK_ID && !t.done)).toBe(true);
const messages = [err("/p/src/A.java", 11), warn("/p/src/B.java", 2)];
await act(async () => {
@@ -157,6 +195,14 @@ describe("build feature", () => {
expect(screen.getByText("deprecated")).toBeInTheDocument();
expect(screen.getByText(":12:5")).toBeInTheDocument();
expect(notes.at(-1)).toMatchObject({ level: "error", title: "Build failed: 1 error, 1 warning" });
// A failure shows the tool window (unfocused), selects the first error and opens it.
expect(show).toHaveBeenCalledWith("build", false);
expect(useBuildRun.getState().selected).toBe(firstError(messages)!.key);
expect(openFile).toHaveBeenCalledWith(
"/p/src/A.java",
expect.objectContaining({ focus: true, selection: { line: 11, col: 4 } }),
);
expect(screen.getByRole("document", { name: "Build message" })).toHaveTextContent("cannot find symbol");
expect(getContext()["build.running"]).toBe(false);
const second = startBuild("rebuild");
@@ -167,6 +213,24 @@ describe("build feature", () => {
await second;
});
expect(notes.at(-1)).toMatchObject({ level: "info", title: "Build completed successfully in 3 s" });
expect(show).toHaveBeenCalledTimes(1);
});
it("shows a message on single click and navigates on F4", async () => {
const messages = [err("/p/src/A.java", 11, "first problem"), warn("/p/src/B.java", 2)];
const run = startBuild("build");
await act(async () => {});
await act(async () => {
fake.resolve(result(messages, true));
await run;
});
render(<BuildView />);
expect(screen.getByRole("log", { name: "Build output" })).toBeInTheDocument();
fireEvent.mouseDown(screen.getByText("deprecated"));
expect(openFile).not.toHaveBeenCalled();
expect(screen.getByRole("document", { name: "Build message" })).toHaveTextContent("deprecated");
fireEvent.keyDown(screen.getByRole("tree"), { key: "F4" });
expect(openFile).toHaveBeenCalledWith("/p/src/B.java", expect.objectContaining({ focus: true }));
});
it("builds the module of the given file and can be stopped", async () => {
+32 -2
View File
@@ -35,7 +35,14 @@ const count = (messages: readonly BuildMessage[], s: Severity) => messages.filte
/** Root node of the tree for the current build. */
export function rootNode(title: string, status: BuildStatus, messages: readonly BuildMessage[]): BuildNode {
return { kind: "root", key: ROOT_KEY, title, status, errors: count(messages, "error"), warnings: count(messages, "warning") };
return {
kind: "root",
key: ROOT_KEY,
title,
status,
errors: count(messages, "error"),
warnings: count(messages, "warning"),
};
}
/** Children of the root: files with messages (errors first), then messages about the build itself. */
@@ -61,7 +68,9 @@ export function rootChildren(messages: readonly BuildMessage[]): BuildNode[] {
),
}));
const worst = (n: BuildNode) => (n.kind === "file" ? Math.min(...n.messages.map((m) => RANK[m.severity])) : 0);
files.sort((a, b) => worst(a) - worst(b) || (a.kind === "file" && b.kind === "file" ? a.path.localeCompare(b.path) : 0));
files.sort(
(a, b) => worst(a) - worst(b) || (a.kind === "file" && b.kind === "file" ? a.path.localeCompare(b.path) : 0),
);
return [...files, ...general];
}
@@ -100,3 +109,24 @@ export function finishSummary(result: Pick<BuildResult, "success" | "errors" | "
export function messageFiles(messages: readonly BuildMessage[]): string[] {
return [...new Set(messages.filter((m) => m.path && m.range).map((m) => m.path!))];
}
/** The node with `key` in the tree of `messages` (root excluded). */
export function findNode(messages: readonly BuildMessage[], key: string): BuildNode | undefined {
for (const c of rootChildren(messages)) {
if (c.key === key) return c;
if (c.kind === "file") {
const hit = fileChildren(c).find((m) => m.key === key);
if (hit) return hit;
}
}
return undefined;
}
/** The first error in tree order, selected (and navigated to) when a build fails. */
export function firstError(messages: readonly BuildMessage[]): Extract<BuildNode, { kind: "message" }> | undefined {
for (const c of rootChildren(messages)) {
const leaves = c.kind === "file" ? fileChildren(c) : [c];
for (const n of leaves) if (n.kind === "message" && n.message.severity === "error") return n;
}
return undefined;
}
+49 -8
View File
@@ -4,6 +4,9 @@
* as a cancellable background task and a notification when it finishes.
* Compiler messages reach the Problems view and editors as `build`
* diagnostics, published (and cleared on the next build) by the backend.
*
* Like IDEA, the tool window is not shown when a build starts; a failed build
* shows it, selects the first error and opens its location in the editor.
*/
import { create } from "zustand";
import { Channel } from "@tauri-apps/api/core";
@@ -23,7 +26,7 @@ import {
import { useProject } from "@/platform/project";
import { maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions";
import { removeTask, upsertTask } from "@/workbench/tasks";
import { appendOutput, finishSummary, messageFiles, type BuildStatus, type ConsoleLine } from "./model";
import { appendOutput, finishSummary, firstError, messageFiles, type BuildStatus, type ConsoleLine } from "./model";
export const BUILD_TASK_ID = "build:compile";
export const BUILD_RUNNING = "build.running";
@@ -71,6 +74,8 @@ export interface BuildState {
lines: ConsoleLine[];
/** Files with diagnostics from the last build. */
files: string[];
/** Key of the selected tree node (a message node shows its detail beside the tree). */
selected: string | null;
}
const INITIAL: BuildState = {
@@ -85,6 +90,7 @@ const INITIAL: BuildState = {
messages: [],
lines: [],
files: [],
selected: null,
};
export const useBuildRun = create<BuildState>(() => INITIAL);
@@ -124,11 +130,6 @@ export async function startBuild(kind: BuildKind, file: string | null = null): P
useBuildRun.setState({ ...INITIAL, request, kind, file, title: taskTitle, status: "running", files: previous });
setContext(BUILD_RUNNING, true);
upsertTask({ id: BUILD_TASK_ID, title: taskTitle, message: null, fraction: null, done: false, cancel });
try {
toolWindowService().show("build", false);
} catch {
// No workbench (tests / settings window).
}
await maybeEditorService()
?.saveAll()
.catch(() => undefined);
@@ -173,8 +174,12 @@ export async function startBuild(kind: BuildKind, file: string | null = null): P
title: summary,
key: "build-finished",
sticky: false,
actions: result.success && !result.warnings ? undefined : [{ label: "Show Build", run: () => toolWindowService().show("build", true) }],
actions:
result.success && !result.warnings
? undefined
: [{ label: "Show Build", run: () => toolWindowService().show("build", true) }],
});
if (!result.success) showFailure();
} catch (e) {
clearTimeout(timer);
flush();
@@ -189,7 +194,14 @@ export async function startBuild(kind: BuildKind, file: string | null = null): P
lines: appendOutput(s.lines, `${text}\n`, "err"),
messages: [...s.messages, { severity: "error", text, path: null, range: null, code: null }],
}));
notifications().notify({ group: "run", level: "error", title: "Build failed", body: text, key: "build-finished" });
notifications().notify({
group: "run",
level: "error",
title: "Build failed",
body: text,
key: "build-finished",
});
showFailure();
}
} finally {
if (useBuildRun.getState().request === request) useBuildRun.setState({ request: null });
@@ -199,6 +211,35 @@ export async function startBuild(kind: BuildKind, file: string | null = null): P
return result;
}
/** Opens the source of a build message; `focus` moves focus to the editor (otherwise a preview). */
export function navigateToMessage(m: BuildMessage, focus: boolean) {
if (!m.path) return;
const start = m.range?.start;
void maybeEditorService()
?.openFile(m.path, { selection: start ? { line: start.line, col: start.col } : undefined, focus, preview: !focus })
.catch(() => undefined);
}
export function selectBuildNode(key: string | null) {
if (useBuildRun.getState().selected !== key) useBuildRun.setState({ selected: key });
}
/**
* A failed build activates the tool window without focusing it, selects the
* first error and navigates to it, as IDEA does by default.
*/
function showFailure() {
try {
toolWindowService().show("build", false);
} catch {
// No workbench (tests / settings window).
}
const first = firstError(useBuildRun.getState().messages);
if (!first) return;
selectBuildNode(first.key);
navigateToMessage(first.message, true);
}
export function stopBuild() {
const { request } = useBuildRun.getState();
if (request != null) backend.cancel(request);
@@ -140,7 +140,7 @@ describe("debugger actions (IDEA parity)", () => {
"debug.forceRunToCursor": ["Meta+Alt+F9"],
"debug.showExecutionPoint": ["Alt+F10"],
"debug.setValue": ["F2"],
"debug.jumpToSource": ["F4"],
"debug.jumpToSource": ["F4", "Meta+Down"],
"debug.resetFrame": undefined,
"debug.toggleMethodBreakpoint": undefined,
"debug.toggleFieldWatchpoint": undefined,
@@ -250,4 +250,21 @@ describe("debugger actions (IDEA parity)", () => {
expect(ipc.debugAttachableProcesses).toHaveBeenCalled();
expect(useOverlays.getState().attach?.processes?.map((p) => p.pid)).toEqual([9]);
});
it("Evaluate prefills the expression at the caret, or the whole selection", async () => {
paused();
const doc = "total = order.items\nfor i in x:\n pass";
editor.current = {
ctx: { path: "/p/a.py", language: "python" },
view: { state: EditorState.create({ doc, selection: { anchor: doc.indexOf("items") + 2 } }) },
};
await executeAction("debug.evaluate");
expect(useOverlays.getState().evaluate?.expression).toBe("order.items");
editor.current = {
ctx: { path: "/p/a.py", language: "python" },
view: { state: EditorState.create({ doc, selection: { anchor: 0, head: doc.indexOf("\n pass") } }) },
};
await executeAction("debug.evaluate");
expect(useOverlays.getState().evaluate?.expression).toBe("total = order.items\nfor i in x:");
});
});
@@ -1,6 +1,6 @@
import { describe, expect, it, beforeEach } from "vitest";
import { ChangeSet, EditorState, Text } from "@codemirror/state";
import { isConditional, makeBreakpoint, mapLines, toBackend, toggleLine } from "../breakpoints/model";
import { interpolateLog, isConditional, makeBreakpoint, mapLines, toBackend, toggleLine } from "../breakpoints/model";
import {
addBreakpoint,
breakpointsOf,
@@ -85,6 +85,56 @@ describe("breakpoint model", () => {
});
});
describe("suspend and logging (IDEA's independent options)", () => {
it("sends non-suspending breakpoints as logpoints with the hit message and the evaluated template", () => {
const list = [
makeBreakpoint(1, { suspend: false, logHit: true }),
makeBreakpoint(2, { suspend: false, logHit: true, logMessage: "i={i}" }),
makeBreakpoint(3, { suspend: false }),
makeBreakpoint(4, { logHit: true, logMessage: "i={i}" }),
];
expect(toBackend(list, false, "/p/a.py")).toEqual([
{ line: 1, condition: null, hitCondition: null, logMessage: "Breakpoint reached at a.py:2", enabled: true },
{
line: 2,
condition: null,
hitCondition: null,
logMessage: "Breakpoint reached at a.py:3\ni={i}",
enabled: true,
},
// Logs nothing and never suspends: no DAP equivalent.
{ line: 3, condition: null, hitCondition: null, logMessage: null, enabled: false },
// Suspending breakpoints log from the UI on stop.
{ line: 4, condition: null, hitCondition: null, logMessage: null, enabled: true },
]);
});
it("interpolates {expressions} in log templates", async () => {
const out = await interpolateLog("a={a} b={ b }", async (e) => {
if (e === "b") throw new Error("undefined");
return e.toUpperCase();
});
expect(out).toBe("a=A b=<undefined>");
});
it("migrates stored log messages to non-suspending breakpoints", () => {
localStorage.setItem(
"openidea.debugger.breakpoints:/old",
JSON.stringify({
files: {
"/x": [
{ id: "a", line: 1, logMessage: "v={v}" },
{ id: "b", line: 2 },
],
},
}),
);
loadBreakpoints("/old");
expect(breakpointsOf("/x")[0]).toMatchObject({ suspend: false, logMessage: "v={v}" });
expect(breakpointsOf("/x")[1]!.suspend).toBeUndefined();
});
});
describe("breakpoint store", () => {
beforeEach(() => {
localStorage.clear();
+41 -1
View File
@@ -77,7 +77,11 @@ describe("breakpoint click area", () => {
doc: "a\nb",
extensions: [
lineNumbers(),
gutter({ class: "extra", renderEmptyElements: true, lineMarker: (_v, line) => (line.from > 0 ? new Icon() : null) }),
gutter({
class: "extra",
renderEmptyElements: true,
lineMarker: (_v, line) => (line.from > 0 ? new Icon() : null),
}),
breakpointGutter(ctx),
],
}),
@@ -93,6 +97,42 @@ describe("breakpoint click area", () => {
});
});
describe("breakpoint gutter clicks", () => {
const click = (view: EditorView, init: MouseEventInit) =>
view.dom
.querySelector(".cm-lineNumbers .cm-gutterElement:not([style*='hidden'])")!
.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, ...init }));
it("⇧-click adds a non-suspending logging breakpoint, ⌥-click a temporary one", () => {
localStorage.clear();
loadBreakpoints("/proj");
const view = makeView("a\nb");
click(view, { shiftKey: true });
expect(breakpointsOf(ctx.path)[0]).toMatchObject({ line: 0, suspend: false, logHit: true });
// Modifier clicks leave an existing breakpoint alone; a plain click removes it.
click(view, { altKey: true });
click(view, { shiftKey: true });
expect(breakpointsOf(ctx.path)).toHaveLength(1);
click(view, {});
expect(breakpointsOf(ctx.path)).toHaveLength(0);
click(view, { altKey: true });
expect(breakpointsOf(ctx.path)[0]).toMatchObject({ line: 0, temporary: true });
});
it("middle-click enables / disables an existing breakpoint", () => {
localStorage.clear();
loadBreakpoints("/proj");
const view = makeView("a\nb");
click(view, { button: 1 });
expect(breakpointsOf(ctx.path)).toHaveLength(0);
toggleBreakpoint(ctx.path, 0);
click(view, { button: 1 });
expect(breakpointsOf(ctx.path)[0]!.enabled).toBe(false);
click(view, { button: 1 });
expect(breakpointsOf(ctx.path)[0]!.enabled).toBe(true);
});
});
describe("execution point", () => {
it("highlights the suspended line and shows inline values", async () => {
const view = makeView("x = 1\ny = x + 1\nprint(y)");
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { ValueChanges } from "../ui/VariablesView";
describe("changed values (IDEA highlights them after a step)", () => {
it("compares with the previous stop in the same scope only", () => {
const c = new ValueChanges();
c.enter("t1:0", 1);
c.record("t1:0", "local:i", "1");
c.record("t1:0", "local:s", "'a'");
expect(c.changed("t1:0", "local:i", "1")).toBe(false);
c.enter("t1:0", 2);
expect(c.changed("t1:0", "local:i", "2")).toBe(true);
expect(c.changed("t1:0", "local:s", "'a'")).toBe(false);
// New variables aren't "changed".
expect(c.changed("t1:0", "local:n", "0")).toBe(false);
// Another frame depth has its own baseline.
expect(c.changed("t1:1", "local:i", "2")).toBe(false);
// Re-rendering in the same stop keeps the baseline.
c.record("t1:0", "local:i", "2");
c.enter("t1:0", 2);
expect(c.changed("t1:0", "local:i", "2")).toBe(true);
c.enter("t1:0", 3);
expect(c.changed("t1:0", "local:i", "2")).toBe(false);
});
});
+14 -4
View File
@@ -32,6 +32,7 @@ import {
type DebugSession,
} from "./state";
import { focusedDebugView } from "./ui/focusedView";
import { expressionAt } from "./editor/valueTooltip";
import {
openBreakpointsDialog,
openConfigChooser,
@@ -127,6 +128,16 @@ function cursor(): { path: string; line: number; selection: string } | null {
};
}
/** Evaluate's initial text, as in IDEA: the selection, else the expression at the caret. */
function evaluateSeed(): string {
const ed = activeEditor();
if (!ed) return "";
const { state } = ed.view;
const sel = state.selection.main;
if (!sel.empty) return state.sliceDoc(sel.from, sel.to);
return expressionAt(ed.view, sel.head)?.text ?? "";
}
async function startConfig(arg: unknown) {
const root = useProject.getState().project?.root;
if (!root) return;
@@ -243,6 +254,8 @@ export function registerDebugActions(): () => void {
category: "Debug",
icon: "rerun",
when: "debugCanRerun",
// ⌘R / Ctrl+F5 rerun only in the Debug tool window (⌘R is Replace in the editor).
keyWhen: "activeToolWindow == 'debug'",
run: () => {
const s = activeSession();
if (s) void rerunSession(rootOf(s).id);
@@ -518,10 +531,7 @@ export function registerDebugActions(): () => void {
title: "Evaluate Expression…",
category: "Debug",
when: "debugSessionActive",
run: () => {
const c = cursor();
openEvaluateDialog(c?.selection.split("\n")[0] ?? "");
},
run: () => openEvaluateDialog(evaluateSeed()),
},
{
id: "debug.showToolWindow",
+64 -12
View File
@@ -11,6 +11,10 @@ export interface BreakpointEntry {
hitCondition: string | null;
/** Temporary line breakpoint (⌥⇧⌘F8): removed after the first hit. */
temporary?: boolean;
/** `false`: the breakpoint never suspends (IDEA's "Suspend" unchecked). Absent means it suspends. */
suspend?: boolean;
/** Log the IDEA-style "Breakpoint reached at file:line" message on every hit. */
logHit?: boolean;
}
export interface BreakpointStatus {
@@ -38,9 +42,48 @@ export function makeBreakpoint(
logMessage: props.logMessage ?? null,
hitCondition: props.hitCondition ?? null,
...(props.temporary ? { temporary: true } : {}),
...(props.suspend === false ? { suspend: false } : {}),
...(props.logHit ? { logHit: true } : {}),
};
}
export function suspends(b: BreakpointEntry): boolean {
return b.suspend !== false;
}
/** The "Breakpoint hit" message IDEA logs for `path:line` (0-based line). */
export function hitMessage(path: string, line: number): string {
return `Breakpoint reached at ${path.split(/[\\/]/).pop() ?? path}:${line + 1}`;
}
/** What a hit logs: the hit message and/or the "Evaluate and log" template, one per line. */
export function logParts(b: BreakpointEntry, path: string): string[] {
const parts: string[] = [];
if (b.logHit) parts.push(hitMessage(path, b.line));
const msg = b.logMessage?.trim();
if (msg) parts.push(msg);
return parts;
}
/**
* Fills the `{expression}` placeholders of a log template (DAP logpoint syntax)
* with `evaluate`; failures render as the error text, like adapters do.
*/
export async function interpolateLog(template: string, evaluate: (expr: string) => Promise<string>): Promise<string> {
const parts = template.split(/(\{[^{}]+\})/);
const out = await Promise.all(
parts.map(async (p) => {
if (!/^\{[^{}]+\}$/.test(p)) return p;
try {
return await evaluate(p.slice(1, -1).trim());
} catch (e) {
return `<${e instanceof Error ? e.message : String(e)}>`;
}
}),
);
return out.join("");
}
export function sortByLine<T extends { line: number }>(list: readonly T[]): T[] {
return [...list].sort((a, b) => a.line - b.line);
}
@@ -52,9 +95,9 @@ export function toggleLine(list: readonly BreakpointEntry[], line: number): Brea
: sortByLine([...list, makeBreakpoint(line)]);
}
/** True when the breakpoint has a condition, hit condition or log message. */
/** True when the breakpoint has a condition, hit condition or logs something. */
export function isConditional(b: BreakpointEntry): boolean {
return !!(b.condition?.trim() || b.hitCondition?.trim() || b.logMessage?.trim());
return !!(b.condition?.trim() || b.hitCondition?.trim() || b.logMessage?.trim() || b.logHit);
}
/**
@@ -87,15 +130,22 @@ export function mapLines<T extends { line: number }>(
return out;
}
/** The list sent to the backend; muting disables everything without touching the stored flags. */
export function toBackend(list: readonly BreakpointEntry[], muted: boolean): Breakpoint[] {
return list.map((b) => ({
line: b.line,
condition: b.condition?.trim() || null,
hitCondition: b.hitCondition?.trim() || null,
logMessage: b.logMessage?.trim() || null,
enabled: b.enabled && !muted,
}));
/**
* The list sent to the backend; muting disables everything without touching the stored flags.
* A non-suspending breakpoint becomes a DAP logpoint; one that logs nothing has no DAP
* equivalent and stays disabled. Suspending breakpoints log from the UI when they stop.
*/
export function toBackend(list: readonly BreakpointEntry[], muted: boolean, path = ""): Breakpoint[] {
return list.map((b) => {
const log = suspends(b) ? null : logParts(b, path).join("\n") || null;
return {
line: b.line,
condition: b.condition?.trim() || null,
hitCondition: b.hitCondition?.trim() || null,
logMessage: log,
enabled: b.enabled && !muted && (suspends(b) || log != null),
};
});
}
export function sameEntries(a: readonly BreakpointEntry[], b: readonly BreakpointEntry[]): boolean {
@@ -109,7 +159,9 @@ export function sameEntries(a: readonly BreakpointEntry[], b: readonly Breakpoin
x.condition === y.condition &&
x.logMessage === y.logMessage &&
x.hitCondition === y.hitCondition &&
!!x.temporary === !!y.temporary
!!x.temporary === !!y.temporary &&
suspends(x) === suspends(y) &&
!!x.logHit === !!y.logHit
);
});
}
+6 -1
View File
@@ -59,6 +59,11 @@ function sanitize(raw: unknown): BreakpointEntry[] {
logMessage: typeof b.logMessage === "string" ? b.logMessage : null,
hitCondition: typeof b.hitCondition === "string" ? b.hitCondition : null,
...(b.temporary === true ? { temporary: true } : {}),
// Before "Suspend" was its own option, a log message meant "log instead of suspending".
...(b.suspend === false || (b.suspend === undefined && typeof b.logMessage === "string" && b.logMessage.trim())
? { suspend: false }
: {}),
...(b.logHit === true ? { logHit: true } : {}),
})),
);
}
@@ -111,7 +116,7 @@ export async function syncFile(path: string) {
list.map((b) => b.id),
);
try {
const states = await call(commands.debugSetBreakpoints(path, toBackend(list, muted)));
const states = await call(commands.debugSetBreakpoints(path, toBackend(list, muted, path)));
applyStates(path, states);
} catch (e) {
console.warn("debugSetBreakpoints failed", e);
@@ -6,10 +6,11 @@
import { RangeSet, StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state";
import { Decoration, EditorView, GutterMarker, ViewPlugin, lineNumberMarkers, type ViewUpdate } from "@codemirror/view";
import { editorsFor, mirroredChange, remoteChange, useEditorRegistry, type EditorDocContext } from "@/editor/api";
import { isConditional, mapLines, type BreakpointEntry, type BreakpointStatus } from "../breakpoints/model";
import { isConditional, mapLines, suspends, type BreakpointEntry, type BreakpointStatus } from "../breakpoints/model";
import {
addBreakpoint,
breakpointsOf,
removeBreakpoint,
setFileBreakpoints,
updateBreakpoint,
useBreakpoints,
@@ -45,7 +46,7 @@ export type BreakpointLook =
export function lookOf(b: BreakpointEntry, status: BreakpointStatus | undefined, muted: boolean, running: boolean) {
if (!b.enabled || muted) return "disabled";
if (running && status && !status.verified) return "unverified";
if (b.logMessage?.trim()) return "log";
if (!suspends(b)) return "log";
if (isConditional(b)) return "conditional";
return running && status?.verified ? "verified" : "normal";
}
@@ -198,29 +199,39 @@ export function breakpointGutter(ctx: EditorDocContext): Extension {
}
}
/**
* IDEA's gutter clicks: click toggles; on an empty line ⇧-click adds a non-suspending
* logging breakpoint and opens its properties, ⌥-click a temporary one. On an existing
* breakpoint only a plain (or ⌘/Ctrl) click removes it; middle-click enables/disables.
*/
onMouseDown = (e: MouseEvent) => {
if (e.button !== 0 || !inBreakpointArea(e)) return;
if ((e.button !== 0 && e.button !== 1) || !inBreakpointArea(e)) return;
const line = lineAtEvent(this.view, e);
if (line == null) return;
if (!canPlaceBreakpoint(ctx.language) && !breakpointsOf(path).some((b) => b.line === line)) return;
e.preventDefault();
const special = specialAt(path).find((b) => b.line === line);
if (special) {
if (e.altKey) updateSpecial(special.id, { enabled: !special.enabled });
else removeSpecial(special.id);
const bp = breakpointsOf(path).find((b) => b.line === line);
if (e.button === 1) {
if (!special && !bp) return;
e.preventDefault();
if (special) updateSpecial(special.id, { enabled: !special.enabled });
else if (bp) updateBreakpoint(path, bp.id, { enabled: !bp.enabled });
return;
}
if (!canPlaceBreakpoint(ctx.language) && !bp) return;
e.preventDefault();
if (special || bp) {
if (e.shiftKey || e.altKey) return;
if (special) removeSpecial(special.id);
else removeBreakpoint(path, bp!.id);
return;
}
if (e.altKey) {
// ⌥-click enables/disables instead of removing.
const bp = breakpointsOf(path).find((b) => b.line === line);
if (bp) updateBreakpoint(path, bp.id, { enabled: !bp.enabled });
else addBreakpoint(path, line);
addBreakpoint(path, line, { temporary: true });
return;
}
if (e.shiftKey) {
// ⇧-click: logging breakpoint that doesn't suspend.
const bp = breakpointsOf(path).find((b) => b.line === line) ?? addBreakpoint(path, line);
openBreakpointPopover(path, bp.id, { x: e.clientX, y: e.clientY }, true);
const created = addBreakpoint(path, line, { suspend: false, logHit: true });
openBreakpointPopover(path, created.id, { x: e.clientX, y: e.clientY });
return;
}
toggleBreakpointSmart(path, line, ctx.language, this.view.state.doc.toString());
+23 -1
View File
@@ -134,7 +134,6 @@
user-select: none;
}
/* Data Views › Show value tooltip on mouse hover. */
.valueTooltip {
max-width: 520px;
@@ -144,6 +143,29 @@
white-space: pre-wrap;
word-break: break-word;
}
.valueTooltipTree {
padding: 2px 0;
white-space: normal;
}
.valueExpand {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-right: 4px;
padding: 0;
border: 0;
border-radius: var(--radius-small);
background: transparent;
color: var(--fg-muted);
vertical-align: -3px;
cursor: pointer;
}
.valueExpand:hover {
background: var(--bg-hover, transparent);
color: var(--fg-strong);
}
.valueName {
color: var(--fg-strong);
}
+25 -2
View File
@@ -8,6 +8,9 @@ import { hoverTooltip, type EditorView } from "@codemirror/view";
import { workflowSettings } from "@/platform/workflowSettings";
import { activeSession, evaluate } from "../state";
import { inlineValue } from "../values";
import { tx } from "@/platform/i18n";
import { iconMarkup } from "@/ui";
import { mountValueTree } from "../ui/ValueHintTree";
import styles from "./editor.module.css";
const BASE_HOVER = 100;
@@ -42,7 +45,8 @@ export function valueTooltip(): Extension {
if (!session || session.status !== "paused") return null;
const expr = expressionAt(view, pos);
if (!expr) return null;
if (dbg.valueTooltipDelay > BASE_HOVER) await new Promise((r) => setTimeout(r, dbg.valueTooltipDelay - BASE_HOVER));
if (dbg.valueTooltipDelay > BASE_HOVER)
await new Promise((r) => setTimeout(r, dbg.valueTooltipDelay - BASE_HOVER));
let value;
try {
value = await evaluate(expr.text, "hover", session);
@@ -56,6 +60,25 @@ export function valueTooltip(): Extension {
create: () => {
const dom = document.createElement("div");
dom.className = styles.valueTooltip!;
let tree: { unmount(): void } | null = null;
if (value.variablesReference > 0) {
// IDEA's value hint: the "+" expands the object into a variables tree in place.
const expand = document.createElement("button");
expand.type = "button";
expand.className = styles.valueExpand!;
expand.setAttribute("aria-label", tx("Expand value"));
expand.setAttribute("aria-expanded", "false");
expand.innerHTML = iconMarkup("chevron-right", 12) ?? "";
expand.addEventListener("mousedown", (e) => e.preventDefault());
expand.addEventListener("click", () => {
if (tree) return;
expand.remove();
dom.classList.add(styles.valueTooltipTree!);
dom.replaceChildren();
tree = mountValueTree(dom, expr.text, value, session.id);
});
dom.append(expand);
}
const name = document.createElement("span");
name.className = styles.valueName!;
name.textContent = `${expr.text} = `;
@@ -66,7 +89,7 @@ export function valueTooltip(): Extension {
type.textContent = ` {${value.typeName}}`;
dom.append(type);
}
return { dom };
return { dom, destroy: () => tree?.unmount() };
},
};
},
+11 -2
View File
@@ -8,7 +8,7 @@ import { onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { valueTooltip } from "./editor/valueTooltip";
import { registerEditorContribution } from "@/editor/api";
import { registerOverlay, registerToolWindow } from "@/workbench/contributions";
import { registerMenuGroup, registerOverlay, registerToolWindow } from "@/workbench/contributions";
import { registerDebugActions } from "./actions";
import { loadBreakpoints } from "./breakpoints/store";
import { loadExceptionBreakpoints } from "./breakpoints/exceptions";
@@ -16,7 +16,7 @@ import { loadSpecialBreakpoints } from "./breakpoints/special";
import { breakpointGutter } from "./editor/breakpointGutter";
import { executionPoint } from "./editor/execution";
import { onHotCodeReplace } from "./hotswap";
import { handleDebugEvent, loadWatches, publishContext, resetSessions } from "./state";
import { handleDebugEvent, isLive, loadWatches, publishContext, resetSessions, stopSession, useDebug } from "./state";
import { DebuggerOverlays } from "./ui/Overlays";
export function activate(): () => void {
@@ -38,6 +38,15 @@ export function activate(): () => void {
headerContent: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugTabs)),
}),
registerOverlay({ id: "debugger", component: DebuggerOverlays }),
// Stop (⌘F2) offers the running debug sessions beside run processes.
registerMenuGroup({
id: "debugger.stop",
menus: ["stopProcess"],
items: () =>
Object.values(useDebug.getState().sessions)
.filter((s) => s.parent == null && isLive(s))
.map((s) => ({ label: s.name, verbatim: true, icon: "debug", run: () => void stopSession(s.id) })),
}),
registerEditorContribution({
id: "debugger",
order: 60,
+19 -1
View File
@@ -20,7 +20,8 @@ import {
type Variable,
} from "@/platform/ipc";
import { maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions";
import { consumeTemporary, refreshStates, resetStatuses } from "./breakpoints/store";
import { breakpointsOf, consumeTemporary, refreshStates, resetStatuses } from "./breakpoints/store";
import { interpolateLog, logParts, suspends } from "./breakpoints/model";
import { syncSpecial, useSpecialBreakpoints } from "./breakpoints/special";
import { refreshExceptionFilters } from "./breakpoints/exceptions";
import { missingAdapter } from "./missingAdapter";
@@ -421,12 +422,29 @@ async function loadFrames(id: DebugSessionId, threadId: number, reveal: boolean)
patch(id, { frames });
const top = frames.find((f) => f.path && f.presentationHint !== "label") ?? frames[0];
if (top?.path && (s.stopReason === "breakpoint" || s.stopReason === "function breakpoint")) {
void logSuspendingHit(id, top);
consumeTemporary(top.path, top.line);
}
if (top) selectFrame(id, top.id, reveal);
else updateExecutionPoint();
}
/**
* A suspending breakpoint that also logs: DAP logpoints never suspend, so the
* message is evaluated in the stopped frame and printed here instead.
*/
async function logSuspendingHit(id: DebugSessionId, frame: StackFrame) {
const bp = breakpointsOf(frame.path!).find((b) => b.line === frame.line && b.enabled);
if (!bp || !suspends(bp)) return;
for (const part of logParts(bp, frame.path!)) {
const text = await interpolateLog(part, async (expr) => {
const v = await call(commands.debugEvaluateIn(id, frame.id, expr, "repl"));
return v.value;
});
appendConsole(id, "console", `${text}\n`);
}
}
async function loadThreads(id: DebugSessionId) {
try {
const threads = await call(commands.debugThreads(id));
@@ -372,6 +372,8 @@ function VariablesPane({ session }: { session: DebugSession }) {
rows: [],
});
const frame = session.status === "paused" ? session.selectedFrame : null;
// Same thread and stack depth across stops: compare like with like.
const changeScope = `${session.id}:${session.selectedThread}:${session.frames.findIndex((f) => f.id === frame)}`;
// Scopes + first scope's variables of the selected frame.
useEffect(() => {
@@ -520,6 +522,7 @@ function VariablesPane({ session }: { session: DebugSession }) {
roots={roots}
session={session.id}
generation={stopSeq * 100000 + (frame ?? 0)}
changes={{ epoch: stopSeq, scope: changeScope }}
aria-label="Variables"
/>
)}
@@ -240,3 +240,37 @@
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
/* Evaluate: a field that grows with ⇧Enter, like IDEA's expression editor. */
.evalInputRow {
display: flex;
align-items: flex-start;
gap: 4px;
}
.evalInput {
flex: 1;
min-width: 0;
min-height: var(--h-field, 28px);
padding: 5px 8px;
resize: none;
appearance: none;
border: 0;
border-radius: var(--radius-control, 4px);
background: var(--bg-field, var(--bg-button));
box-shadow: inset 0 0 0 1px var(--border-button);
color: var(--fg-strong);
font-family: var(--font-mono);
font-size: var(--fs-ui);
line-height: 18px;
outline: none;
-webkit-user-select: text;
user-select: text;
}
.evalInput:focus {
box-shadow:
inset 0 0 0 1px var(--focus-ring),
0 0 0 1px var(--focus-ring);
}
.evalInput::placeholder {
color: var(--fg-faint);
}
+232 -129
View File
@@ -1,11 +1,11 @@
/** Debugger popovers and dialogs: breakpoint properties, View Breakpoints, Evaluate, config chooser. */
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type MutableRefObject } from "react";
import { basename, relativePath, useProject } from "@/platform/project";
import { executeAction } from "@/platform/actions";
import { call, commands, type AttachableProcess, type RunConfig, type Variable } from "@/platform/ipc";
import { maybeEditorService, notifications } from "@/workbench/contributions";
import { Button, Checkbox, Dialog, Icon, IconButton, Input, Kbd, List, Popup, Toggle, T } from "@/ui";
import { isConditional, type BreakpointEntry } from "../breakpoints/model";
import { Button, Checkbox, Dialog, Icon, IconButton, Input, Kbd, List, Menu, Popup, Toggle, T } from "@/ui";
import { isConditional, suspends, type BreakpointEntry } from "../breakpoints/model";
import {
removeAllBreakpoints,
removeBreakpoint,
@@ -31,6 +31,7 @@ import {
} from "../breakpoints/exceptions";
import { closeBreakpointPopover, useOverlays } from "./popoverStore";
import { VariablesView, type VarRoot } from "./VariablesView";
import { pushHistory, readHistory } from "./expressionHistory";
import styles from "./Overlays.module.css";
export function DebuggerOverlays() {
@@ -74,33 +75,11 @@ function BreakpointEditor({
at: { x: number; y: number };
focusLog: boolean;
}) {
const [condition, setCondition] = useState(bp.condition ?? "");
const [hitCondition, setHitCondition] = useState(bp.hitCondition ?? "");
const [logEnabled, setLogEnabled] = useState(focusLog || !!bp.logMessage);
const [logMessage, setLogMessage] = useState(bp.logMessage ?? "");
const draft = useRef({ condition, hitCondition, logEnabled, logMessage });
useEffect(() => {
draft.current = { condition, hitCondition, logEnabled, logMessage };
});
const commit = () => {
const d = draft.current;
updateBreakpoint(path, bp.id, {
condition: d.condition.trim() || null,
hitCondition: d.hitCondition.trim() || null,
logMessage: d.logEnabled ? d.logMessage.trim() || null : null,
});
};
const commitText = useRef<(() => void) | null>(null);
const close = () => {
commit();
commitText.current?.();
closeBreakpointPopover();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
close();
}
};
return (
<Popup open onClose={close} anchor={at} placement="bottom-start" trapFocus aria-label="Breakpoint properties">
@@ -111,50 +90,13 @@ function BreakpointEditor({
{basename(path)}:{bp.line + 1}
</span>
</div>
<Checkbox
checked={bp.enabled}
onChange={(enabled) => updateBreakpoint(path, bp.id, { enabled })}
label="Enabled"
<BreakpointFields
path={path}
bp={bp}
focus={focusLog ? "log" : "condition"}
onEnter={close}
commitRef={commitText}
/>
<label className={styles.field}>
<span className={styles.label}>
<T>Condition</T>
</span>
<Input
mono
value={condition}
onChange={(e) => setCondition(e.target.value)}
onKeyDown={onKey}
placeholder="e.g. order.total > 100"
data-autofocus={focusLog ? undefined : ""}
aria-label="Condition"
/>
</label>
<label className={styles.field}>
<span className={styles.label}>
<T>Hit count</T>
</span>
<Input
mono
value={hitCondition}
onChange={(e) => setHitCondition(e.target.value)}
onKeyDown={onKey}
placeholder="e.g. 5 or >= 3"
aria-label="Hit count condition"
/>
</label>
<Checkbox checked={logEnabled} onChange={setLogEnabled} label="Log message instead of suspending" />
{logEnabled && (
<Input
mono
value={logMessage}
onChange={(e) => setLogMessage(e.target.value)}
onKeyDown={onKey}
placeholder="Value: {expression}"
data-autofocus={focusLog ? "" : undefined}
aria-label="Log message"
/>
)}
<div className={styles.bpFooter}>
<button
type="button"
@@ -186,6 +128,136 @@ function BreakpointEditor({
);
}
/**
* Line breakpoint properties shared by the gutter popover and the Breakpoints dialog,
* like IDEA's: Suspend and the two logging options are independent. Text fields
* commit on blur (and through `commitRef` when the popover closes).
*/
function BreakpointFields({
path,
bp,
focus,
onEnter,
commitRef,
}: {
path: string;
bp: BreakpointEntry;
focus?: "condition" | "log";
onEnter?: () => void;
commitRef?: MutableRefObject<(() => void) | null>;
}) {
const [condition, setCondition] = useState(bp.condition ?? "");
const [hitCondition, setHitCondition] = useState(bp.hitCondition ?? "");
const [logEval, setLogEval] = useState(!!bp.logMessage?.trim());
const [logMessage, setLogMessage] = useState(bp.logMessage ?? "");
const conditionHistory = useMemo(() => readHistory("condition"), []);
const historyId = useId();
const draft = useRef({ condition, hitCondition, logEval, logMessage });
useEffect(() => {
draft.current = { condition, hitCondition, logEval, logMessage };
});
const commitText = () => {
const d = draft.current;
const cond = d.condition.trim() || null;
if (cond) pushHistory("condition", cond);
updateBreakpoint(path, bp.id, {
condition: cond,
hitCondition: d.hitCondition.trim() || null,
logMessage: d.logEval ? d.logMessage.trim() || null : null,
});
};
useEffect(() => {
if (!commitRef) return;
commitRef.current = commitText;
return () => {
commitRef.current = null;
};
});
const onKey = (e: KeyboardEvent) => {
if (onEnter && e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onEnter();
}
};
return (
<>
<Checkbox
checked={bp.enabled}
onChange={(enabled) => updateBreakpoint(path, bp.id, { enabled })}
label="Enabled"
/>
<Checkbox
checked={suspends(bp)}
onChange={(on) => updateBreakpoint(path, bp.id, { suspend: on ? undefined : false })}
label="Suspend"
/>
<label className={styles.field}>
<span className={styles.label}>
<T>Condition</T>
</span>
<Input
mono
value={condition}
onChange={(e) => setCondition(e.target.value)}
onBlur={commitText}
onKeyDown={onKey}
list={conditionHistory.length ? historyId : undefined}
placeholder="e.g. order.total > 100"
data-autofocus={focus === "condition" ? "" : undefined}
aria-label="Condition"
/>
{conditionHistory.length > 0 && (
<datalist id={historyId}>
{conditionHistory.map((h) => (
<option key={h} value={h} />
))}
</datalist>
)}
</label>
<label className={styles.field}>
<span className={styles.label}>
<T>Hit count</T>
</span>
<Input
mono
value={hitCondition}
onChange={(e) => setHitCondition(e.target.value)}
onBlur={commitText}
onKeyDown={onKey}
placeholder="e.g. 5 or >= 3"
aria-label="Hit count condition"
/>
</label>
<Checkbox
checked={!!bp.logHit}
onChange={(on) => updateBreakpoint(path, bp.id, { logHit: on || undefined })}
label={'"Breakpoint hit" message'}
/>
<Checkbox
checked={logEval}
onChange={(on) => {
setLogEval(on);
draft.current = { ...draft.current, logEval: on };
commitText();
}}
label="Evaluate and log"
/>
{logEval && (
<Input
mono
value={logMessage}
onChange={(e) => setLogMessage(e.target.value)}
onBlur={commitText}
onKeyDown={onKey}
placeholder="Value: {expression}"
data-autofocus={focus === "log" ? "" : undefined}
aria-label="Evaluate and log"
/>
)}
</>
);
}
function specialTitle(b: SpecialBreakpoint): string {
return b.kind === "method" ? b.name || "(no method)" : b.label;
}
@@ -411,7 +483,7 @@ function BreakpointsDialog() {
{basename(r.path)}:{r.bp.line + 1}
</span>
<span className={styles.bpDir}>{relativePath(root, r.path)}</span>
{isConditional(r.bp) && <span className={styles.bpCond}>{r.bp.logMessage ? "log" : "if"}</span>}
{isConditional(r.bp) && <span className={styles.bpCond}>{suspends(r.bp) ? "if" : "log"}</span>}
{r.bp.temporary && <span className={styles.bpCond}>temp</span>}
</div>
);
@@ -476,10 +548,6 @@ function ExceptionBreakpoints({ open }: { open: boolean }) {
function BreakpointDetail({ row, onGo }: { row: BpRow; onGo: () => void }) {
const { path, bp } = row;
const st = useBreakpoints((s) => s.status[bp.id]);
const [condition, setCondition] = useState(bp.condition ?? "");
const [hitCondition, setHitCondition] = useState(bp.hitCondition ?? "");
const [logMessage, setLogMessage] = useState(bp.logMessage ?? "");
const commit = (patch: Partial<BreakpointEntry>) => updateBreakpoint(path, bp.id, patch);
return (
<div className={styles.detail}>
<div className={styles.bpTitle}>
@@ -495,45 +563,12 @@ function BreakpointDetail({ row, onGo }: { row: BpRow; onGo: () => void }) {
</Button>
</div>
{st && <div className={styles.statusLine}>{st.verified ? "Verified" : (st.message ?? "Not verified")}</div>}
<Checkbox checked={bp.enabled} onChange={(enabled) => commit({ enabled })} label="Enabled" />
<Checkbox checked={!!bp.temporary} onChange={(temporary) => commit({ temporary })} label="Remove once hit" />
<label className={styles.field}>
<span className={styles.label}>
<T>Condition</T>
</span>
<Input
mono
value={condition}
onChange={(e) => setCondition(e.target.value)}
onBlur={() => commit({ condition: condition.trim() || null })}
aria-label="Condition"
/>
</label>
<label className={styles.field}>
<span className={styles.label}>
<T>Hit count</T>
</span>
<Input
mono
value={hitCondition}
onChange={(e) => setHitCondition(e.target.value)}
onBlur={() => commit({ hitCondition: hitCondition.trim() || null })}
aria-label="Hit count condition"
/>
</label>
<label className={styles.field}>
<span className={styles.label}>
<T>Log message</T>
</span>
<Input
mono
value={logMessage}
onChange={(e) => setLogMessage(e.target.value)}
onBlur={() => commit({ logMessage: logMessage.trim() || null })}
placeholder="Suspends when empty"
aria-label="Log message"
/>
</label>
<BreakpointFields path={path} bp={bp} />
<Checkbox
checked={!!bp.temporary}
onChange={(temporary) => updateBreakpoint(path, bp.id, { temporary })}
label="Remove once hit"
/>
</div>
);
}
@@ -635,15 +670,29 @@ function EvaluateDialog() {
return <EvaluateDialogInner initial={state.expression} />;
}
/**
* IDEA's Evaluate dialog: Enter evaluates, ⇧Enter adds a line (the field grows),
* ⇧⌘Enter / Ctrl+Shift+Enter adds a watch; ↑ on the first line / ↓ on the last walk
* the per-project history, which the history button also lists.
*/
function EvaluateDialogInner({ initial }: { initial: string }) {
const [expression, setExpression] = useState(initial);
const [result, setResult] = useState<VarRoot | null>(null);
const [history, setHistory] = useState(() => readHistory("evaluate"));
const [historyMenu, setHistoryMenu] = useState(false);
const historyPos = useRef(-1);
const historyButton = useRef<HTMLSpanElement>(null);
const field = useRef<HTMLTextAreaElement>(null);
const session = useDebug((s) => (s.active != null ? s.sessions[s.active] : undefined));
const paused = session?.status === "paused";
const close = () => useOverlays.setState({ evaluate: null });
const lines = Math.min(10, expression.split("\n").length);
const run = async () => {
const e = expression.trim();
if (!e) return;
pushHistory("evaluate", e);
setHistory(readHistory("evaluate"));
historyPos.current = -1;
try {
const v: Variable = await evaluate(e, "repl", session ?? null);
setResult({ key: `eval:${e}:${Date.now()}`, kind: "eval", name: e, variable: v, error: null });
@@ -651,6 +700,36 @@ function EvaluateDialogInner({ initial }: { initial: string }) {
setResult({ key: `eval:${e}:${Date.now()}`, kind: "eval", name: e, variable: null, error: errorText(err) });
}
};
const recall = (text: string) => {
setExpression(text);
requestAnimationFrame(() => {
const el = field.current;
if (!el) return;
el.focus();
el.setSelectionRange(text.length, text.length);
});
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing) return;
const el = e.currentTarget;
if (e.key === "Enter" && e.shiftKey && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
addWatch(expression);
} else if (e.key === "Enter" && !e.shiftKey && !e.altKey) {
e.preventDefault();
if (paused) void run();
} else if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !e.shiftKey && !e.altKey && !e.metaKey) {
const before = el.value.slice(0, el.selectionStart);
const after = el.value.slice(el.selectionEnd);
const up = e.key === "ArrowUp";
if (up ? before.includes("\n") : after.includes("\n")) return;
const next = historyPos.current + (up ? 1 : -1);
if (next < -1 || next >= history.length) return;
e.preventDefault();
historyPos.current = next;
recall(next < 0 ? "" : history[next]!);
}
};
return (
<Dialog
open
@@ -676,25 +755,49 @@ function EvaluateDialogInner({ initial }: { initial: string }) {
]}
>
<div className={styles.evalBody}>
<label className={styles.field}>
<div className={styles.field}>
<span className={styles.label}>
<T>Expression</T>
</span>
<Input
mono
value={expression}
onChange={(e) => setExpression(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void run();
}
}}
data-autofocus=""
aria-label="Expression"
placeholder={paused ? "Expression to evaluate in the selected frame" : "Pause the program to evaluate"}
<div className={styles.evalInputRow}>
<textarea
ref={field}
className={styles.evalInput}
rows={lines}
value={expression}
onChange={(e) => {
setExpression(e.target.value);
historyPos.current = -1;
}}
onKeyDown={onKeyDown}
onFocus={(e) => e.currentTarget.select()}
spellCheck={false}
autoComplete="off"
data-autofocus=""
aria-label="Expression"
placeholder={paused ? "Expression to evaluate in the selected frame" : "Pause the program to evaluate"}
/>
<span ref={historyButton}>
<IconButton
icon="history"
title="History"
size="small"
disabled={!history.length}
onClick={() => setHistoryMenu(true)}
/>
</span>
</div>
<Menu
open={historyMenu}
onClose={() => setHistoryMenu(false)}
anchor={historyButton}
placement="bottom-end"
aria-label="Expression history"
items={history
.slice(0, 20)
.map((h) => ({ label: h.replace(/\s+/g, " "), verbatim: true, run: () => recall(h) }))}
/>
</label>
</div>
<div className={styles.evalResult}>
{result ? (
<VariablesView roots={[result]} session={session?.id ?? null} aria-label="Evaluation result" />
@@ -0,0 +1,21 @@
/** The expanded value hint: an object under the mouse as a variables tree (as IDEA). */
import { createRoot } from "react-dom/client";
import type { DebugSessionId, Variable } from "@/platform/ipc";
import { VariablesView } from "./VariablesView";
import styles from "./VariablesView.module.css";
export function mountValueTree(host: HTMLElement, expression: string, variable: Variable, session: DebugSessionId) {
const root = createRoot(host);
root.render(
<div className={styles.hint}>
<VariablesView
roots={[{ key: "hint", kind: "eval", name: expression, variable, error: null }]}
session={session}
defaultExpanded={["hint"]}
autoFocus
aria-label={expression}
/>
</div>,
);
return { unmount: () => queueMicrotask(() => root.unmount()) };
}
@@ -79,7 +79,6 @@
font-size: var(--fs-ui);
}
.editInput {
flex: 1;
min-width: 80px;
@@ -96,3 +95,16 @@
.editInvalid {
border-color: var(--danger);
}
/* Changed since the previous stop. */
.name.changed {
color: var(--debug-changed, var(--link));
}
/* Expanded value hint in the editor. */
.hint {
display: flex;
flex-direction: column;
width: 460px;
height: 260px;
}
+80 -7
View File
@@ -95,19 +95,70 @@ function message(key: string, text: string): VarNode {
};
}
/**
* Values changed since the previous stop (IDEA paints them in a
* distinct colour). Values are remembered per
* `scope` (e.g. thread + frame depth) so switching frames doesn't compare unrelated locals.
*/
export class ValueChanges {
private scopes = new Map<string, { epoch: number; prev: Map<string, string>; cur: Map<string, string> }>();
/** Starts `epoch` for `scope`: the values seen in the earlier epoch become the baseline. */
enter(scope: string, epoch: number) {
const s = this.scopes.get(scope);
if (!s) {
if (this.scopes.size >= 64) this.scopes.delete(this.scopes.keys().next().value!);
this.scopes.set(scope, { epoch, prev: new Map(), cur: new Map() });
} else if (s.epoch !== epoch) {
s.prev = s.cur.size ? s.cur : s.prev;
s.cur = new Map();
s.epoch = epoch;
}
}
record(scope: string, key: string, value: string) {
this.scopes.get(scope)?.cur.set(key, value);
}
changed(scope: string, key: string, value: string): boolean {
const prev = this.scopes.get(scope)?.prev.get(key);
return prev !== undefined && prev !== value;
}
}
const tracksChanges = (n: VarNode) => (n.kind === "var" || n.kind === "watch") && !n.error;
/** Shared by every view: the variables pane remounts while the program runs between stops. */
const valueChanges = new ValueChanges();
export interface VariablesViewProps {
roots: VarRoot[];
session: DebugSessionId | null;
/** Changing it drops loaded children (new stop); expansion is kept and re-loaded. */
generation?: number;
/** Highlight values changed since the previous `epoch` (stop) within the same `scope`. */
changes?: { epoch: number; scope: string };
/** Keys of nodes expanded initially (e.g. the root of a value hint). */
defaultExpanded?: readonly string[];
/** Keyboard focus goes to the tree on mount. */
autoFocus?: boolean;
"aria-label"?: string;
className?: string;
}
export function VariablesView({ roots, session, generation = 0, className, ...aria }: VariablesViewProps) {
export function VariablesView({
roots,
session,
generation = 0,
changes,
defaultExpanded,
autoFocus,
className,
...aria
}: VariablesViewProps) {
const [cache, setCache] = useState<{ gen: number; map: Map<string, VarNode[]> }>({ gen: generation, map: new Map() });
const children = cache.gen === generation ? cache.map : null;
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(defaultExpanded));
const loading = useRef(new Set<string>());
const tree = useRef<TreeHandle>(null);
@@ -124,6 +175,19 @@ export function VariablesView({ roots, session, generation = 0, className, ...ar
return m;
}, [nodes, children]);
if (changes) valueChanges.enter(changes.scope, changes.epoch);
const changeScope = changes?.scope;
useEffect(() => {
if (changeScope == null) return;
for (const n of byKey.values()) if (tracksChanges(n)) valueChanges.record(changeScope, n.key, n.value);
}, [byKey, changeScope]);
const isChanged = (n: VarNode) =>
!!changes && tracksChanges(n) && valueChanges.changed(changes.scope, n.key, n.value);
useEffect(() => {
if (autoFocus) tree.current?.focus();
}, [autoFocus]);
const load = useCallback(
async (node: VarNode) => {
if (session == null || node.reference <= 0) return;
@@ -206,7 +270,11 @@ export function VariablesView({ roots, session, generation = 0, className, ...ar
if (!node || node.kind === "message") return;
e.preventDefault();
const items: MenuItem[] = [];
if (editable(node)) items.push({ label: "Set Value…", icon: "edit", shortcut: "F2", run: () => startEdit(node) }, { kind: "separator" });
if (editable(node))
items.push(
{ label: "Set Value…", icon: "edit", shortcut: "F2", run: () => startEdit(node) },
{ kind: "separator" },
);
if (node.kind === "watch") items.push({ label: "Remove Watch", icon: "minus", run: () => removeWatch(node.name) });
if (node.kind !== "scope") {
items.push({ label: "Copy Value", icon: "copy", run: () => void navigator.clipboard?.writeText(node.value) });
@@ -219,7 +287,10 @@ export function VariablesView({ roots, session, generation = 0, className, ...ar
icon: "breakpoint-field",
run: () => void addWatchpointFor(session!, node),
});
items.push({ kind: "separator" }, { label: "Jump to Source", shortcut: "F4", run: () => void jumpToDeclaration(node) });
items.push(
{ kind: "separator" },
{ label: "Jump to Source", shortcut: "F4", run: () => void jumpToDeclaration(node) },
);
}
if (items.length) showContextMenu(e, items);
};
@@ -271,7 +342,7 @@ export function VariablesView({ roots, session, generation = 0, className, ...ar
}}
/>
) : (
<VarRow node={n} />
<VarRow node={n} changed={isChanged(n)} />
)
}
/>
@@ -337,7 +408,7 @@ function nodeIcon(node: VarNode): string {
return node.depth0 ? "variable" : "field";
}
function VarRow({ node }: { node: VarNode }) {
function VarRow({ node, changed }: { node: VarNode; changed: boolean }) {
if (node.kind === "message") return <span className={styles.message}>{node.name}</span>;
if (node.kind === "scope")
return (
@@ -350,7 +421,9 @@ function VarRow({ node }: { node: VarNode }) {
return (
<span className={styles.row}>
<Icon name={nodeIcon(node)} size={16} className={styles.nodeIcon} />
<span className={clsx(styles.name, node.kind === "var" && styles.varName)}>{node.name}</span>
<span className={clsx(styles.name, node.kind === "var" && styles.varName, changed && styles.changed)}>
{node.name}
</span>
<span className={styles.eq}>=</span>
{node.error ? (
<span className={styles.error}>{node.error}</span>
@@ -0,0 +1,38 @@
/**
* Per-project history of expressions typed into the debugger (breakpoint
* conditions, Evaluate), most recent first, as IDEA keeps them.
*/
import { useProject } from "@/platform/project";
export type HistoryKind = "condition" | "evaluate";
const PREFIX = "openidea.debugger.history.";
const MAX = 50;
function key(kind: HistoryKind): string | null {
const root = useProject.getState().project?.root;
return root ? `${PREFIX}${kind}:${root}` : null;
}
export function readHistory(kind: HistoryKind): string[] {
const k = key(kind);
if (!k) return [];
try {
const raw = JSON.parse(localStorage.getItem(k) ?? "[]") as unknown;
return Array.isArray(raw) ? raw.filter((e): e is string => typeof e === "string") : [];
} catch {
return [];
}
}
export function pushHistory(kind: HistoryKind, expression: string) {
const e = expression.trim();
const k = key(kind);
if (!e || !k) return;
const next = [e, ...readHistory(kind).filter((h) => h !== e)].slice(0, MAX);
try {
localStorage.setItem(k, JSON.stringify(next));
} catch {
// Storage unavailable: no history.
}
}
@@ -1,13 +1,23 @@
/**
* IDEA's Rename (⇧F6) and Move (F6, drag & drop) dialogs for files and
* folders, with "Search for references" and "Search in comments and strings".
* IDEA's Rename (⇧F6), Move (F6, drag & drop, cut & paste) and Copy (copy &
* paste, ⌥ drag) dialogs for files and folders, with "Search for references"
* and "Search in comments and strings".
*/
import { useEffect, useRef, useState } from "react";
import { create } from "zustand";
import type { FileMove } from "@/bindings";
import { Checkbox, Dialog, Input, Spinner, T } from "@/ui";
import { runFileRefactoring, type FileRefactorOptions } from "./fileRefactor";
import { isSameOrInside, joinPath, nameOf, parentOf, relativeTo, splitExtension, validateName } from "./paths";
import {
isSameOrInside,
joinPath,
nameOf,
parentOf,
relativeTo,
splitExtension,
uniqueName,
validateName,
} from "./paths";
import { useProjectTree } from "./store";
import styles from "./FileRefactor.module.css";
@@ -28,6 +38,16 @@ export type FileRefactorRequest =
dir?: boolean;
/** Called after the files moved (tree refresh / reveal). */
onDone?: (moves: FileMove[]) => void;
}
| {
kind: "copy";
paths: string[];
/** Initial target folder. */
target: string;
/** The single copied item is a folder. */
dir?: boolean;
/** Copies into `target` (`name`: the single item's new name); false keeps the dialog open. */
copy: (target: string, name: string | null, openInEditor: boolean) => Promise<boolean>;
};
const useRequest = create<{ request: (FileRefactorRequest & { seq: number }) | null }>(() => ({ request: null }));
@@ -299,14 +319,133 @@ function MoveDialog({ request }: { request: Extract<FileRefactorRequest, { kind:
);
}
/** Overlay contribution rendering the open Rename / Move dialog. */
const COPY_OPEN_KEY = "openidea.projectTree.copyOpenInEditor";
function CopyDialog({ request }: { request: Extract<FileRefactorRequest, { kind: "copy" }> }) {
const root = useProjectTree((s) => s.root) ?? "";
const single = request.paths.length === 1 ? request.paths[0]! : null;
const [target, setTarget] = useState(request.target);
const dest = resolveTarget(target, root);
const listing = useProjectTree((s) => s.listings.get(dest));
const [name, setName] = useState(() => {
const own = single ? nameOf(single) : "";
const taken = new Set((useProjectTree.getState().listings.get(request.target) ?? []).map((e) => fold(e.name)));
return single ? uniqueName(own, (n) => taken.has(fold(n)), !!request.dir) : "";
});
const [openInEditor, setOpenInEditor] = useState(() => {
try {
return localStorage.getItem(COPY_OPEN_KEY) === "true";
} catch {
return false;
}
});
const [busy, setBusy] = useState(false);
const input = useRef<HTMLInputElement>(null);
useEffect(() => {
const el = input.current;
if (!el) return;
el.focus();
const stem = request.dir ? name : splitExtension(name)[0];
el.setSelectionRange(0, stem.length || name.length);
// Only on open.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const siblings = (listing ?? []).map((e) => e.name);
const error = request.paths.some((p) => isSameOrInside(dest, p) && dest !== parentOf(p))
? "Cannot copy a folder into itself"
: single
? validateName(name, { siblings, caseInsensitive }).error
: null;
const subject = single
? `Copy ${request.dir ? "directory" : "file"} '${nameOf(single)}'`
: `Copy ${request.paths.length} items`;
const submit = async () => {
if (busy || error) return;
try {
localStorage.setItem(COPY_OPEN_KEY, String(openInEditor));
} catch {
/* storage unavailable */
}
setBusy(true);
const ok = await request.copy(dest, single ? name : null, openInEditor && !request.dir);
setBusy(false);
if (ok) closeFileRefactorDialog();
};
return (
<Dialog
open
title="Copy"
onClose={closeFileRefactorDialog}
width={520}
buttons={[
{ label: "OK", role: "default", disabled: busy || !!error, onClick: () => void submit() },
{ label: "Cancel", role: "cancel" },
]}
>
<form
className={styles.form}
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
>
<div className={styles.subject}>{subject}</div>
{single && (
<>
<label className={styles.fieldLabel} htmlFor="file-copy-name">
<T>New name:</T>
</label>
<Input
id="file-copy-name"
ref={input}
value={name}
disabled={busy}
invalid={!!error}
aria-label="New name"
onChange={(e) => setName(e.target.value)}
/>
</>
)}
<label className={styles.fieldLabel} htmlFor="file-copy-target">
<T>To directory:</T>
</label>
<Input
id="file-copy-target"
autoFocus={!single}
mono
value={target}
disabled={busy}
aria-label="Target directory"
onChange={(e) => setTarget(e.target.value)}
/>
<div className={styles.hint}>{relativeTo(root, dest) || (dest === root ? "Project root" : dest)}</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{single && !request.dir && (
<div className={styles.options}>
<Checkbox label="Open copy in editor" checked={openInEditor} disabled={busy} onChange={setOpenInEditor} />
</div>
)}
</form>
</Dialog>
);
}
const fold = (n: string) => (caseInsensitive ? n.toLowerCase() : n);
/** Overlay contribution rendering the open Rename / Move / Copy dialog. */
export function FileRefactorDialogHost() {
const request = useRequest((s) => s.request);
if (!request) return null;
return request.kind === "rename" ? (
<RenameDialog key={request.seq} request={request} />
) : (
) : request.kind === "move" ? (
<MoveDialog key={request.seq} request={request} />
) : (
<CopyDialog key={request.seq} request={request} />
);
}
+17 -5
View File
@@ -9,6 +9,7 @@ import { MessagesHost } from "@/workbench/messages";
import { FileRefactorDialogHost } from "./FileRefactorDialogs";
import { setRefactorBackendForTests } from "./fileRefactor";
import { ProjectView } from "./ProjectView";
import { beginNew } from "./operations";
import { initProjectTree, setViewOption, useProjectTree } from "./store";
const ROOT = "/w/acme";
@@ -70,18 +71,29 @@ describe("ProjectView", () => {
await waitFor(() => expect(rows()).toEqual(["acme/w/acme", "build", "src", "App.kt", "Util.kt", "README.md"]));
});
it("opens files on double click and previews on single click", async () => {
it("opens files on double click and previews on single click only when enabled", async () => {
const { row, rows } = setup();
await waitFor(() => expect(rows()).toContain("README.md"));
const content = row("README.md").querySelector("[data-project-path]")!;
fireEvent.click(content, { detail: 1 });
expect(openFile).toHaveBeenLastCalledWith("/w/acme/README.md", { preview: true, focus: false });
expect(openFile).not.toHaveBeenCalled();
fireEvent.doubleClick(row("README.md"));
expect(openFile).toHaveBeenLastCalledWith("/w/acme/README.md", { preview: undefined, focus: true });
act(() => setViewOption("previewTab", false));
openFile.mockClear();
act(() => setViewOption("previewTab", true));
fireEvent.click(content, { detail: 1 });
expect(openFile).not.toHaveBeenCalled();
expect(openFile).toHaveBeenLastCalledWith("/w/acme/README.md", { preview: true, focus: false });
act(() => setViewOption("previewTab", false));
});
it("cancels a new file when its name field loses focus", async () => {
const { rows } = setup();
await waitFor(() => expect(rows()).toContain("README.md"));
await act(() => beginNew("file", ROOT));
const input = await screen.findByRole("textbox", { name: "New file name" });
fireEvent.change(input, { target: { value: "Notes.md" } });
await act(async () => void fireEvent.blur(input));
expect(useProjectTree.getState().editing).toBeNull();
expect(fs.nodes.has("/w/acme/Notes.md")).toBe(false);
});
it("publishes the focus context key", async () => {
+3 -1
View File
@@ -177,8 +177,10 @@ function InlineEditor({ editing, initial, onDone }: { editing: Editing; initial:
}
}}
onBlur={() => {
// Like IDEA's New File popup, clicking elsewhere cancels creating; a rename commits.
if (editing.kind === "new") return void finish(false);
const valid = !checkEditingName(value).error && !!value.trim();
void finish(valid && (editing.kind === "new" || value !== initial));
void finish(valid && value !== initial);
}}
/>
{showError && (
+5
View File
@@ -211,6 +211,8 @@ export function registerProjectActions(): () => void {
category: "Project View",
icon: "trash",
when: FOCUS,
// Not while speed search (a popup) owns ⌫.
keyWhen: "!popupOpen",
keywords: ["remove", "trash"],
run: ({ arg }) => deleteSelection(nodesFrom(arg)),
},
@@ -324,6 +326,7 @@ export function registerProjectActions(): () => void {
category: "Project View",
icon: "refresh",
when: OPEN,
keyWhen: FOCUS,
keywords: ["synchronize", "refresh"],
run: ({ arg }) => reloadFromDisk(nodesFrom(arg)),
},
@@ -333,6 +336,7 @@ export function registerProjectActions(): () => void {
category: "Project View",
icon: "expand-all",
when: OPEN,
keyWhen: FOCUS,
run: () => {
const lead = leadNode();
return expandAll(lead?.dir && !lead.root ? lead.path : undefined);
@@ -344,6 +348,7 @@ export function registerProjectActions(): () => void {
category: "Project View",
icon: "collapse-all",
when: OPEN,
keyWhen: FOCUS,
run: () => {
collapseAll();
treeHandle.current?.focus();
+5 -1
View File
@@ -2,7 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { FileEntry } from "@/bindings";
import { registerProjectViewRoot } from "@/workbench/contributions";
vi.mock("@/workbench/messages", () => ({ confirm: async () => true }));
vi.mock("@/workbench/messages", () => ({
confirm: async () => true,
confirmChecked: async () => ({ checked: false }),
ask: async () => ({ button: "cancel", checked: false }),
}));
vi.mock("@/editor/refactor", () => ({
fileUsages: async () => ({ status: "ok", usages: [] }),
askUsagesDetected: async () => "cancel",
+87 -17
View File
@@ -6,7 +6,7 @@ import { createElement, Fragment } from "react";
import { askUsagesDetected, fileUsages, viewUsages } from "@/editor/refactor";
import { executeAction, getAction } from "@/platform/actions";
import { maybeEditorService, notifications } from "@/workbench/contributions";
import { confirm } from "@/workbench/messages";
import { ask, confirmChecked } from "@/workbench/messages";
import { runFileRefactoring } from "./fileRefactor";
import { openFileRefactorDialog, rememberedOptions } from "./FileRefactorDialogs";
import { projectFs } from "./fs";
@@ -165,7 +165,7 @@ export function openMoveDialog(nodes: ProjectNode[] = selectedNodes(), target?:
openMoveDialogFor(paths, target, paths.length === 1 && movable.some((n) => n.path === paths[0] && n.dir));
}
function openMoveDialogFor(paths: string[], target?: string, dir?: boolean) {
function openMoveDialogFor(paths: string[], target?: string, dir?: boolean, done?: () => void) {
if (!paths.length) return;
openFileRefactorDialog({
kind: "move",
@@ -173,6 +173,7 @@ function openMoveDialogFor(paths: string[], target?: string, dir?: boolean) {
dir,
target: target ?? parentOf(paths[0]!),
onDone: (moves) => {
done?.();
void afterMove(moves);
if (rememberedOptions("move").openInEditor)
for (const m of moves) if (!get().listings.has(m.to)) void openFile(m.to, { focus: false });
@@ -306,6 +307,16 @@ export function selectionAfterDelete(rows: readonly ProjectNode[], deleted: read
/** Safe delete searches at most this many files, for at most this long. */
const SAFE_DELETE_MAX_FILES = 20;
const SAFE_DELETE_TIMEOUT_MS = 15_000;
const SAFE_DELETE_PROGRESS_DELAY_MS = 400;
const SAFE_DELETE_KEY = "openidea.projectTree.safeDelete";
function safeDeleteEnabled(): boolean {
try {
return localStorage.getItem(SAFE_DELETE_KEY) !== "false";
} catch {
return true;
}
}
/**
* IDEA's safe delete for files: usages of their top-level symbols from other
@@ -316,15 +327,35 @@ export async function safeToDelete(files: string[]): Promise<boolean> {
if (files.length > SAFE_DELETE_MAX_FILES) return true;
const abort = new AbortController();
const timer = setTimeout(() => abort.abort(), SAFE_DELETE_TIMEOUT_MS);
// A search that takes a while shows progress the user can skip (delete anyway) or cancel.
const progress = new AbortController();
const user: { choice: "skip" | "cancel" | null } = { choice: null };
const shown = setTimeout(() => {
void ask({
title: "Delete",
body: "Looking for usages…",
icon: "info",
buttons: [{ id: "skip", label: "Delete Without Search" }],
signal: progress.signal,
}).then((r) => {
if (r.button === "dismissed") return;
user.choice = r.button === "skip" ? "skip" : "cancel";
abort.abort();
});
}, SAFE_DELETE_PROGRESS_DELAY_MS);
let result;
try {
result = await fileUsages(files, abort.signal);
} catch {
if (user.choice) return user.choice === "skip";
notifications().notify({ level: "warning", title: "Usage search did not finish; nothing was deleted", key: "project.safeDelete" });
return false;
} finally {
clearTimeout(timer);
clearTimeout(shown);
progress.abort();
}
if (user.choice) return user.choice === "skip";
if (result.status !== "ok" || !result.usages.length) return true;
const name = files.length === 1 ? nameOf(files[0]!) : `${files.length} files`;
const subject = files.length === 1 ? `File '${name}'` : `The ${name}`;
@@ -368,9 +399,14 @@ export async function deleteSelection(nodes: ProjectNode[] = selectedNodes()) {
// Invoked from the tree (or its context menu): give the tree its focus back afterwards.
const refocus = typeof document !== "undefined" && !!document.activeElement?.closest('[role="tree"], [role="menu"]');
const folders = nodes.filter((n) => n.dir && paths.includes(n.path)).length;
const ok = await confirm({
const files = nodes.filter((n) => !n.dir && paths.includes(n.path)).map((n) => n.path);
// IDEA's Delete dialog: "Safe delete (with usage search)", remembered.
const searchable = files.length > 0 && files.length <= SAFE_DELETE_MAX_FILES;
const answer = await confirmChecked({
title: "Delete",
ok: "Delete",
checkbox: searchable ? "Safe delete (with usage search)" : undefined,
checked: searchable && safeDeleteEnabled(),
message: createElement(
Fragment,
null,
@@ -379,9 +415,15 @@ export async function deleteSelection(nodes: ProjectNode[] = selectedNodes()) {
"Items are moved to the Trash.",
),
});
if (!ok) return;
const files = nodes.filter((n) => !n.dir && paths.includes(n.path)).map((n) => n.path);
if (files.length && !(await safeToDelete(files))) return;
if (!answer) return;
if (searchable) {
try {
localStorage.setItem(SAFE_DELETE_KEY, String(answer.checked));
} catch {
/* storage unavailable */
}
}
if (searchable && answer.checked && !(await safeToDelete(files))) return;
const next = selectionAfterDelete(visibleNodes(), paths);
const fs = projectFs(root);
const parents = new Set<string>();
@@ -412,18 +454,47 @@ export function canPaste(): boolean {
return !!get().clipboard?.paths.length;
}
/** Paste: cut items go through the Move dialog, copied ones through the Copy dialog (IDEA). */
export async function paste(target = targetFolder()) {
const { clipboard } = get();
if (!clipboard || !target) return;
const done = await transfer(clipboard.paths, target, clipboard.cut ? "move" : "copy");
if (clipboard.cut && done) setClipboard(null);
const isDir = (p: string) => get().listings.has(p) || currentModel().byKey.get(currentModel().keyOfPath.get(p) ?? p)?.dir === true;
const single = clipboard.paths.length === 1 && isDir(clipboard.paths[0]!);
if (!clipboard.cut) return openCopyDialogFor(clipboard.paths, target, single);
const paths = clipboard.paths.filter((p) => parentOf(p) !== target);
if (!paths.length) return;
openMoveDialogFor(paths, target, single, () => {
if (get().clipboard === clipboard) setClipboard(null);
});
}
function openCopyDialogFor(paths: string[], target: string, dir?: boolean) {
if (!paths.length) return;
void loadDir(target);
openFileRefactorDialog({
kind: "copy",
paths,
target,
dir,
copy: async (to, name, openInEditor) => {
const first = await transfer(paths, to, "copy", name ?? undefined);
if (typeof first === "string" && openInEditor) void openFile(first, { focus: true });
return !!first;
},
});
}
/**
* Moves or copies `paths` into `target`. Copies get a non-conflicting name
* (`Foo copy.kt`); moves refuse to overwrite. Returns whether anything changed.
* Moves or copies `paths` into `target`. Copies take `name` (a single item) or
* get a non-conflicting name (`Foo copy.kt`); moves refuse to overwrite.
* Returns whether anything changed (for copies: the first copy's path).
*/
export async function transfer(paths: string[], target: string, mode: "move" | "copy"): Promise<boolean> {
export async function transfer(
paths: string[],
target: string,
mode: "move" | "copy",
name?: string,
): Promise<string | boolean> {
if (mode === "move") return moveInto(paths, target);
const { root } = get();
if (!root) return false;
@@ -434,6 +505,7 @@ export async function transfer(paths: string[], target: string, mode: "move" | "
const touched = new Set<string>([target]);
const errors: string[] = [];
let first: string | null = null;
const newName = name;
for (const src of paths) {
const name = nameOf(src);
if (isSameOrInside(target, src) && target !== parentOf(src) && src !== target) {
@@ -441,7 +513,7 @@ export async function transfer(paths: string[], target: string, mode: "move" | "
continue;
}
const srcIsDir = get().listings.has(src) || currentModel().byKey.get(src)?.dir === true;
const finalName = uniqueName(name, isTaken, srcIsDir);
const finalName = paths.length === 1 && newName ? newName : uniqueName(name, isTaken, srcIsDir);
const to = joinPath(target, finalName);
try {
await fs.copy(src, to);
@@ -458,7 +530,7 @@ export async function transfer(paths: string[], target: string, mode: "move" | "
expandFolder(target);
revealPath(first, false);
}
return first !== null;
return first ?? false;
}
/**
@@ -499,10 +571,8 @@ export async function dropPaths(paths: string[], target: string, copy: boolean)
const node = movable.length === 1 ? currentModel().byKey.get(currentModel().keyOfPath.get(movable[0]!) ?? "") : undefined;
return openMoveDialogFor(movable, target, node?.dir);
}
const { root } = get();
const where = root && target === root ? (get().name ?? nameOf(target)) : relativeTo(root ?? "", target) || target;
const ok = await confirm({ title: "Copy", ok: "Copy", message: `Copy ${describe(movable)} to '${where}'?` });
if (ok) await transfer(movable, target, "copy");
const node = movable.length === 1 ? currentModel().byKey.get(currentModel().keyOfPath.get(movable[0]!) ?? "") : undefined;
openCopyDialogFor(movable, target, node?.dir);
}
/** Files dropped from the OS: copied into `target` (never moved). */
@@ -11,6 +11,12 @@ vi.mock("@/workbench/contributions", async (orig) => ({
notifications: () => ({ notify }),
}));
const ask = vi.fn();
vi.mock("@/workbench/messages", async (orig) => ({
...(await orig<typeof import("@/workbench/messages")>()),
ask,
}));
const { safeToDelete } = await import("./operations");
const usage: Location = { path: "/p/main.rs", range: { start: { line: 1, col: 0 }, end: { line: 1, col: 1 } } };
@@ -46,4 +52,27 @@ describe("safe delete of files", () => {
expect(await safeToDelete(["/p/a.rs"])).toBe(false);
expect(notify).toHaveBeenCalledWith(expect.objectContaining({ level: "warning" }));
});
it("lets a slow search be skipped (delete anyway) or cancelled", async () => {
vi.useFakeTimers();
try {
const pending = (_: string[], signal: AbortSignal) =>
new Promise((_r, reject) => signal.addEventListener("abort", () => reject(new Error("aborted"))));
fileUsages.mockImplementation(pending);
ask.mockResolvedValueOnce({ button: "skip", checked: false });
const skipped = safeToDelete(["/p/a.rs"]);
await vi.advanceTimersByTimeAsync(500);
expect(await skipped).toBe(true);
expect(ask).toHaveBeenCalledWith(expect.objectContaining({ body: "Looking for usages…" }));
ask.mockResolvedValueOnce({ button: "cancel", checked: false });
const cancelled = safeToDelete(["/p/a.rs"]);
await vi.advanceTimersByTimeAsync(500);
expect(await cancelled).toBe(false);
expect(notify).not.toHaveBeenCalled();
} finally {
fileUsages.mockReset();
vi.useRealTimers();
}
});
});
+15 -4
View File
@@ -13,6 +13,7 @@ import {
useProjectTree,
} from "./store";
import { beginNew, commitEditing, copySelection, paste, transfer } from "./operations";
import { closeFileRefactorDialog, pendingFileRefactorDialog } from "./FileRefactorDialogs";
const ROOT = "/p";
let fs: MemoryFs;
@@ -140,20 +141,30 @@ describe("file operations", () => {
expect(childLabels("/p/docs")).toContain("notes");
});
it("copies with unique names and moves with cut / paste", async () => {
it("pastes copies through the Copy dialog and cut items through the Move dialog", async () => {
await loadDir(ROOT);
await loadDir("/p/docs");
setSelection(new Set(["/p/README.md"]), "/p/README.md");
copySelection(false);
await paste(ROOT);
expect(fs.nodes.has("/p/README copy.md")).toBe(true);
const copy = pendingFileRefactorDialog();
expect(copy).toMatchObject({ kind: "copy", paths: ["/p/README.md"], target: ROOT });
if (copy?.kind !== "copy") throw new Error("no copy dialog");
expect(await copy.copy(ROOT, "NOTES.md", false)).toBe(true);
expect(fs.nodes.has("/p/NOTES.md")).toBe(true);
// Without a name (several items) copies get a unique name.
expect(await transfer(["/p/README.md"], ROOT, "copy")).toBe("/p/README copy.md");
closeFileRefactorDialog();
setSelection(new Set(["/p/README.md"]), "/p/README.md");
copySelection(true);
await paste("/p/docs");
expect(fs.nodes.has("/p/docs/README.md")).toBe(true);
expect(fs.nodes.has("/p/README.md")).toBe(false);
const move = pendingFileRefactorDialog();
expect(move).toMatchObject({ kind: "move", paths: ["/p/README.md"], target: "/p/docs" });
if (move?.kind !== "move") throw new Error("no move dialog");
move.onDone?.([{ from: "/p/README.md", to: "/p/docs/README.md" }]);
expect(useProjectTree.getState().clipboard).toBeNull();
closeFileRefactorDialog();
});
it("refuses to move a folder into itself", async () => {
+2 -1
View File
@@ -39,7 +39,8 @@ export const DEFAULT_VIEW_OPTIONS: ViewOptions = {
autoscrollFromSource: false,
autoscrollToSource: false,
singleClickOpen: false,
previewTab: true,
// IDEA's "Enable Preview Tab" is off by default.
previewTab: false,
};
export type Editing =
+80 -6
View File
@@ -1,11 +1,11 @@
import { useMemo, useState, type KeyboardEvent } from "react";
import { useEffect, useMemo, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import { executeAction } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
import { HighlightedText, Icon, IconButton, List, Popup, SearchField, fuzzyMatch, listNavigate, T } from "@/ui";
import type { RunConfig } from "@/platform/ipc";
import { commandLine, configIcon, groupConfigs } from "./configs";
import { saveTemporary, selectConfig, startRun, useRun } from "./store";
import { canDeleteConfig, deleteConfig, saveTemporary, selectConfig, startRun, useRun } from "./store";
import styles from "./Run.module.css";
type Row =
@@ -54,7 +54,19 @@ export function ConfigChooserOverlay() {
return <ConfigChooser />;
}
/** Numeric mnemonics (IDEA: 1–9 run the Nth configuration while the search is empty). */
export function mnemonics(rows: readonly Row[]): Map<string, number> {
const out = new Map<string, number>();
for (const r of rows) {
if (r.type !== "config") continue;
if (out.size >= 9) break;
out.set(r.key, out.size + 1);
}
return out;
}
function ConfigChooser() {
const mode = useRun((s) => s.chooserMode);
const configs = useRun((s) => s.configs);
const selectedId = useRun((s) => s.selectedId);
const temporary = useRun((s) => s.temporary);
@@ -81,8 +93,27 @@ function ConfigChooser() {
useRun.setState({ chooserOpen: false });
};
const clamped = Math.min(active, rows.length - 1);
const numbers = useMemo(
() => (mode === "select" || query ? new Map<string, number>() : mnemonics(rows)),
[mode, query, rows],
);
// IDEA: holding Shift switches to the alternative executor (Run ⇄ Debug) and updates the caption.
const [shift, setShift] = useState(false);
useEffect(() => {
const track = (e: globalThis.KeyboardEvent) => setShift(e.shiftKey);
const reset = () => setShift(false);
window.addEventListener("keydown", track, true);
window.addEventListener("keyup", track, true);
window.addEventListener("blur", reset);
return () => {
window.removeEventListener("keydown", track, true);
window.removeEventListener("keyup", track, true);
window.removeEventListener("blur", reset);
};
}, []);
const executor = mode === "select" ? "select" : shift ? (mode === "run" ? "debug" : "run") : mode;
const activate = (row: Row | undefined, mode: "select" | "run" | "debug" = "select") => {
const activate = (row: Row | undefined, how: "select" | "run" | "debug" | "edit" = executor) => {
if (!row) return;
close();
if (row.type === "edit") {
@@ -95,16 +126,44 @@ function ConfigChooser() {
}
if (row.type !== "config") return;
selectConfig(row.config.id);
if (mode === "run") void startRun(row.config);
if (mode === "debug") void executeAction("run.debug");
if (how === "edit") void executeAction("run.editConfigs", row.config.id);
if (how === "run") void startRun(row.config);
if (how === "debug") void executeAction("run.debug");
};
const onKeyDown = (e: KeyboardEvent) => {
const row = rows[clamped];
if (e.key === "Enter") {
e.preventDefault();
activate(rows[clamped], e.shiftKey ? "debug" : e.altKey || e.metaKey ? "run" : "select");
// The widget (select mode) keeps Shift+Enter = Debug and Alt/⌘+Enter = Run.
activate(row, mode !== "select" ? executor : e.shiftKey ? "debug" : e.altKey || e.metaKey ? "run" : "select");
return;
}
if (e.key === "F4" && row?.type === "config") {
e.preventDefault();
activate(row, "edit");
return;
}
const input = e.target as HTMLInputElement;
if (
e.key === "Delete" &&
row?.type === "config" &&
canDeleteConfig(row.config.id) &&
input.selectionStart === query.length
) {
e.preventDefault();
void deleteConfig(row.config.id);
return;
}
if (numbers.size && !e.altKey && !e.ctrlKey && !e.metaKey && /^Digit[1-9]$|^Numpad[1-9]$/.test(e.code)) {
const n = Number(e.code.slice(-1));
const target = rows.find((r) => numbers.get(r.key) === n);
if (target) {
e.preventDefault();
activate(target, e.shiftKey ? (mode === "run" ? "debug" : "run") : mode);
return;
}
}
const next = listNavigate(e.key, { items: rows, active: clamped, isSection, wrap: true });
if (next !== null) {
e.preventDefault();
@@ -124,6 +183,11 @@ function ConfigChooser() {
className={styles.chooser}
trapFocus
>
{mode !== "select" && (
<div className={styles.chooserCaption}>
<T>{executor === "debug" ? "Debug" : "Run"}</T>
</div>
)}
<div className={styles.chooserSearch}>
<SearchField
value={query}
@@ -181,6 +245,7 @@ function ConfigChooser() {
size={13}
className={clsx(styles.chooserIcon, temporary.includes(c.id) && styles.chooserTemporary)}
/>
{numbers.size > 0 && <span className={styles.chooserMnemonic}>{numbers.get(r.key) ?? ""}</span>}
<span className={clsx(styles.chooserName, c.id === selectedId && styles.chooserSelected)}>
{r.indices ? <HighlightedText text={c.name} indices={r.indices} /> : c.name}
</span>
@@ -218,6 +283,15 @@ function ConfigChooser() {
}}
/>
</div>
{mode !== "select" && (
<div className={styles.chooserHint}>
<T>
{mode === "run"
? "Hold Shift to Debug, F4 to edit, Delete to remove"
: "Hold Shift to Run, F4 to edit, Delete to remove"}
</T>
</div>
)}
</Popup>
);
}
+129 -26
View File
@@ -1,8 +1,19 @@
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent } from "react";
import {
memo,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ClipboardEvent,
type CSSProperties,
type KeyboardEvent,
type MouseEvent,
} from "react";
import clsx from "clsx";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { showContextMenu } from "@/ui";
import { Icon, showContextMenu } from "@/ui";
import type { ConsoleLine, ConsoleModel, SpanStyle } from "./console";
import { findOutputLinks, type OutputLink } from "./links";
import { openTarget, openUrl } from "./navigate";
@@ -62,11 +73,23 @@ const Line = memo(
(a, b) => a.line === b.line && a.length === b.length && a.cwd === b.cwd,
);
/** A visible console row: one line, or a fold of consecutive lines (Settings › Console). */
export type DisplayRow = { kind: "line"; index: number } | { kind: "fold"; start: number; count: number; id: number };
/** A run of folded lines (Settings › Console › Fold lines that contain). */
export interface Fold {
start: number;
count: number;
/** Id of the first folded line (stable across trimming). */
id: number;
expanded: boolean;
}
/**
* Folds runs of at least two consecutive output lines that contain one of
* A visible console row: one line (carrying the fold of the lines after it, IDEA attaches
* the `<N internal lines>` placeholder to the previous line), or a fold with no line before it.
*/
export type DisplayRow = { kind: "line"; index: number; fold?: Fold } | { kind: "fold"; fold: Fold };
/**
* Folds runs of consecutive output lines (one or more) that contain one of
* `patterns`; runs whose first line id is in `expanded` stay open.
*/
export function foldRows(lines: readonly ConsoleLine[], patterns: readonly string[], expanded: ReadonlySet<number>): DisplayRow[] {
@@ -76,13 +99,12 @@ export function foldRows(lines: readonly ConsoleLine[], patterns: readonly strin
if (patterns.length && matches(lines[i]!)) {
let j = i + 1;
while (j < lines.length && matches(lines[j]!)) j++;
const id = lines[i]!.id;
if (j - i >= 2 && !expanded.has(id)) {
rows.push({ kind: "fold", start: i, count: j - i, id });
i = j;
continue;
}
for (; i < j; i++) rows.push({ kind: "line", index: i });
const fold: Fold = { start: i, count: j - i, id: lines[i]!.id, expanded: expanded.has(lines[i]!.id) };
const prev = rows[rows.length - 1];
if (prev?.kind === "line") prev.fold = fold;
else if (!fold.expanded) rows.push({ kind: "fold", fold });
if (fold.expanded) for (; i < j; i++) rows.push({ kind: "line", index: i });
i = j;
continue;
}
rows.push({ kind: "line", index: i });
@@ -91,6 +113,17 @@ export function foldRows(lines: readonly ConsoleLine[], patterns: readonly strin
return rows;
}
/** Typed console input (IDEA: type into the console, Enter sends the line). */
export interface ConsoleInput {
/** Typed, not yet sent. */
pending: string;
onChange(pending: string): void;
/** Sends one line (without the newline). */
onSubmit(line: string): void;
/** Closes stdin (EOF). */
onEof(): void;
}
export interface ConsoleViewProps {
model: ConsoleModel;
version: number;
@@ -100,17 +133,31 @@ export interface ConsoleViewProps {
/** Directory relative paths in the output resolve against. */
cwd: string;
onClear?: () => void;
/** Set while the process accepts input. */
input?: ConsoleInput;
"aria-label"?: string;
}
export function ConsoleView({ model, version, softWrap, follow, onFollowChange, cwd, onClear, ...aria }: ConsoleViewProps) {
export function ConsoleView({ model, version, softWrap, follow, onFollowChange, cwd, onClear, input, ...aria }: ConsoleViewProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const lines = model.lines;
const patterns = useWorkflowSettings((s) => s.console.foldPatterns);
const [expanded, setExpanded] = useState<ReadonlySet<number>>(() => new Set());
// eslint-disable-next-line react-hooks/exhaustive-deps -- `version` marks in-place appends to `lines`
const rows = useMemo(() => foldRows(lines, patterns, expanded), [lines, version, patterns, expanded]);
const count = rows.length;
const folded = useMemo(() => foldRows(lines, patterns, expanded), [lines, version, patterns, expanded]);
// Pending input continues the open line (a prompt), or starts a row of its own.
const lastRow = folded[folded.length - 1];
const inputInline =
!!input && !!model.openLine && lastRow?.kind === "line" && !lastRow.fold && lines[lastRow.index] === model.openLine;
const inputRow = !!input && !inputInline;
const rows = folded;
const count = rows.length + (inputRow ? 1 : 0);
const toggleFold = (id: number) =>
setExpanded((s) => {
const next = new Set(s);
if (!next.delete(id)) next.add(id);
return next;
});
const programmatic = useRef(false);
const virtualizer = useVirtualizer({
@@ -120,8 +167,8 @@ export function ConsoleView({ model, version, softWrap, follow, onFollowChange,
overscan: 30,
getItemKey: (i) => {
const r = rows[i];
if (!r) return i;
return r.kind === "fold" ? `f${r.id}` : (lines[r.index]?.id ?? i);
if (!r) return "input";
return r.kind === "fold" ? `f${r.fold.id}` : (lines[r.index]?.id ?? i);
},
});
@@ -160,9 +207,56 @@ export function ConsoleView({ model, version, softWrap, follow, onFollowChange,
{ label: "Copy All", run: () => void navigator.clipboard?.writeText(model.text()).catch(() => undefined) },
{ kind: "separator" },
{ label: "Clear All", icon: "trash", disabled: !onClear, run: () => onClear?.() },
...(input ? [{ kind: "separator" } as const, { label: "Send EOF", shortcut: "⌃D", run: () => input.onEof() }] : []),
]);
};
const onKeyDown = (e: KeyboardEvent) => {
if (!input || e.nativeEvent.isComposing) return;
if (e.key === "d" && e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
input.onEof();
} else if (e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
input.onSubmit(input.pending);
input.onChange("");
} else if (e.key === "Backspace" && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
input.onChange(e.altKey ? input.pending.replace(/\S*\s*$/, "") : [...input.pending].slice(0, -1).join(""));
} else if (e.key.length === 1 && [...e.key].length === 1 && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
input.onChange(input.pending + e.key);
}
};
const onPaste = (e: ClipboardEvent) => {
if (!input) return;
const text = e.clipboardData.getData("text/plain");
if (!text) return;
e.preventDefault();
const parts = (input.pending + text).replace(/\r\n?/g, "\n").split("\n");
const rest = parts.pop() ?? "";
for (const line of parts) input.onSubmit(line);
input.onChange(rest);
};
const pendingInput = input && (
<span className={styles.userInput}>
{input.pending}
<span className={styles.inputCaret} aria-hidden />
</span>
);
const foldButton = (fold: Fold, attached: boolean) =>
fold.expanded ? (
<button type="button" className={styles.foldToggle} title="Collapse folded lines" aria-label="Collapse folded lines" onClick={() => toggleFold(fold.id)}>
<Icon name="chevron-up" size={10} />
</button>
) : (
<button type="button" className={styles.fold} title="Expand folded lines" onClick={() => toggleFold(fold.id)}>
{`${attached ? " " : ""}<${fold.count} internal line${fold.count === 1 ? "" : "s"}>`}
</button>
);
const items = virtualizer.getVirtualItems();
return (
<div
@@ -174,12 +268,26 @@ export function ConsoleView({ model, version, softWrap, follow, onFollowChange,
tabIndex={0}
onScroll={onScroll}
onContextMenu={onContextMenu}
onKeyDown={onKeyDown}
onPaste={onPaste}
data-autofocus=""
>
<div className={styles.consoleInner} style={{ height: virtualizer.getTotalSize() }}>
{items.map((item) => {
const row = rows[item.index];
if (!row) return null;
if (!row) {
return (
<div
key={item.key}
data-index={item.index}
ref={softWrap ? virtualizer.measureElement : undefined}
className={styles.line}
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : ROW }}
>
{pendingInput}
</div>
);
}
if (row.kind === "fold") {
return (
<div
@@ -189,14 +297,7 @@ export function ConsoleView({ model, version, softWrap, follow, onFollowChange,
className={styles.line}
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : ROW }}
>
<button
type="button"
className={styles.fold}
title="Expand folded lines"
onClick={() => setExpanded((s) => new Set(s).add(row.id))}
>
{`<${row.count} internal line${row.count === 1 ? "" : "s"}>`}
</button>
{foldButton(row.fold, false)}
</div>
);
}
@@ -211,6 +312,8 @@ export function ConsoleView({ model, version, softWrap, follow, onFollowChange,
style={{ transform: `translateY(${item.start}px)`, height: softWrap ? undefined : ROW }}
>
<Line line={line} length={line.text.length} cwd={cwd} />
{row.fold && foldButton(row.fold, true)}
{inputInline && item.index === rows.length - 1 && pendingInput}
</div>
);
})}
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff. Show more