mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
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:
1 parent
68dcea409c
commit
fb3d42b76d
193 files changed
+8673
-1329
No files matched your search
@@ -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).
|
||||
|
||||
@@ -287,6 +287,7 @@ mod tests {
|
||||
detected: true,
|
||||
is_test: true,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -232,6 +232,7 @@ mod tests {
|
||||
detected: true,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -260,6 +260,7 @@ mod tests {
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ fn cfg(id: &str, command: &str, args: &[&str]) -> RunConfig {
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ fn run(kind: &str, command: &str, args: &[&str]) -> RunConfig {
|
||||
detected: true,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)?;
|
||||
|
||||
@@ -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
@@ -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}"),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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))),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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"],
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user