From fb3d42b76db99440fdb0dc45233b4b2070066f10 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:35:33 +0800 Subject: [PATCH] feat: Align editor, workbench, run/debug and VCS interactions with IntelliJ IDEA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/ide-core/src/editor_settings.rs | 24 +- crates/ide-coverage/src/plan.rs | 1 + crates/ide-index/src/lib.rs | 20 + crates/ide-index/src/tests.rs | 4 + crates/ide-profiler/src/plan.rs | 1 + crates/ide-run/src/config.rs | 7 + crates/ide-run/src/detect.rs | 1 + crates/ide-run/src/lib.rs | 4 + crates/ide-run/src/wrap.rs | 1 + crates/ide-run/tests/config.rs | 2 + crates/ide-run/tests/process.rs | 1 + crates/ide-sdk/src/tests.rs | 1 + crates/ide-terminal/src/lib.rs | 108 ++++++ crates/ide-vcs/src/lib.rs | 174 ++++++++- crates/ide-vcs/src/ops.rs | 67 +++- crates/ide-vcs/src/write.rs | 247 +++++++++++- crates/ide-vcs/tests/history_ops.rs | 52 +++ crates/ide-vcs/tests/repo.rs | 207 ++++++++++ src-tauri/src/commands/mod.rs | 4 + src-tauri/src/commands/nav.rs | 7 + src-tauri/src/commands/term.rs | 8 + src-tauri/src/commands/vcs.rs | 72 ++-- src/bindings.ts | 201 +++++++++- src/editor/core/actions.ts | 83 ++-- src/editor/core/cm/baseKeymap.ts | 36 ++ src/editor/core/cm/caretCommands.test.ts | 103 +++++ src/editor/core/cm/caretCommands.ts | 92 +++++ src/editor/core/cm/comments.ts | 263 +++++++++++++ src/editor/core/cm/editing.test.ts | 83 +++- src/editor/core/cm/editing.ts | 59 +-- src/editor/core/cm/prefs.ts | 28 +- src/editor/core/cm/setup.ts | 11 +- src/editor/core/cm/smartKeys.test.ts | 152 ++++++++ src/editor/core/cm/smartKeys.ts | 144 +++++++ src/editor/core/controller.ts | 37 +- src/editor/core/index.ts | 2 + src/editor/core/model/model.test.ts | 70 +++- src/editor/core/model/navHistory.ts | 49 ++- src/editor/core/model/session.test.ts | 3 +- src/editor/core/model/slot.ts | 12 +- src/editor/core/model/tabs.ts | 19 +- src/editor/core/view/EditorArea.tsx | 10 +- src/editor/core/view/FindBar.tsx | 31 +- src/editor/core/view/NavBarPopup.module.css | 21 + src/editor/core/view/NavBarPopup.tsx | 108 ++++++ src/editor/core/view/StatusItems.tsx | 36 +- src/editor/core/view/navChildren.test.tsx | 33 ++ src/editor/lsp/Popups.module.css | 7 + src/editor/lsp/Popups.tsx | 20 +- src/editor/lsp/actions.ts | 4 +- src/editor/lsp/completion.test.ts | 57 ++- src/editor/lsp/completion.ts | 161 +++++++- src/editor/lsp/diagnostics.ts | 2 +- src/editor/lsp/features.test.ts | 57 ++- src/editor/lsp/hover.ts | 27 +- src/editor/lsp/index.ts | 7 +- src/editor/lsp/inplaceRename.test.ts | 87 +++++ src/editor/lsp/inplaceRename.ts | 175 +++++++++ src/editor/lsp/intentions.ts | 49 ++- src/editor/lsp/markdown.ts | 17 + src/editor/lsp/navigation.ts | 223 +++++++++-- src/editor/lsp/popupStore.ts | 8 +- src/editor/lsp/rename.ts | 81 +++- src/editor/lsp/theme.ts | 12 + src/editor/navigation/index.ts | 2 +- src/features/build/Build.module.css | 41 +- src/features/build/BuildView.tsx | 134 +++++-- src/features/build/build.test.tsx | 76 +++- src/features/build/model.ts | 34 +- src/features/build/store.ts | 57 ++- .../debugger/__tests__/actions.test.ts | 19 +- .../debugger/__tests__/breakpoints.test.ts | 52 ++- .../debugger/__tests__/editor.test.ts | 42 +- .../debugger/__tests__/valueChanges.test.ts | 27 ++ src/features/debugger/actions.ts | 18 +- src/features/debugger/breakpoints/model.ts | 76 +++- src/features/debugger/breakpoints/store.ts | 7 +- .../debugger/editor/breakpointGutter.ts | 41 +- .../debugger/editor/editor.module.css | 24 +- src/features/debugger/editor/valueTooltip.ts | 27 +- src/features/debugger/index.ts | 13 +- src/features/debugger/state.ts | 20 +- src/features/debugger/ui/DebugToolWindow.tsx | 3 + src/features/debugger/ui/Overlays.module.css | 34 ++ src/features/debugger/ui/Overlays.tsx | 361 +++++++++++------- src/features/debugger/ui/ValueHintTree.tsx | 21 + .../debugger/ui/VariablesView.module.css | 14 +- src/features/debugger/ui/VariablesView.tsx | 87 ++++- src/features/debugger/ui/expressionHistory.ts | 38 ++ .../project-tree/FileRefactorDialogs.tsx | 149 +++++++- .../project-tree/ProjectView.test.tsx | 22 +- src/features/project-tree/ProjectView.tsx | 4 +- src/features/project-tree/actions.ts | 5 + src/features/project-tree/extraRoots.test.ts | 6 +- src/features/project-tree/operations.ts | 104 ++++- src/features/project-tree/safeDelete.test.ts | 29 ++ src/features/project-tree/store.test.ts | 19 +- src/features/project-tree/store.ts | 3 +- src/features/run/ConfigChooser.tsx | 86 ++++- src/features/run/ConsoleView.tsx | 155 ++++++-- src/features/run/EditConfigsDialog.tsx | 7 + src/features/run/Run.module.css | 77 +++- src/features/run/RunView.tsx | 77 +--- src/features/run/RunWidget.tsx | 6 +- src/features/run/console.ts | 5 + src/features/run/dialogs.tsx | 9 +- src/features/run/index.ts | 64 +++- src/features/run/session.test.tsx | 177 +++++++++ src/features/run/store.ts | 61 ++- src/features/run/workflow.test.ts | 32 +- .../search/__tests__/dialogs.test.tsx | 16 +- src/features/search/__tests__/recent.test.ts | 22 +- .../search/everywhere/SearchEverywhere.tsx | 32 +- src/features/search/everywhere/store.ts | 52 ++- src/features/search/find/FindInFiles.tsx | 2 + src/features/search/index.ts | 7 +- .../search/recent/RecentPopups.module.css | 1 + src/features/search/recent/RecentPopups.tsx | 177 ++++++--- src/features/search/recent/model.ts | 25 ++ .../settings/__tests__/dialog.test.tsx | 14 + .../settings/dialog/SettingsDialog.tsx | 9 +- .../settings/pages/editor/codeStyle.tsx | 27 +- .../settings/pages/editor/general.tsx | 1 + src/features/terminal/TerminalView.tsx | 21 +- src/features/terminal/index.ts | 11 +- src/features/terminal/keys.ts | 68 +++- src/features/terminal/runtime.ts | 1 + src/features/terminal/session.ts | 20 + src/features/terminal/store.ts | 32 ++ src/features/terminal/terminal.test.ts | 28 +- src/features/vcs/CommitView.module.css | 34 ++ src/features/vcs/CommitView.tsx | 86 +++-- src/features/vcs/CompareBranches.tsx | 115 ++++-- src/features/vcs/DiffEditor.module.css | 17 + src/features/vcs/DiffEditor.tsx | 81 +++- src/features/vcs/GitLog.tsx | 12 +- src/features/vcs/MergeEditor.tsx | 42 +- src/features/vcs/__tests__/changes.test.ts | 26 ++ src/features/vcs/__tests__/conflicts.test.ts | 34 +- src/features/vcs/__tests__/diffChain.test.ts | 25 ++ src/features/vcs/__tests__/hunks.test.ts | 35 +- src/features/vcs/actions.tsx | 51 ++- src/features/vcs/branchOps.ts | 158 ++++++-- src/features/vcs/changeOps.tsx | 58 ++- src/features/vcs/changes.ts | 40 +- src/features/vcs/commitChecks.tsx | 141 ++++--- src/features/vcs/commitStore.ts | 59 ++- src/features/vcs/common.module.css | 5 + src/features/vcs/conflicts.ts | 56 ++- src/features/vcs/conflictsUi.tsx | 3 +- src/features/vcs/diffInput.ts | 26 +- src/features/vcs/gutter.ts | 28 ++ src/features/vcs/historyActions.tsx | 8 +- src/features/vcs/hunkInclusion.ts | 162 ++++++++ src/features/vcs/hunks.ts | 17 + src/features/vcs/ops.ts | 79 +++- src/features/vcs/remoteUi.tsx | 75 +++- src/platform/colorScheme.test.ts | 2 +- src/platform/editorSettings.ts | 44 ++- src/platform/keymap.test.ts | 142 ++++++- src/platform/keymap.ts | 110 ++++-- src/platform/keymaps/ideaParity.test.ts | 60 +++ src/platform/keymaps/intellij-macos.ts | 45 ++- src/platform/keymaps/intellij-windows.ts | 47 ++- src/platform/keymaps/vscode-macos.ts | 21 + src/platform/keymaps/vscode-windows.ts | 1 + src/platform/locales/en.json | 3 + src/platform/locales/text/zh.json | 69 +++- src/platform/locales/zh.json | 3 + src/ui/Dialog.tsx | 11 +- src/ui/Input.module.css | 14 + src/ui/Input.tsx | 48 ++- src/ui/Popup.test.tsx | 47 +++ src/ui/Popup.tsx | 12 +- src/ui/Tabs.test.tsx | 42 +- src/ui/Tabs.tsx | 41 +- src/ui/Tree.tsx | 10 + src/ui/popupState.ts | 19 + src/workbench/Notifications.tsx | 32 +- src/workbench/Workbench.test.tsx | 118 +++++- src/workbench/activate.ts | 8 +- src/workbench/contributions.ts | 14 +- src/workbench/distractionFree.test.tsx | 2 +- src/workbench/messages.test.tsx | 20 + src/workbench/messages.tsx | 36 +- src/workbench/notificationStore.ts | 3 +- .../toolwindows/ToolWindows.module.css | 44 +++ src/workbench/toolwindows/ToolWindows.tsx | 309 +++++++++++---- src/workbench/toolwindows/actions.ts | 17 +- src/workbench/toolwindows/content.test.ts | 18 + src/workbench/toolwindows/content.ts | 26 ++ src/workbench/toolwindows/store.test.ts | 146 ++++++- src/workbench/toolwindows/store.ts | 321 +++++++++++----- 193 files changed, 8673 insertions(+), 1329 deletions(-) create mode 100644 src/editor/core/cm/baseKeymap.ts create mode 100644 src/editor/core/cm/caretCommands.test.ts create mode 100644 src/editor/core/cm/caretCommands.ts create mode 100644 src/editor/core/cm/comments.ts create mode 100644 src/editor/core/cm/smartKeys.test.ts create mode 100644 src/editor/core/cm/smartKeys.ts create mode 100644 src/editor/core/view/navChildren.test.tsx create mode 100644 src/editor/lsp/inplaceRename.test.ts create mode 100644 src/editor/lsp/inplaceRename.ts create mode 100644 src/features/debugger/__tests__/valueChanges.test.ts create mode 100644 src/features/debugger/ui/ValueHintTree.tsx create mode 100644 src/features/debugger/ui/expressionHistory.ts create mode 100644 src/features/run/session.test.tsx create mode 100644 src/features/vcs/__tests__/diffChain.test.ts create mode 100644 src/features/vcs/hunkInclusion.ts create mode 100644 src/platform/keymaps/ideaParity.test.ts create mode 100644 src/workbench/toolwindows/content.test.ts create mode 100644 src/workbench/toolwindows/content.ts diff --git a/crates/ide-core/src/editor_settings.rs b/crates/ide-core/src/editor_settings.rs index 43547f962..b6f879a4f 100644 --- a/crates/ide-core/src/editor_settings.rs +++ b/crates/ide-core/src/editor_settings.rs @@ -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, #[serde(skip_serializing_if = "Option::is_none")] pub hard_wrap_at: Option, + /// Comment Code › Line comment at first column. + #[serde(skip_serializing_if = "Option::is_none")] + pub line_comment_at_first_column: Option, + /// Comment Code › Add a space at line comment start. + #[serde(skip_serializing_if = "Option::is_none")] + pub line_comment_add_space: Option, } /// Editor › Code Style (General tab + per-language overrides). diff --git a/crates/ide-coverage/src/plan.rs b/crates/ide-coverage/src/plan.rs index 5ff6c797b..4d3ebcc6e 100644 --- a/crates/ide-coverage/src/plan.rs +++ b/crates/ide-coverage/src/plan.rs @@ -287,6 +287,7 @@ mod tests { detected: true, is_test: true, debug_adapter: None, + allow_parallel: false, } } diff --git a/crates/ide-index/src/lib.rs b/crates/ide-index/src/lib.rs index eef25d350..2d737f9ff 100644 --- a/crates/ide-index/src/lib.rs +++ b/crates/ide-index/src/lib.rs @@ -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 { self.shared.recent.lock().clone() diff --git a/crates/ide-index/src/tests.rs b/crates/ide-index/src/tests.rs index 36468d7b6..1de6c22d4 100644 --- a/crates/ide-index/src/tests.rs +++ b/crates/ide-index/src/tests.rs @@ -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(); diff --git a/crates/ide-profiler/src/plan.rs b/crates/ide-profiler/src/plan.rs index 8dd749a37..9a679e02f 100644 --- a/crates/ide-profiler/src/plan.rs +++ b/crates/ide-profiler/src/plan.rs @@ -232,6 +232,7 @@ mod tests { detected: true, is_test: false, debug_adapter: None, + allow_parallel: false, } } diff --git a/crates/ide-run/src/config.rs b/crates/ide-run/src/config.rs index 72ed774f1..4fc44368b 100644 --- a/crates/ide-run/src/config.rs +++ b/crates/ide-run/src/config.rs @@ -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, + #[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> { 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 } diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 27027ec58..cb4c2fe03 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -37,6 +37,7 @@ fn config(id: impl Into, name: impl Into, kind: &str, command: & detected: true, is_test: false, debug_adapter: None, + allow_parallel: false, } } diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index ca0d3d52e..55f950573 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -34,6 +34,10 @@ pub struct RunConfig { pub is_test: bool, /// DAP adapter that can debug this config, if any. pub debug_adapter: Option, + /// "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)] diff --git a/crates/ide-run/src/wrap.rs b/crates/ide-run/src/wrap.rs index d3fe25b9e..3c00c97bb 100644 --- a/crates/ide-run/src/wrap.rs +++ b/crates/ide-run/src/wrap.rs @@ -260,6 +260,7 @@ mod tests { detected: false, is_test: false, debug_adapter: None, + allow_parallel: false, } } diff --git a/crates/ide-run/tests/config.rs b/crates/ide-run/tests/config.rs index 7d364f1b2..74344b026 100644 --- a/crates/ide-run/tests/config.rs +++ b/crates/ide-run/tests/config.rs @@ -16,6 +16,7 @@ fn sample() -> Vec { 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 { detected: false, is_test: true, debug_adapter: None, + allow_parallel: false, }, ] } diff --git a/crates/ide-run/tests/process.rs b/crates/ide-run/tests/process.rs index a87cd90a9..bd9d51c4a 100644 --- a/crates/ide-run/tests/process.rs +++ b/crates/ide-run/tests/process.rs @@ -42,6 +42,7 @@ fn cfg(id: &str, command: &str, args: &[&str]) -> RunConfig { detected: false, is_test: false, debug_adapter: None, + allow_parallel: false, } } diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index ae2dce420..0d74f038e 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -46,6 +46,7 @@ fn run(kind: &str, command: &str, args: &[&str]) -> RunConfig { detected: true, is_test: false, debug_adapter: None, + allow_parallel: false, } } diff --git a/crates/ide-terminal/src/lib.rs b/crates/ide-terminal/src/lib.rs index 5592a2bc1..1ab4b72e9 100644 --- a/crates/ide-terminal/src/lib.rs +++ b/crates/ide-terminal/src/lib.rs @@ -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 { + 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 { let mut out: Vec = 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 ``. + #[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::() 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(); diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 4151a6c06..8cb4d85c8 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -136,6 +136,30 @@ pub struct CommitOptions { pub author: Option, /// 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, +} + +/// 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, +} + +/// 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, } +/// 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, +} + +/// 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, + }, + /// 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, + }, +} + +/// 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, + /// 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, + /// 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, + /// Short id of the commit being applied (REBASE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD) + /// or merged (MERGE_HEAD). + pub commit: Option, + /// The other branch: the one being merged, or the one rebased onto. + pub other_branch: Option, +} + +/// 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, + /// The user's side ("Accept Yours"): git's stage 2, or stage 3 when `sides.reversed`. + pub ours: Option, + pub theirs: Option, + 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 { - 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 { + 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 { + 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> { ops::tags(self) } + /// Files that differ between revisions `from` and `to`. + pub fn diff_revisions(&self, from: &str, to: &str) -> IdeResult> { + ops::diff_revisions(self, from, to) + } /// Tracked files whose working-tree content differs from `rev`. pub fn diff_with_worktree(&self, rev: &str) -> IdeResult> { 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 { + 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 { + 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, Option, Option)> { let rel = self.rel_path(path)?; diff --git a/crates/ide-vcs/src/ops.rs b/crates/ide-vcs/src/ops.rs index dba15a2a6..892a2ef66 100644 --- a/crates/ide-vcs/src/ops.rs +++ b/crates/ide-vcs/src/ops.rs @@ -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 { + 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::(); + 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 { + 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 = + 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 IdeResult> { + 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 { let mut parts = text.split('\0').filter(|s| !s.is_empty()); diff --git a/crates/ide-vcs/src/write.rs b/crates/ide-vcs/src/write.rs index e70c497f6..943808cbd 100644 --- a/crates/ide-vcs/src/write.rs +++ b/crates/ide-vcs/src/write.rs @@ -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), + /// `git rebase `. + Rebase { + local: String, + remote: String, + }, + Ahead { + local: String, + remote: String, + }, +} + +fn switch_plan(r: &Repo, branch: &str, create: bool, diverged: Option) -> IdeResult { + 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> { + 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 { + 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 { + 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 { + 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}"), diff --git a/crates/ide-vcs/tests/history_ops.rs b/crates/ide-vcs/tests/history_ops.rs index f9ff882a9..f29c1d026 100644 --- a/crates/ide-vcs/tests/history_ops.rs +++ b/crates/ide-vcs/tests/history_ops.rs @@ -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] diff --git a/crates/ide-vcs/tests/repo.rs b/crates/ide-vcs/tests/repo.rs index f785076b3..cc198bed4 100644 --- a/crates/ide-vcs/tests/repo.rs +++ b/crates/ide-vcs/tests/repo.rs @@ -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(); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 41a5ce6b3..c3d025307 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -103,6 +103,7 @@ pub fn builder() -> tauri_specta::Builder { 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 { 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 { 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 { term::term_write, term::term_resize, term::term_kill, + term::term_is_busy, term::term_default_shell, term::term_available_shells, run::run_configs, diff --git a/src-tauri/src/commands/nav.rs b/src-tauri/src/commands/nav.rs index 349862396..252d0f656 100644 --- a/src-tauri/src/commands/nav.rs +++ b/src-tauri/src/commands/nav.rs @@ -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] diff --git a/src-tauri/src/commands/term.rs b/src-tauri/src/commands/term.rs index a510080df..07401d208 100644 --- a/src-tauri/src/commands/term.rs +++ b/src-tauri/src/commands/term.rs @@ -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 { + state.terminals.is_busy(id) +} + /// Shell used when Settings › Terminal › Shell path is empty. #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 42ec9c4ee..1dbc9baf6 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -185,10 +185,15 @@ pub async fn vcs_checkout( repo: Option, branch: String, create: bool, -) -> IdeResult<()> { + options: Option, +) -> IdeResult { 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, branch: String, - force: bool, +) -> IdeResult { + 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, + 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, force_with_lease: bool, + #[serde(default)] + target: Option, }, /// 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, - pub ours: Option, - pub theirs: Option, -} - +/// 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 { +) -> IdeResult { 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, + from: String, + to: String, +) -> IdeResult> { + 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, diff --git a/src/bindings.ts b/src/bindings.ts index 223f04fc7..13403feaf 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -186,6 +186,7 @@ export const commands = { markRecent: (path: string) => typedError(__TAURI_INVOKE("mark_recent", { path })), /** Recently opened files, most recent first (⌘E). */ recentFiles: () => typedError(__TAURI_INVOKE("recent_files")), + removeRecent: (path: string) => typedError(__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(__TAURI_INVOKE("vcs_stage_patch", { patch })), vcsDiscard: (paths: string[]) => typedError(__TAURI_INVOKE("vcs_discard", { paths })), vcsCommit: (repo: string | null, message: string, options: CommitOptions) => typedError(__TAURI_INVOKE("vcs_commit", { repo, message, options })), - vcsCheckout: (repo: string | null, branch: string, create: boolean) => typedError(__TAURI_INVOKE("vcs_checkout", { repo, branch, create })), - vcsDeleteBranch: (repo: string | null, branch: string, force: boolean) => typedError(__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(__TAURI_INVOKE("vcs_checkout", { repo, branch, create, options })), + vcsDeleteBranch: (repo: string | null, branch: string) => typedError(__TAURI_INVOKE("vcs_delete_branch", { repo, branch })), + /** "Restore" of the branch-deleted notification. */ + vcsRestoreBranch: (repo: string | null, deleted: DeletedBranch) => typedError(__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(__TAURI_INVOKE("vcs_create_branch", { repo, name, start, checkout })), /** @@ -307,7 +314,8 @@ export const commands = { vcsStashDrop: (repo: string | null, index: number) => typedError(__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) => typedError(__TAURI_INVOKE("vcs_remote", { repo, request, op, onOutput })), - vcsConflictVersions: (path: string) => typedError(__TAURI_INVOKE("vcs_conflict_versions", { path })), + /** The user's side is `ours` (sides swapped during a rebase, as in IDEA). */ + vcsConflictVersions: (path: string) => typedError(__TAURI_INVOKE("vcs_conflict_versions", { path })), vcsDiffText: (path: string, staged: boolean) => typedError(__TAURI_INVOKE("vcs_diff_text", { path, staged })), /** Line hunks between two arbitrary texts (diff viewer, commit dialog). */ vcsDiffHunks: (oldText: string, newText: string) => __TAURI_INVOKE("vcs_diff_hunks", { oldText, newText }), @@ -336,6 +344,8 @@ export const commands = { vcsTags: (repo: string | null) => typedError(__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(__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(__TAURI_INVOKE("vcs_diff_revisions", { repo, from, to })), vcsCreatePatch: (source: PatchSource) => typedError(__TAURI_INVOKE("vcs_create_patch", { source })), vcsPatchPreview: (patch: string) => typedError(__TAURI_INVOKE("vcs_patch_preview", { patch })), vcsApplyPatch: (patch: string) => typedError(__TAURI_INVOKE("vcs_apply_patch", { patch })), @@ -358,14 +368,19 @@ export const commands = { termWrite: (id: TerminalId, data: string) => typedError(__TAURI_INVOKE("term_write", { id, data })), termResize: (id: TerminalId, cols: number, rows: number) => typedError(__TAURI_INVOKE("term_resize", { id, cols, rows })), termKill: (id: TerminalId) => typedError(__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(__TAURI_INVOKE("term_is_busy", { id })), /** Shell used when Settings › Terminal › Shell path is empty. */ termDefaultShell: () => __TAURI_INVOKE("term_default_shell"), /** Installed shells for the Terminal's "New Predefined Session" menu. */ termAvailableShells: () => __TAURI_INVOKE("term_available_shells"), /** Detected configs followed by user-defined ones (user ids win on conflict). */ - runConfigs: () => typedError(__TAURI_INVOKE("run_configs")), - runSaveConfigs: (configs: RunConfig[]) => typedError(__TAURI_INVOKE("run_save_configs", { configs })), - runStart: (config: RunConfig, onOutput: Channel) => typedError(__TAURI_INVOKE("run_start", { config, onOutput })), + runConfigs: () => typedError(__TAURI_INVOKE("run_configs")), + runSaveConfigs: (configs: RunConfig_Deserialize[]) => typedError(__TAURI_INVOKE("run_save_configs", { configs })), + runStart: (config: RunConfig_Deserialize, onOutput: Channel) => typedError(__TAURI_INVOKE("run_start", { config, onOutput })), runStop: (id: RunId) => typedError(__TAURI_INVOKE("run_stop", { id })), runStdin: (id: RunId, data: string) => typedError(__TAURI_INVOKE("run_stdin", { id, data })), runList: () => __TAURI_INVOKE("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(__TAURI_INVOKE("coverage_prepare", { config })), + coveragePrepare: (config: RunConfig_Deserialize) => typedError(__TAURI_INVOKE("coverage_prepare", { config })), /** Reads the report a coverage run left behind. */ coverageCollect: (report: CoverageReportSpec) => typedError(__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(__TAURI_INVOKE("web_open", { url, browser })), /** How to run `config` under a sampling profiler, or which profiler is missing. */ - profilerPrepare: (config: RunConfig) => typedError(__TAURI_INVOKE("profiler_prepare", { config })), + profilerPrepare: (config: RunConfig_Deserialize) => typedError(__TAURI_INVOKE("profiler_prepare", { config })), /** Reads the profile a profiled run left behind. */ profilerCollect: (spec: ProfileSpec) => typedError(__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 › › 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, }; diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts index 77783de77..8371a8a2a 100644 --- a/src/editor/core/actions.ts +++ b/src/editor/core/actions.ts @@ -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(); +/** One stack per selection range (by index): ⌥↑ / ⌥↓ work on every caret. */ +const stacks = new WeakMap(); /** 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(); +/** Backend candidates around `current` (the cached chain for repeated extends of one range). */ +async function backendCandidates(slot: EditorSlot, current: SimpleRange, cache: boolean): Promise { + 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); diff --git a/src/editor/core/cm/baseKeymap.ts b/src/editor/core/cm/baseKeymap.ts new file mode 100644 index 000000000..179eaaec6 --- /dev/null +++ b/src/editor/core/cm/baseKeymap.ts @@ -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([ + 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 }, +]; diff --git a/src/editor/core/cm/caretCommands.test.ts b/src/editor/core/cm/caretCommands.test.ts new file mode 100644 index 000000000..fb2e2bf3b --- /dev/null +++ b/src/editor/core/cm/caretCommands.test.ts @@ -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); + }); +}); diff --git a/src/editor/core/cm/caretCommands.ts b/src/editor/core/cm/caretCommands.ts new file mode 100644 index 000000000..11dd8d38e --- /dev/null +++ b/src/editor/core/cm/caretCommands.ts @@ -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); diff --git a/src/editor/core/cm/comments.ts b/src/editor/core/cm/comments.ts new file mode 100644 index 000000000..ecd86ddd9 --- /dev/null +++ b/src/editor/core/cm/comments.ts @@ -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 › › Code Generation › Comment Code. */ +export interface LineCommentStyle { + atFirstColumn: boolean; + addSpace: boolean; +} + +export const lineCommentStyle = Facet.define({ + 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(); + +/** + * ⌘/: 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; +}; diff --git a/src/editor/core/cm/editing.test.ts b/src/editor/core/cm/editing.test.ts index 50106f3ec..0272acc58 100644 --- a/src/editor/core/cm/editing.test.ts +++ b/src/editor/core/cm/editing.test.ts @@ -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); }); }); diff --git a/src/editor/core/cm/editing.ts b/src/editor/core/cm/editing.ts index 3a45f5e34..8d9a70df4 100644 --- a/src/editor/core/cm/editing.ts +++ b/src/editor/core/cm/editing.ts @@ -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; diff --git a/src/editor/core/cm/prefs.ts b/src/editor/core/cm/prefs.ts index da0aef1d3..4da4372c2 100644 --- a/src/editor/core/cm/prefs.ts +++ b/src/editor/core/cm/prefs.ts @@ -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 = { "(": 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; } diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index b1a6f2a90..3f60ebac2 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -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))), diff --git a/src/editor/core/cm/smartKeys.test.ts b/src/editor/core/cm/smartKeys.test.ts new file mode 100644 index 000000000..79ce8dd4d --- /dev/null +++ b/src/editor/core/cm/smartKeys.test.ts @@ -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[] = []; + 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); + }); +}); diff --git a/src/editor/core/cm/smartKeys.ts b/src/editor/core/cm/smartKeys.ts new file mode 100644 index 000000000..870865c94 --- /dev/null +++ b/src/editor/core/cm/smartKeys.ts @@ -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 = { "(": ")", "[": "]", "{": "}", '"': '"', "'": "'", "`": "`" }; + +interface Scope { + from: number; + to: number; +} + +const leaveScopes = StateEffect.define(); + +/** Inside each pair the editor inserted itself; Tab at a scope's end steps over the closing character. */ +export const tabOutScopes = StateField.define({ + 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; diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index cfdfd936e..60b1a1f65 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -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("[data-editor-focus], .cm-content")?.focus({ preventScroll: true }); } + openPaths(): string[] { + const s = useTabs.getState(); + const out = new Set(); + 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(); + 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; } diff --git a/src/editor/core/index.ts b/src/editor/core/index.ts index b3d067d20..9796bcd69 100644 --- a/src/editor/core/index.ts +++ b/src/editor/core/index.ts @@ -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 = [ diff --git a/src/editor/core/model/model.test.ts b/src/editor/core/model/model.test.ts index 258bcc928..360d0dfea 100644 --- a/src/editor/core/model/model.test.ts +++ b/src/editor/core/model/model.test.ts @@ -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 }); diff --git a/src/editor/core/model/navHistory.ts b/src/editor/core/model/navHistory.ts index b843e8843..384612a3a 100644 --- a/src/editor/core/model/navHistory.ts +++ b/src/editor/core/model/navHistory.ts @@ -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, b: Omit) => + 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) { + /** + * Records the caret position. `navigation`: the move came from a navigation + * command; a file switch always counts as one. + */ + update(loc: Omit, 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; diff --git a/src/editor/core/model/session.test.ts b/src/editor/core/model/session.test.ts index eb45e258f..3cd45ef51 100644 --- a/src/editor/core/model/session.test.ts +++ b/src/editor/core/model/session.test.ts @@ -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"); diff --git a/src/editor/core/model/slot.ts b/src/editor/core/model/slot.ts index 03f8067b7..3352d91b5 100644 --- a/src/editor/core/model/slot.ts +++ b/src/editor/core/model/slot.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). */ diff --git a/src/editor/core/model/tabs.ts b/src/editor/core/model/tabs.ts index b88de1171..7a460c20b 100644 --- a/src/editor/core/model/tabs.ts +++ b/src/editor/core/model/tabs.ts @@ -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 }; } diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index e14ac42b9..a87773e54 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -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; diff --git a/src/editor/core/view/FindBar.tsx b/src/editor/core/view/FindBar.tsx index 8ce10a071..be0e0fa45 100644 --- a/src/editor/core/view/FindBar.tsx +++ b/src/editor/core/view/FindBar.tsx @@ -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)} /> diff --git a/src/editor/core/view/NavBarPopup.module.css b/src/editor/core/view/NavBarPopup.module.css index fff146b39..8c6eda656 100644 --- a/src/editor/core/view/NavBarPopup.module.css +++ b/src/editor/core/view/NavBarPopup.module.css @@ -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); +} diff --git a/src/editor/core/view/NavBarPopup.tsx b/src/editor/core/view/NavBarPopup.tsx index a196795a8..363ba62df 100644 --- a/src/editor/core/view/NavBarPopup.tsx +++ b/src/editor/core/view/NavBarPopup.tsx @@ -210,3 +210,111 @@ function NavBar({ path, line, col }: { path: string; line: number; col: number } ); } + +/** + * 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([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(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('[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 ( + +
+ {stack.length > 1 && ( +
+ + {top.label} +
+ )} + {items?.length === 0 && ( +
+ Nothing here +
+ )} + {items?.map((c, i) => ( +
setActive(i)} + onClick={() => choose(c)} + > + + {c.label} + {c.kind === "dir" && } +
+ ))} +
+
+ ); +} diff --git a/src/editor/core/view/StatusItems.tsx b/src/editor/core/view/StatusItems.tsx index ced7676d6..853829ba8 100644 --- a/src/editor/core/view/StatusItems.tsx +++ b/src/editor/core/view/StatusItems.tsx @@ -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 ( ); } diff --git a/src/editor/core/view/navChildren.test.tsx b/src/editor/core/view/navChildren.test.tsx new file mode 100644 index 000000000..14ff595d4 --- /dev/null +++ b/src/editor/core/view/navChildren.test.tsx @@ -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(); + 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 }); + }); +}); diff --git a/src/editor/lsp/Popups.module.css b/src/editor/lsp/Popups.module.css index 2d7184802..433e9b403 100644 --- a/src/editor/lsp/Popups.module.css +++ b/src/editor/lsp/Popups.module.css @@ -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; +} diff --git a/src/editor/lsp/Popups.tsx b/src/editor/lsp/Popups.tsx index a854bfecc..4381de674 100644 --- a/src/editor/lsp/Popups.tsx +++ b/src/editor/lsp/Popups.tsx @@ -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 }) { + const focusSeq = useLspPopup((s) => s.focusSeq); + const scope = useRef(null); + const opened = useRef(focusSeq); + useEffect(() => { + if (focusSeq !== opened.current) scope.current?.focus({ preventScroll: true }); + }, [focusSeq]); return ( -
+
renderMarkup(popup.markup, popup.language)} />
@@ -319,6 +326,17 @@ function PreviewDialog({ popup }: { popup: Extract s.text); + if (!text) return null; + return ( + + {text} + + ); +} + /** Overlay host: renders the current LSP popup, if any. */ export function LspPopups() { const popup = useLspPopup((s) => s.popup); diff --git a/src/editor/lsp/actions.ts b/src/editor/lsp/actions.ts index 9bc45fac2..dff9b73c2 100644 --- a/src/editor/lsp/actions.ts +++ b/src/editor/lsp/actions.ts @@ -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"], }), diff --git a/src/editor/lsp/completion.test.ts b/src/editor/lsp/completion.test.ts index f2bcddbbd..0a92203ea 100644 --- a/src/editor/lsp/completion.test.ts +++ b/src/editor/lsp/completion.test.ts @@ -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 { 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(); + }); +}); diff --git a/src/editor/lsp/completion.ts b/src/editor/lsp/completion.ts index 4d25da4c8..964e4dcab 100644 --- a/src/editor/lsp/completion.ts +++ b/src/editor/lsp/completion.ts @@ -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 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 { 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 { const wait = delay ? new Promise((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 { }); } +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 (!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(); +/** Explicit invocations waiting for their first result (single candidate → insert it). */ +const explicitPending = new WeakSet(); + +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 = 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([ { diff --git a/src/editor/lsp/diagnostics.ts b/src/editor/lsp/diagnostics.ts index 7d627b5d0..f6351baf1 100644 --- a/src/editor/lsp/diagnostics.ts +++ b/src/editor/lsp/diagnostics.ts @@ -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", diff --git a/src/editor/lsp/features.test.ts b/src/editor/lsp/features.test.ts index f870d7f25..acd5c67a0 100644 --- a/src/editor/lsp/features.test.ts +++ b/src/editor/lsp/features.test.ts @@ -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 }, + ]); + }); +}); diff --git a/src/editor/lsp/hover.ts b/src/editor/lsp/hover.ts index 822651891..30f25db8f 100644 --- a/src/editor/lsp/hover.ts +++ b/src/editor/lsp/hover.ts @@ -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) { diff --git a/src/editor/lsp/index.ts b/src/editor/lsp/index.ts index 206dcdb1c..76b567ed1 100644 --- a/src/editor/lsp/index.ts +++ b/src/editor/lsp/index.ts @@ -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(), diff --git a/src/editor/lsp/inplaceRename.test.ts b/src/editor/lsp/inplaceRename.test.ts new file mode 100644 index 000000000..338f41bd0 --- /dev/null +++ b/src/editor/lsp/inplaceRename.test.ts @@ -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"); + }); +}); diff --git a/src/editor/lsp/inplaceRename.ts b/src/editor/lsp/inplaceRename.ts new file mode 100644 index 000000000..7c1fab393 --- /dev/null +++ b/src/editor/lsp/inplaceRename.ts @@ -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(); +const endSession = StateEffect.define(); +/** Marks the session's own edits (mirroring, restoring). */ +const inplaceEdit = Annotation.define(); + +const mapSpan = (s: Span, changes: ChangeSet): Span => ({ from: changes.mapPos(s.from, -1), to: changes.mapPos(s.to, 1) }); + +const sessionField = StateField.define({ + 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): 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]; +} diff --git a/src/editor/lsp/intentions.ts b/src/editor/lsp/intentions.ts index f6f9c1843..144513609 100644 --- a/src/editor/lsp/intentions.ts +++ b/src/editor/lsp/intentions.ts @@ -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 { + 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({ diff --git a/src/editor/lsp/markdown.ts b/src/editor/lsp/markdown.ts index 5a245a5fb..b07cf978c 100644 --- a/src/editor/lsp/markdown.ts +++ b/src/editor/lsp/markdown.ts @@ -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(); function findLanguage(name: string): LanguageDescription | null { diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index c5515a69c..4b648e228 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -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>; + +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(); const linkMark = Decoration.mark({ class: "cm-lsp-link" }); -const linkField = StateField.define({ - 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({ + 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(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(); + 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({ ), 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; }, }, diff --git a/src/editor/lsp/popupStore.ts b/src/editor/lsp/popupStore.ts index 738a707a0..3f827a318 100644 --- a/src/editor/lsp/popupStore.ts +++ b/src/editor/lsp/popupStore.ts @@ -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(() => ({ popup: null, seq: 0 })); +export const useLspPopup = create(() => ({ 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 })); diff --git a/src/editor/lsp/rename.ts b/src/editor/lsp/rename.ts index 96645a920..680a20b30 100644 --- a/src/editor/lsp/rename.ts +++ b/src/editor/lsp/rename.ts @@ -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 { 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 { + 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; diff --git a/src/editor/lsp/theme.ts b/src/editor/lsp/theme.ts index 95220c816..df8ba2b2c 100644 --- a/src/editor/lsp/theme.ts +++ b/src/editor/lsp/theme.ts @@ -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": { diff --git a/src/editor/navigation/index.ts b/src/editor/navigation/index.ts index 6cfb1e72f..c839f1ee5 100644 --- a/src/editor/navigation/index.ts +++ b/src/editor/navigation/index.ts @@ -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. */ diff --git a/src/features/build/Build.module.css b/src/features/build/Build.module.css index 237e4ebe6..be0f0c465 100644 --- a/src/features/build/Build.module.css +++ b/src/features/build/Build.module.css @@ -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; +} diff --git a/src/features/build/BuildView.tsx b/src/features/build/BuildView.tsx index 35b82e1bf..61f27239c 100644 --- a/src/features/build/BuildView.tsx +++ b/src/features/build/BuildView.tsx @@ -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 = { 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>(() => new Set()); + const tree = useRef(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 ( - - 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); +
{ + 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={} - /> + > + + 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={} + /> +
); } @@ -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 ( +
+ {location && ( + + )} +
{message.text}
+
+ ); +} + +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" ? : ; +} + export function BuildView() { const hasBuild = useBuildRun((s) => s.status !== null); if (!hasBuild) { @@ -237,7 +289,7 @@ export function BuildView() { - +
diff --git a/src/features/build/build.test.tsx b/src/features/build/build.test.tsx index 74352ba51..ac96fa84b 100644 --- a/src/features/build/build.test.tsx +++ b/src/features/build/build.test.tsx @@ -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; + let show: ReturnType; 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(); + 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 () => { diff --git a/src/features/build/model.ts b/src/features/build/model.ts index e12b616ac..79bb341d5 100644 --- a/src/features/build/model.ts +++ b/src/features/build/model.ts @@ -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 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 | 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; +} diff --git a/src/features/build/store.ts b/src/features/build/store.ts index 1912ee489..f481cc451 100644 --- a/src/features/build/store.ts +++ b/src/features/build/store.ts @@ -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(() => 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); diff --git a/src/features/debugger/__tests__/actions.test.ts b/src/features/debugger/__tests__/actions.test.ts index c323214e9..0fe45b753 100644 --- a/src/features/debugger/__tests__/actions.test.ts +++ b/src/features/debugger/__tests__/actions.test.ts @@ -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:"); + }); }); diff --git a/src/features/debugger/__tests__/breakpoints.test.ts b/src/features/debugger/__tests__/breakpoints.test.ts index 7b5f3413e..9aa2764f6 100644 --- a/src/features/debugger/__tests__/breakpoints.test.ts +++ b/src/features/debugger/__tests__/breakpoints.test.ts @@ -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="); + }); + + 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(); diff --git a/src/features/debugger/__tests__/editor.test.ts b/src/features/debugger/__tests__/editor.test.ts index 91caf776f..58ef03072 100644 --- a/src/features/debugger/__tests__/editor.test.ts +++ b/src/features/debugger/__tests__/editor.test.ts @@ -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)"); diff --git a/src/features/debugger/__tests__/valueChanges.test.ts b/src/features/debugger/__tests__/valueChanges.test.ts new file mode 100644 index 000000000..91512d608 --- /dev/null +++ b/src/features/debugger/__tests__/valueChanges.test.ts @@ -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); + }); +}); diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 24f63a461..b8013ddb7 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -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", diff --git a/src/features/debugger/breakpoints/model.ts b/src/features/debugger/breakpoints/model.ts index b5611e258..38a985874 100644 --- a/src/features/debugger/breakpoints/model.ts +++ b/src/features/debugger/breakpoints/model.ts @@ -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): Promise { + 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(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( 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 ); }); } diff --git a/src/features/debugger/breakpoints/store.ts b/src/features/debugger/breakpoints/store.ts index 932eb960e..60881aef8 100644 --- a/src/features/debugger/breakpoints/store.ts +++ b/src/features/debugger/breakpoints/store.ts @@ -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); diff --git a/src/features/debugger/editor/breakpointGutter.ts b/src/features/debugger/editor/breakpointGutter.ts index 615e1e62e..c5445fb78 100644 --- a/src/features/debugger/editor/breakpointGutter.ts +++ b/src/features/debugger/editor/breakpointGutter.ts @@ -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()); diff --git a/src/features/debugger/editor/editor.module.css b/src/features/debugger/editor/editor.module.css index 994ab2bb1..b45db337d 100644 --- a/src/features/debugger/editor/editor.module.css +++ b/src/features/debugger/editor/editor.module.css @@ -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); } diff --git a/src/features/debugger/editor/valueTooltip.ts b/src/features/debugger/editor/valueTooltip.ts index bdec001fc..a8af94adc 100644 --- a/src/features/debugger/editor/valueTooltip.ts +++ b/src/features/debugger/editor/valueTooltip.ts @@ -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() }; }, }; }, diff --git a/src/features/debugger/index.ts b/src/features/debugger/index.ts index a8dff400a..716836e77 100644 --- a/src/features/debugger/index.ts +++ b/src/features/debugger/index.ts @@ -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, diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index ce2b83249..f35a50e93 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -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)); diff --git a/src/features/debugger/ui/DebugToolWindow.tsx b/src/features/debugger/ui/DebugToolWindow.tsx index 791fd6d46..3435e312a 100644 --- a/src/features/debugger/ui/DebugToolWindow.tsx +++ b/src/features/debugger/ui/DebugToolWindow.tsx @@ -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" /> )} diff --git a/src/features/debugger/ui/Overlays.module.css b/src/features/debugger/ui/Overlays.module.css index 8f5da2e3d..125ecc169 100644 --- a/src/features/debugger/ui/Overlays.module.css +++ b/src/features/debugger/ui/Overlays.module.css @@ -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); +} diff --git a/src/features/debugger/ui/Overlays.tsx b/src/features/debugger/ui/Overlays.tsx index 27bc0b61f..a837d02b6 100644 --- a/src/features/debugger/ui/Overlays.tsx +++ b/src/features/debugger/ui/Overlays.tsx @@ -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 ( @@ -111,50 +90,13 @@ function BreakpointEditor({ {basename(path)}:{bp.line + 1} - updateBreakpoint(path, bp.id, { enabled })} - label="Enabled" + - - - - {logEnabled && ( - setLogMessage(e.target.value)} - onKeyDown={onKey} - placeholder="Value: {expression}" - data-autofocus={focusLog ? "" : undefined} - aria-label="Log message" - /> - )}
); @@ -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) => updateBreakpoint(path, bp.id, patch); return (
@@ -495,45 +563,12 @@ function BreakpointDetail({ row, onGo }: { row: BpRow; onGo: () => void }) {
{st &&
{st.verified ? "Verified" : (st.message ?? "Not verified")}
} - commit({ enabled })} label="Enabled" /> - commit({ temporary })} label="Remove once hit" /> - - - + + updateBreakpoint(path, bp.id, { temporary })} + label="Remove once hit" + />
); } @@ -635,15 +670,29 @@ function EvaluateDialog() { return ; } +/** + * 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(null); + const [history, setHistory] = useState(() => readHistory("evaluate")); + const [historyMenu, setHistoryMenu] = useState(false); + const historyPos = useRef(-1); + const historyButton = useRef(null); + const field = useRef(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) => { + 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 (
-